Skip to content
My Guidelines
Guides
In this guide (13 items)

GitHub Actions CI/CD

GitHub Actions — Reusable Workflow CI/CD Guideline

House standard for CI/CD on GitHub Actions, organized as a central library of reusable workflows (workflow_call) composed by thin per-repo callers. One platform repo owns the workflows; product repos invoke them. This keeps pipeline logic DRY, versioned, and auditable across every stack.

Examples use placeholders — ${ORG} (GitHub org), <stack> (java/krakend/ react/…), generic secret names. Replace with your real values; never commit account IDs, ARNs, or tokens.

1. Principles

  • Reusable-first. Pipeline logic lives in workflow_call workflows in a central ${ORG}/ci-templates repo. Product repos hold a ~20-line caller only.
  • Compose, don’t copy. A *-main-pipeline.yml orchestrates atomic reusable workflows via uses: + needs:. No step is duplicated across stacks.
  • Stack taxonomy. Files named <stack>-<concern>.yml; cross-stack ones shared-<concern>.yml. Predictable, greppable.
  • Everything gated, everything observable. Quality/security checks gate artifact+deploy; failures open issues and notify chat.
  • Least privilege. Explicit permissions: per workflow; pin actions; prefer OIDC over long-lived cloud keys.
  • Branch protection as code. Rulesets (JSON) version-control the merge rules.

2. Reusable workflow contract (workflow_call)

Every reusable workflow declares a typed interface: inputs (with type/required/default), secrets, and outputs wired from job/step outputs. Callers stay declarative.

# .github/workflows/java-build.yml (reusable)
name: Java - Build
on:
workflow_call:
inputs:
runner: { type: string, required: false, default: 'ubuntu-latest' }
java_version: { type: string, required: false, default: '21' }
java_distribution: { type: string, required: false, default: 'temurin' }
run_spotless: { type: boolean, required: false, default: true }
upload_artifact: { type: boolean, required: false, default: true }
secrets:
GH_PACKAGES_USERNAME: { required: false }
GH_PACKAGES_TOKEN: { required: false }
outputs:
artifact_name:
description: 'Name of the uploaded JAR artifact'
value: ${{ jobs.build.outputs.artifact_name }}
jobs:
build:
runs-on: ${{ inputs.runner }}
timeout-minutes: 15
permissions:
contents: read
outputs:
artifact_name: ${{ steps.jar-info.outputs.name }}
steps:
- uses: actions/checkout@v5
- uses: actions/setup-java@v5
with:
distribution: ${{ inputs.java_distribution }}
java-version: ${{ inputs.java_version }}
- name: Build
env:
GH_PACKAGES_USERNAME: ${{ secrets.GH_PACKAGES_USERNAME }}
GH_PACKAGES_TOKEN: ${{ secrets.GH_PACKAGES_TOKEN }}
run: ./gradlew clean build -x test --no-daemon --build-cache --parallel

Rules

  • Inputs optional with sane defaults; boolean flags toggle stages.
  • Declare each secret consumed; never read undeclared secrets.
  • Expose outputs from step IDs (steps.<id>.outputs.<k> → job output → workflow output) so orchestrators can chain them.
  • Set timeout-minutes and a minimal permissions: on every job.

3. Pipeline orchestration

A top-level pipeline composes reusable workflows. Stages run in parallel unless ordered by needs:; feature flags (run_* inputs) turn stages on/off; gating if: guards artifact/deploy on upstream success.

# java-main-pipeline.yml (orchestrator, itself workflow_call)
jobs:
commit-lint:
if: inputs.run_commit_lint
uses: ./.github/workflows/shared-commit-lint.yml
build:
if: inputs.run_build
uses: ./.github/workflows/java-build.yml
with: { upload_artifact: ${{ inputs.run_artifact }} }
secrets: inherit
test:
if: inputs.run_test
needs: build
uses: ./.github/workflows/java-test.yml
secrets: inherit
artifact-ecr:
if: |
inputs.run_artifact && always() &&
needs.build.result == 'success' &&
(needs.test.result == 'success' || needs.test.result == 'skipped')
needs: [build, test]
uses: ./.github/workflows/shared-artifact-docker-ecr.yml
with:
artifact_name: ${{ needs.build.outputs.artifact_name }}
image_tag: ${{ inputs.image_tag }}
secrets: inherit
deploy:
if: inputs.run_deploy
needs: [artifact-ecr]
uses: ./.github/workflows/shared-deploy-eks.yml
with: { image_tag: ${{ needs.artifact-ecr.outputs.image_tag }} }
secrets: inherit

