Skip to content

Usage Guide#

This guide shows how to consume reusable workflows and composite actions from another repository.

Reusable workflows#

Create a workflow in your repo, then call the reusable workflow via uses.

Gitleaks on PRs:

name: Secrets Scan
on:
  pull_request:
    branches: [ main, master ]

jobs:
  gitleaks:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Run gitleaks scan
        uses: nikolareljin/ci-helpers/.github/actions/gitleaks-scan@production
        with:
          scan_path: "."
          fail_on_findings: "true"
          upload_artifact: "true"

PHP scan (unit + framework lint + WP-CLI when detected):

name: PHP Scan
on:
  pull_request:
    branches: [ main, master ]

jobs:
  php_scan:
    uses: nikolareljin/ci-helpers/.github/workflows/php-scan.yml@production
    with:
      php_version: "8.4"

Python scan (unit + Django when detected):

name: Python Scan
on:
  pull_request:
    branches: [ main, master ]

jobs:
  python_scan:
    uses: nikolareljin/ci-helpers/.github/workflows/python-scan.yml@production
    with:
      python_version: "3.13"

Go scan (tests + gosec):

name: Go Scan
on:
  pull_request:
    branches: [ main, master ]

jobs:
  go_scan:
    uses: nikolareljin/ci-helpers/.github/workflows/go-scan.yml@production

Rust scan (tests + cargo-audit):

name: Rust Scan
on:
  pull_request:
    branches: [ main, master ]

jobs:
  rust_scan:
    uses: nikolareljin/ci-helpers/.github/workflows/rust-scan.yml@production

Java scan (tests + dependency check):

name: Java Scan
on:
  pull_request:
    branches: [ main, master ]

jobs:
  java_scan:
    uses: nikolareljin/ci-helpers/.github/workflows/java-scan.yml@production
    with:
      java_version: "17"

C# scan (tests + vulnerable packages):

name: C# Scan
on:
  pull_request:
    branches: [ main, master ]

jobs:
  csharp_scan:
    uses: nikolareljin/ci-helpers/.github/workflows/csharp-scan.yml@production
    with:
      dotnet_version: "8.0.x"

Node.js scan (lint/test/audit):

name: Node Scan
on:
  pull_request:
    branches: [ main, master ]

jobs:
  node_scan:
    uses: nikolareljin/ci-helpers/.github/workflows/node-scan.yml@production
    with:
      node_version: "22"

React scan (lint/test/build/audit):

name: React Scan
on:
  pull_request:
    branches: [ main, master ]

jobs:
  react_scan:
    uses: nikolareljin/ci-helpers/.github/workflows/react-scan.yml@production
    with:
      node_version: "22"

Vue scan (lint/test/build/audit):

name: Vue Scan
on:
  pull_request:
    branches: [ main, master ]

jobs:
  vue_scan:
    uses: nikolareljin/ci-helpers/.github/workflows/vue-scan.yml@production
    with:
      node_version: "22"

Docker scan (Trivy + Snyk):

name: Docker Scan
on:
  pull_request:
    branches: [ main, master ]

jobs:
  docker_scan:
    uses: nikolareljin/ci-helpers/.github/workflows/docker-scan.yml@production
    with:
      image_name: "app:ci"
    secrets:
      snyk_token: ${{ secrets.SNYK_TOKEN }}

Composite actions#

Composite actions run inside a normal job. Always include actions/checkout.

Gitleaks composite action:

name: Secrets Scan
on:
  pull_request:
    branches: [ main, master ]

jobs:
  gitleaks:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Gitleaks scan
        uses: nikolareljin/ci-helpers/.github/actions/gitleaks-scan@production
        with:
          scan_path: "."
          fail_on_findings: "true"
          upload_artifact: "true"

Trivy composite action:

name: Trivy Scan
on:
  pull_request:
    branches: [ main, master ]