Job graph

commit-lint security dependency-review (parallel, no deps)
└─► build ─► test · owasp · code-analysis (parallel on build)
└─► artifact-* (gated on all checks green/skipped)
└─► deploy ─► release ─► tag
└─► notify / create-issue-on-failure

Caller in a product repo — thin, just triggers + flags:

product-repo/.github/workflows/main.yml
on:
push:
branches: [main, develop]
paths-ignore: ['README.md', '.gitignore']
workflow_dispatch:
concurrency:
group: ${{ github.repository }}-${{ github.ref }}
cancel-in-progress: true
jobs:
pipeline:
uses: ${ORG}/ci-templates/.github/workflows/java-main-pipeline.yml@v1
with:
run_test: true
run_artifact: true
run_deploy: true
secrets: inherit

Rules

  • Pass data between jobs only via needs.<job>.outputs.<key>.
  • Use always() && needs.X.result == 'success' gates so skipped optional stages don’t block, but failed required stages do.
  • Set concurrency to cancel superseded runs per ref.
  • A separate *-pr-pipeline.yml runs lint/build/test/security on pull_request (no artifact/deploy).

4. Naming & taxonomy

Prefix Scope
java-*, react-*, krakend-*, nginx-*, contracts-* stack-specific
shared-* cross-stack (commit-lint, semver, release, artifact, deploy, notify)
security-* dependency review, secret scanning
<stack>-main-pipeline.yml / <stack>-pr-pipeline.yml orchestrators

Concerns: build, test, commit-lint, semver, artifact-docker-*, artifact-dependency-*, deploy-*, delete-branch, create-issue-on-failure, slack-notify, validate-source-branch, tag-release, release.

Promote a workflow to shared-* the moment a second stack needs it.

5. SemVer & release (from conventional commits)

Version is derived from commit history; tags + releases are automated. Pairs with git-workflow.md (conventional commits) — feat → minor, fix → patch, BREAKING CHANGE/MAJOR → major.

# shared-semver.yml (reusable)
on:
workflow_call:
inputs:
tag_prefix: { type: string, default: 'v' }
major_pattern: { type: string, default: '(MAJOR|BREAKING CHANGE)' }
minor_pattern: { type: string, default: '(feat)' }
outputs:
version: { value: ${{ jobs.semver.outputs.version }} }
jobs:
semver:
runs-on: ubuntu-latest
outputs: { version: ${{ steps.ver.outputs.version }} }
steps:
- uses: actions/checkout@v5
with: { fetch-depth: 0 } # full history for version calc
- id: ver
uses: PaulHatch/semantic-version@v5.4.0
with: { tag_prefix: ${{ inputs.tag_prefix }} }

Release stage tags vX.Y.Z, generates a changelog from commits, and creates the GitHub Release — runs only after a successful deploy. Tags are immutable (§8).

6. Security gates

Run as parallel jobs that gate artifact/deploy; keep them permissions: contents: read unless they must write.

Check Action Trigger Gate
Dependency review actions/dependency-review-action@v5 PR only fail-on-severity: high + license allow/deny
Secret scan trufflesecurity/trufflehog@<sha> (SHA-pinned) push + PR --only-verified, fail on findings
Vulnerable deps OWASP Dependency-Check (Gradle/Maven) build fail on CRITICAL, optionally HIGH (CVSS threshold)
Static analysis Qodana / SonarQube quality gate build gate status must pass
# security-trufflehog.yml — pin third-party actions to a full SHA
- uses: trufflesecurity/trufflehog@37b77001d0174ebec2fcca2bd83ff83a6d45a3ab # v3.95.3
with:
extra_args: ${{ inputs.only_verified && '--only-verified' || '' }}

OWASP gate pattern (parse JSON report, fail by severity):

Terminal window
CRITICAL=$(jq '[.dependencies[]?.vulnerabilities[]? | select(.severity=="CRITICAL")] | length' "$REPORT")
[ "$CRITICAL" -gt 0 ] && { echo "::error::$CRITICAL CRITICAL vulns"; exit 1; }

7. Deploy

Reusable shared-deploy-* per target (EKS, EC2, S3). Take image_tag + environment; bind to a GitHub Environment for approvals/secrets.

deploy:
environment: ${{ inputs.environment }} # enables required reviewers + env secrets
permissions:
id-token: write # OIDC — preferred over static keys
contents: read
steps:
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: ${{ secrets.AWS_DEPLOY_ROLE_ARN }} # OIDC role, not access keys
aws-region: ${{ secrets.AWS_REGION }}

Rules

  • Prefer OIDC (id-token: write + role-to-assume) over AWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEY long-lived secrets.
  • Gate prod deploys behind a GitHub Environment with required reviewers.
  • Deploy the immutable artifact built earlier (needs.artifact.outputs.image_tag); never rebuild in the deploy job.
  • One reusable workflow per target; pick the target via a caller input.

8. Branch protection as code (rulesets)

Store GitHub rulesets as JSON in .github/ruleset/ and apply via API/Terraform. Production branches (main, develop) and release tags are protected.

{
"name": "Protect main (PR-only, 2 approvals, linear, squash)",
"target": "branch",
"enforcement": "active",
"conditions": { "ref_name": { "include": ["refs/heads/main"] },
"repository_name": { "include": ["${ORG}-ms-*", "${ORG}-sdk-*"] } },
"rules": [
{ "type": "deletion" },
{ "type": "non_fast_forward" },
{ "type": "required_linear_history" },
{ "type": "pull_request", "parameters": {
"dismiss_stale_reviews_on_push": true,
"require_last_push_approval": true,
"required_approving_review_count": 2,
"required_review_thread_resolution": true,
"allowed_merge_methods": ["squash"] } },
{ "type": "required_status_checks", "parameters": {
"strict_required_status_checks_policy": true,
"required_status_checks": [ { "context": "validate / PR Quality Gates" } ] } }
],
"bypass_actors": []
}

Tag ruleset (release immutability) — target: "tag", refs/tags/v[0-9]*.[0-9]*.[0-9]*, rules creation/deletion/non_fast_forward/update, bypass only the Actions bot + admin role. Use enforcement: "evaluate" to dry-run a ruleset before flipping to "active".