jobs:
  trivy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Trivy scan
        uses: nikolareljin/ci-helpers/.github/actions/trivy-scan@production
        with:
          scan_path: "."
          format: "sarif"
          output: "trivy-results.sarif"
          fail_on_findings: "true"
          upload_sarif: "true"

Data-safety composite action:

name: Data Safety
on:
  pull_request:
    branches: [ main, master ]

jobs:
  data_safety:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v5
      - name: Data safety scan
        uses: nikolareljin/ci-helpers/.github/actions/data-safety-scan@production
        with:
          scan_path: backend/app/data

WordPress plugin-check composite action:

name: WP Plugin Check
on:
  pull_request:
    branches: [ main, master ]

jobs:
  plugin_check:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Plugin check
        uses: nikolareljin/ci-helpers/.github/actions/wp-plugin-check@production
        with:
          plugin_slug: my-plugin
          plugin_src_env: MY_PLUGIN_SRC
          plugin_src: "."
          php_version: "8.4"
          phpunit_command: "vendor/bin/phpunit"
          phpcs_warning_command: "vendor/bin/phpcs -p -s --warning-severity=1 --error-severity=0 ."
          fail_on_findings: "true"

Semver compare:

name: Compare Versions
on:
  pull_request:
    branches: [ main, master ]

jobs:
  semver:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Compare versions
        id: semver
        uses: nikolareljin/ci-helpers/.github/actions/semver-compare@production
        with:
          version_a: "1.2.3"
          version_b: "1.4.0"
      - name: Use result
        env:
          RESULT: ${{ steps.semver.outputs.result }}
        run: echo "result=$RESULT"

Release notes come from the release workflows, through script-helpers' release_notes.sh. release-build.yml, go-release.yml and rust-release.yml generate them when generate_release_notes is true, or take your own text in release_notes. deb-build.yml, fpc-release.yml, rust-release-tarballs.yml and tauri-release.yml generate them whenever they upload to a release.

Release tag guard (PR):

name: Release Tag Guard (PR)
on:
  pull_request:
    branches: [ main, master ]

jobs:
  release_guard:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v5
        with:
          fetch-depth: 0
          fetch-tags: true
      - name: Guard release tag
        id: release_guard
        uses: nikolareljin/ci-helpers/.github/actions/check-release-tag@production
        with:
          release_branch: ${{ github.head_ref }}
          fetch_tags: true
      - name: Use version
        env:
          VERSION: ${{ steps.release_guard.outputs.version }}
        run: echo "version=$VERSION"

Release tag guard (release):

name: Release Tag Guard (Release)
on:
  release:
    types: [ created, published ]

jobs:
  release_guard:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v5
        with:
          fetch-depth: 0
          fetch-tags: true
      - name: Guard release tag
        id: release_guard
        uses: nikolareljin/ci-helpers/.github/actions/check-release-tag@production
        with:
          release_branch: ${{ github.ref_name }}
          fetch_tags: true
      - name: Use version
        env:
          VERSION: ${{ steps.release_guard.outputs.version }}
        run: echo "version=$VERSION"

Release tag gate (reusable workflow):

name: Release Tag Gate
on:
  pull_request:

jobs:
  gate:
    uses: nikolareljin/ci-helpers/.github/workflows/release-tag-gate.yml@production

Auto tag release (reusable workflow):

name: Auto Tag Release
on:
  push:
    branches: [ main, master ]

permissions:
  contents: write
  pull-requests: read

jobs:
  tag:
    uses: nikolareljin/ci-helpers/.github/workflows/auto-tag-release.yml@production

Install specific versions from Git tags#

When you publish tagged releases, you can install a specific version directly:

Go:

GOBIN="$HOME/.local/bin" go install github.com/OWNER/REPO@v1.2.3

Rust:

cargo install --git https://github.com/OWNER/REPO --tag v1.2.3 --bin your-binary

Python:

python -m pip install "git+https://github.com/OWNER/REPO@v1.2.3"

Java (build from tag):

git clone --branch v1.2.3 https://github.com/OWNER/REPO.git
cd REPO
./mvnw -DskipTests package