9. Cross-cutting reusable workflows

  • commit-lint (shared-commit-lint.yml): npx commitlint over the PR commit range (--from base.sha --to head.sha) or HEAD~1..HEAD on push. Enforces conventional commits.
  • create-issue-on-failure: opens a labelled GitHub issue with repo/branch/ actor/run-link when the pipeline fails.
  • slack-notify: posts status to a channel on failure/deploy/release.
  • delete-branch: deletes the merged source branch on push to a protected branch.
  • validate-source-branch: enforces branch-naming (feature/*, fix/*, …) before allowing a PR to proceed.

10. CODEOWNERS, action pinning, permissions

  • CODEOWNERS in .github/: at least a default owner (* @${TEAM}); scope per path for sensitive areas (/.github/workflows/ @${PLATFORM_TEAM}).
  • Pin actions: first-party (actions/*, aws-actions/*) to a major tag (@v5); third-party to a full commit SHA with a version comment. Never use @main/@master.
  • Permissions: set the minimum at workflow or job level. Default the repo to permissions: {} and grant per job (contents: read, add id-token: write only where OIDC is needed, packages: write only to publish).
  • Secrets: pass with secrets: inherit to reusable workflows; declare each in the secrets: block; never echo a secret; mask derived values.

11. Consumer adoption (thin callers + GitFlow stage matrix)

The reusable workflows live once in the central ci-templates repo. Each product repo ships thin ~20-line callers that uses: them — no build logic, just with: inputs + secrets: inherit. Adopt a stack by copying its stage templates into .github/workflows/ and replacing <org>:

Terminal window
cp templates/java-validate.yml .github/workflows/ # PR + branch gate
cp templates/java-develop-deploy.yml .github/workflows/ # dev on merge
cp templates/java-release-deploy.yml .github/workflows/ # staging on release/*
cp templates/java-main-deploy.yml .github/workflows/ # prod on merge to main
# then: replace <org> with your GitHub org in each `uses:` line

GitFlow stage → caller matrix (one caller per lifecycle stage):

Caller Trigger Calls Flow
validate.yml push feature/*·bugfix/*·hotfix/*, PR→develop *-pr-pipeline.yml build→test→coverage→security. No deploy. Job id = required status check
develop-deploy.yml push→develop *-main-pipeline.yml build→test→artifact(ECR)→deploy(dev)→delete-branch→release PR
release-deploy.yml push release/* *-main-pipeline.yml build→test→artifact→deploy(staging)
main-deploy.yml push→main *-main-pipeline.yml build→artifact→deploy(prod)→tag vX.Y.Z+Release

deploy_target input — pick per caller; the orchestrator routes to the matching shared-deploy-* workflow (§7):

Value Target
ec2 SSH deploy to a directly reachable EC2
ec2-vpn SSH deploy to a private EC2; runner brings up a WireGuard wg0 tunnel, deploys, tears it down
eks EKS cluster (plain manifests or Helm)
s3 static site upload (react/nginx)

Rules

  • Callers stay declarative — only uses: + with: + secrets: inherit. Any real step belongs in the reusable library, not the caller.
  • Pin uses: to a release tag (@v1) or SHA on prod-facing callers — not a moving branch (@main). §10.
  • The validate job id must match the ruleset’s required-status-check context (e.g. validate / PR Quality Gates) — §8, or the gate silently passes.
  • Ratchet quality gates per repo: set coverage_*_threshold / owasp_fail_on_cvss to the current baseline, raise over time — never a global hardcode.
  • Document the required secrets per deploy_target (AWS/ECR always; EC2 SSH set for ec2/ec2-vpn; extra WG_* for ec2-vpn; SLACK_WEBHOOK_URL for notifications) in the caller header.

12. Anti-patterns

Copy-pasting steps across repos central workflow_call library + thin callers
uses: some/action@main pin to tag (first-party) or SHA (third-party)
permissions: write-all / default token everywhere least-privilege permissions: per job
Long-lived AWS_* keys in secrets OIDC id-token: write + role-to-assume
Rebuilding the image in the deploy job deploy the artifact built upstream
Secrets in with: inputs or run: echoes secrets: block + ${{ secrets.X }} env
Unbounded jobs timeout-minutes on every job
No concurrency control concurrency.cancel-in-progress per ref
Direct push to main PR-only ruleset, squash, linear history
Mutable release tags tag ruleset: no update/delete/force-push
Quality/security checks that don’t gate needs: + if: gate artifact/deploy
Build logic pasted into a product-repo caller thin caller: uses: + with: + secrets: inherit
validate job id ≠ ruleset required-check context match exactly, or the gate no-ops

13. Pre-merge checklist

  • New pipeline logic added as a reusable workflow_call, not inline per repo.
  • Inputs typed with defaults; secrets declared; outputs wired from step IDs.
  • Orchestrator gates artifact/deploy on upstream success/skipped.
  • permissions: minimal per job; OIDC for cloud, no static keys.
  • Actions pinned (tag for first-party, SHA for third-party); no @main.
  • timeout-minutes + concurrency set.
  • Security gates present (dependency review, secret scan, vuln scan) and gating.
  • commit-lint runs; conventional commits enforced (see git-workflow.md).
  • Prod deploy behind a GitHub Environment with required reviewers.
  • Branch + tag rulesets updated if protection rules changed.
  • No org-specific tokens (account IDs, ARNs, secret values) committed.

CI/CD on GitHub Actions via a central reusable-workflow library. Pairs with git-workflow.md (conventional commits + branching that drive semver), gradle.md (build tasks invoked by java-build), and security.md (what the security gates enforce).