Production tag (optional)#

This repo includes a repo-local workflow that updates the production tag to the latest non-rc tag and moves the production branch to the same commit (create_production.sh does both unless passed --no-branch; consumers that reference @production get it either way). It is not a reusable workflow; copy it into other repos only if you want the same behavior. On the automated path the tag is cut by auto-tag.yml after a merge to main, and the security gate runs in auto-tag-release-push.yml before it.

name: Update Production Tag
on:
  push:
    tags:
      - "*.*.*"
      - "v*.*.*"

permissions:
  contents: write

jobs:
  security-gate:
    if: ${{ !contains(github.ref_name, 'rc') && !contains(github.ref_name, 'RC') }}
    runs-on: ubuntu-latest
    timeout-minutes: 60
    permissions:
      contents: read
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @ 2026-09-10
        with:
          fetch-depth: 0
      - name: Check stale SHA pins
        run: bash scripts/update_pinned_actions.sh --check
        env:
          GH_TOKEN: ${{ github.token }}
      - name: Detect floating action refs
        run: bash scripts/check_floating_refs.sh

  production:
    needs: [security-gate]
    if: ${{ !contains(github.ref_name, 'rc') && !contains(github.ref_name, 'RC') }}
    runs-on: ubuntu-latest
    timeout-minutes: 60
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @ 2026-09-10
        with:
          fetch-depth: 0
          fetch-tags: true
      - name: Update production tag
        run: ./scripts/create_production.sh -t "${GITHUB_REF_NAME}" --fetch-tags

This mirrors .github/workflows/production-branch.yml (which additionally bootstraps vendor/script-helpers when the repository carries it as a submodule). contents: write is granted only to the job that pushes; the gate reads.

What create_production.sh checks before it moves anything:

  • the tag exists on the remote and names the same commit as the local tag -- a tag that was never pushed, or was re-cut locally, is refused;
  • with --fetch-tags, a fetch that fails stops the run (exit 1) instead of continuing on whatever tags the checkout had;
  • --name main, master and HEAD are refused (exit 2);
  • the production branch is pushed with a lease on the value the script read from the remote before it pushed anything, so a concurrent move of that branch makes the push fail instead of being overwritten;
  • the tag is a release, X.Y.Z or vX.Y.Z (--allow-prerelease also accepts X.Y.Z-<suffix>);
  • the tag's commit is on the remote's default branch, as the remote has it (--base-branch names another branch). A fetch error or missing history refuses too;
  • the version is newer than the one production is on now, read from the version tags at its commit (--allow-rollback lifts this). The same tag again is a no-op success.

Manual override (point production at an older tag):

./scripts/create_production.sh -t 1.2.2 --fetch-tags --allow-rollback

This force-moves a shared ref. The production tag is pushed with --force and the production branch with a lease, so every consumer pinned to @production picks up the target on its next run. Run it only as a deliberate release decision, and prefer update-production.yml or the tag workflow above.

Release build (generic):

name: Release Build
on:
  push:
    tags: [ "v*.*.*" ]

jobs:
  release:
    uses: nikolareljin/ci-helpers/.github/workflows/release-build.yml@production
    with:
      test_command: "go test ./..."
      build_command: "go build -o dist/myapp ./cmd/myapp"
      artifact_paths: "dist/*"
      binary_links: |
        Linux|myapp-linux
        macOS|myapp-mac

PPA publish:

name: PPA
on:
  push:
    branches: [ main ]

jobs:
  ppa:
    uses: nikolareljin/ci-helpers/.github/workflows/ppa-deb.yml@production
    with:
      working_directory: "."
      ppa_target: "ppa:your-launchpad-id/distrodeck"
      deb_fullname: "Nikola Reljin"
      deb_email: "nikola.reljin@gmail.com"
      signing_key_id: ${{ secrets.PPA_GPG_KEY_ID }}
    secrets:
      gpg_private_key: ${{ secrets.PPA_GPG_PRIVATE_KEY }}
      gpg_passphrase: ${{ secrets.PPA_GPG_PASSPHRASE }}
      launchpad_ssh_private_key: ${{ secrets.PPA_SSH_PRIVATE_KEY }}

Required secrets for PPA publish:

  • PPA_GPG_PRIVATE_KEY: armored private key used to sign the source package.
  • PPA_GPG_PASSPHRASE: passphrase for the signing key.
  • PPA_GPG_KEY_ID: key ID or fingerprint (passed as an input).
  • PPA_SSH_PRIVATE_KEY: SSH key registered with Launchpad for uploads. Optional inputs:

  • series: distro codename override for debian/changelog (uses dch).

Debian package build:

name: Debian Build
on:
  push:
    branches: [ main ]

jobs:
  deb:
    uses: nikolareljin/ci-helpers/.github/workflows/deb-build.yml@production
    with:
      working_directory: "."

RPM package build:

name: RPM Build
on:
  push:
    branches: [ main ]

jobs:
  rpm:
    uses: nikolareljin/ci-helpers/.github/workflows/rpm-build.yml@production
    with:
      working_directory: "."
      prebuild_command: "./tools/gen-man.sh"
      spec_path: "packaging/myapp.spec"
      artifact_glob: "dist/*.rpm"

Homebrew package build + publish:

name: Homebrew
on:
  push:
    branches: [ main ]

jobs:
  brew:
    uses: nikolareljin/ci-helpers/.github/workflows/homebrew-package.yml@production
    with:
      working_directory: "."
      name: "myapp"
      desc: "My CLI tool"
      homepage: "https://github.com/owner/myapp"
      deps: "curl,jq"
      entrypoint: "bin/myapp"
      man_path: "docs/man/myapp.1"
      use_libexec: "true"
      env_var: "MYAPP_ROOT"
      release_repo: "owner/myapp"
      publish: ${{ vars.HOMEBREW_PUBLISH_ENABLED }}
      tap_repo: ${{ vars.HOMEBREW_TAP_REPO }}
      tap_branch: ${{ vars.HOMEBREW_TAP_BRANCH }}
    secrets:
      tap_deploy_key: ${{ secrets.HOMEBREW_TAP_DEPLOY_KEY }}

Rust release build:

name: Rust Release
on:
  push:
    tags: [ "v*.*.*" ]

jobs:
  release:
    uses: nikolareljin/ci-helpers/.github/workflows/rust-release.yml@production
    with:
      bin_name: "image-view"
      linux_gnu_aliases: "deb,pacman,yum,redhat"

Go release build:

name: Go Release
on:
  push:
    tags: [ "v*.*.*" ]

jobs:
  release:
    uses: nikolareljin/ci-helpers/.github/workflows/go-release.yml@production
    with:
      bin_name: "myapp"
      main_path: "./cmd/myapp"
      build_targets: "linux/amd64,windows/amd64,darwin/amd64"

Java release build (Maven example):

name: Java Release
on:
  push:
    tags: [ "v*.*.*" ]

jobs:
  release:
    uses: nikolareljin/ci-helpers/.github/workflows/release-build.yml@production
    with:
      java_version: "17"
      test_command: "mvn -B test"
      build_command: "mvn -B package -DskipTests"
      artifact_paths: "target/*.jar"

Release tagging in external repos (recommended setup):

1) Add the PR gate to block merges when the tag already exists. 2) Require the gate in branch protection for your default branch. 3) Add the auto-tag workflow on the default branch; it fails if the tag already exists.

Minimal setup:

name: Release Tag Gate
on:
  pull_request:

jobs:
  gate:
    uses: nikolareljin/ci-helpers/.github/workflows/release-tag-gate.yml@production
name: Auto Tag Release
on:
  push:
    branches: [ main, master ]

permissions:
  contents: write
  pull-requests: read

jobs:
  tag:
    uses: nikolareljin/ci-helpers/.github/workflows/auto-tag-release.yml@production