Re-usable GitHub Actions and Workflows

GitHub Actions

Retry Command

This GitHub Action retries a shell command if it fails. It is useful for handling flaky tests in CI/CD pipelines.

Inputs

  • command (required): The shell command to run.

  • max_attempts (optional): The number of retry attempts. Defaults to 3.

  • delay_seconds (optional): The delay between retries in seconds. Defaults to 5.

Usage Example

You can use this action in your workflow as follows:

name: Retry Example

on:
  push:
    branches:
      - main

jobs:
  retry-command-example:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout code
        uses: actions/checkout@v6

      - name: Test
        uses: openwisp/openwisp-utils/.github/actions/retry-command@master
        with:
          delay_seconds: 30
          max_attempts: 5
          command: ./runtests.py --parallel
        env:
          SELENIUM_HEADLESS: 1

This example retries the ./runtests.py --parallel command up to 5 times with a 30 second delay between attempts.

Note

If the command continues to fail after the specified number of attempts, the action will exit with a non-zero status, causing the workflow to fail.

Auto-Assignment Bot

A collection of Python scripts that automate issue and PR management for OpenWISP repositories. The bot provides the following features:

  • Issue auto-assignment: When a contributor opens a PR referencing an issue (e.g., Fixes #123), the issue is automatically assigned to the PR author.

  • Assignment request responses: When someone comments asking to be assigned, the bot responds with contributing guidelines explaining that no assignment is needed — just open a PR.

  • Stale PR management: Warns PR authors after 7 days of inactivity, marks stale and unassigns after 14 days, and posts a final follow-up encouragement after 60 days. The bot does not auto-close PRs.

  • PR reopen reassignment: When a stale PR is reopened, linked issues are reassigned back to the author.

How Stale PR Detection Works

The Stale PR job runs daily. For each open PR:

  1. Trigger condition. The PR is processed only when at least one human reviewer's latest non-COMMENTED review is CHANGES_REQUESTED. Bot reviews and reviews later superseded by APPROVED or DISMISSED from the same reviewer do not block.

  2. Inactivity is the time since the more recent of: the PR author's latest commit, issue comment, review comment, or review after the blocking review; or the blocking review's timestamp if the author has not acted since. For commits the date is taken from whichever identity (author or committer) matches the PR author, so a maintainer rebasing the contributor's commits does not reset the clock.

  3. Maintainer-court skip. If the contributor has responded but no maintainer (OWNER, MEMBER or COLLABORATOR) has submitted a review since, the PR is skipped — the ball is in the maintainer's court. Comments are not reviews.

  4. Action by days inactive:

    • 7–13 days: posts a stale-warning comment.

    • ≥ 14 days: adds the stale label and unassigns the contributor from linked issues. Any subsequent author activity (push or comment) unwinds the label and reassigns linked issues on the next daily run; an author comment also triggers the immediate recovery bot.

    • ≥ 60 days: posts a final follow-up comment asking whether the contributor is still working on it. The PR is not auto-closed; maintainers may close manually if needed.

    Each stage posts at most once per blocking review cycle.

Secrets

These secrets are used by the workflow to generate a GITHUB_TOKEN via the actions/create-github-app-token action. The bot itself consumes the following environment variables at runtime: GITHUB_TOKEN, REPOSITORY, GITHUB_EVENT_NAME, and BOT_USERNAME (optional; defaults to openwisp-companion).

  • OPENWISP_BOT_APP_ID (required): OpenWISP Bot GitHub App ID.

  • OPENWISP_BOT_PRIVATE_KEY (required): OpenWISP Bot GitHub App private key.

Setup for Other Repositories

To enable the auto-assignment bot in another OpenWISP repository, you must create four workflow files under .github/workflows/ that call the reusable GitHub Workflow. This reusable workflow automatically handles token generation, environment setup, and executing the bot scripts.

Note

Each caller workflow must declare its own permissions block. GitHub Actions reusable workflows inherit permissions from the caller, so the reusable workflow cannot set them on its own.

Create the following workflow files in your repository.

1. Issue Assignment Bot (.github/workflows/bot-autoassign-issue.yml)

name: Issue Assignment Bot
on:
  issue_comment:
    types: [created]
permissions:
  contents: read
  issues: write
concurrency:
  group: bot-autoassign-issue-${{ github.repository }}-${{ github.event.issue.number }}
  cancel-in-progress: true
jobs:
  respond-to-assign-request:
    if: github.event.issue.pull_request == null
    uses: openwisp/openwisp-utils/.github/workflows/reusable-bot-autoassign.yml@master
    with:
      bot_command: issue_assignment
    secrets:
      OPENWISP_BOT_APP_ID: ${{ secrets.OPENWISP_BOT_APP_ID }}
      OPENWISP_BOT_PRIVATE_KEY: ${{ secrets.OPENWISP_BOT_PRIVATE_KEY }}

2. PR Issue Link (.github/workflows/bot-autoassign-pr-issue-link.yml)

name: PR Issue Auto-Assignment
on:
  pull_request_target:
    types: [opened, reopened, closed]
permissions:
  contents: read
  issues: write
  pull-requests: read
concurrency:
  group: bot-autoassign-pr-link-${{ github.repository }}-${{ github.event.pull_request.number }}-${{ github.event.action }}
  cancel-in-progress: true
jobs:
  auto-assign-issue:
    if: github.event.action != 'closed' || github.event.pull_request.merged == false
    uses: openwisp/openwisp-utils/.github/workflows/reusable-bot-autoassign.yml@master
    with:
      bot_command: issue_assignment
    secrets:
      OPENWISP_BOT_APP_ID: ${{ secrets.OPENWISP_BOT_APP_ID }}
      OPENWISP_BOT_PRIVATE_KEY: ${{ secrets.OPENWISP_BOT_PRIVATE_KEY }}

3. PR Reopen (.github/workflows/bot-autoassign-pr-reopen.yml)

name: PR Reopen Reassignment
on:
  pull_request_target:
    types: [reopened]
  issue_comment:
    types: [created]
permissions:
  contents: read
  issues: write
  pull-requests: write
concurrency:
  group: bot-autoassign-pr-reopen-${{ github.repository }}-${{ github.event.pull_request.number || github.event.issue.number }}
  cancel-in-progress: true
jobs:
  reassign-on-reopen:
    if: github.event_name == 'pull_request_target' && github.event.action == 'reopened'
    uses: openwisp/openwisp-utils/.github/workflows/reusable-bot-autoassign.yml@master
    with:
      bot_command: pr_reopen
    secrets:
      OPENWISP_BOT_APP_ID: ${{ secrets.OPENWISP_BOT_APP_ID }}
      OPENWISP_BOT_PRIVATE_KEY: ${{ secrets.OPENWISP_BOT_PRIVATE_KEY }}
  handle-pr-activity:
    if: github.event_name == 'issue_comment' && github.event.issue.pull_request && github.event.issue.user.login == github.event.comment.user.login
    uses: openwisp/openwisp-utils/.github/workflows/reusable-bot-autoassign.yml@master
    with:
      bot_command: pr_reopen
    secrets:
      OPENWISP_BOT_APP_ID: ${{ secrets.OPENWISP_BOT_APP_ID }}
      OPENWISP_BOT_PRIVATE_KEY: ${{ secrets.OPENWISP_BOT_PRIVATE_KEY }}

Note

Both jobs use bot_command: pr_reopen. The pr_reopen command dispatches to PRReopenBot on pull_request_target events (to reassign issues when a PR is reopened) and to PRActivityBot on issue_comment events (to remove the stale label when the PR author comments on their stale PR).

4. Stale PR (.github/workflows/bot-autoassign-stale-pr.yml)

name: Stale PR Management
on:
  schedule:
    - cron: "0 0 * * *"
  workflow_dispatch:
permissions:
  contents: read
  issues: write
  pull-requests: write
concurrency:
  group: bot-autoassign-stale-pr-${{ github.repository }}
  cancel-in-progress: false
jobs:
  manage-stale-prs-python:
    uses: openwisp/openwisp-utils/.github/workflows/reusable-bot-autoassign.yml@master
    with:
      bot_command: stale_pr
    secrets:
      OPENWISP_BOT_APP_ID: ${{ secrets.OPENWISP_BOT_APP_ID }}
      OPENWISP_BOT_PRIVATE_KEY: ${{ secrets.OPENWISP_BOT_PRIVATE_KEY }}

Overriding the bot username

All four caller workflows accept an optional bot_username input (default openwisp-companion). The bot uses it to detect mentions of the form @<bot_username> assign in issue comments and to ignore comments authored by the bot itself. Set this if your repository uses a different GitHub App username:

jobs:
  respond-to-assign-request:
    uses: openwisp/openwisp-utils/.github/workflows/reusable-bot-autoassign.yml@master
    with:
      bot_command: issue_assignment
      bot_username: my-custom-bot
    secrets:
      OPENWISP_BOT_APP_ID: ${{ secrets.OPENWISP_BOT_APP_ID }}
      OPENWISP_BOT_PRIVATE_KEY: ${{ secrets.OPENWISP_BOT_PRIVATE_KEY }}

GitHub Workflows

Replicate Commits to Version Branch

This re-usable workflow replicates commits from the master branch to a version branch. The version branch name is derived from the version of the Python package specified in the workflow.

Version branches are essential during development to ensure that each OpenWISP module depends on compatible versions of its OpenWISP dependencies. Without version branches, modules depending on the master branch of other modules may encounter errors, as the master branch could include future changes that are incompatible with previous versions. This makes it impossible to build a specific commit reliably after such changes.

To address this, we use version branches so that each module can depend on a compatible version of its dependencies during development. Managing these version branches manually is time-consuming, which is why this re-usable GitHub workflow automates the process of keeping version branches synchronized with the master branch.

You can invoke this workflow from another workflow using the following example:

name: Replicate Commits to Version Branch

on:
  push:
    branches:
      - master

jobs:
  version-branch:
    uses: openwisp/openwisp-utils/.github/workflows/reusable-version-branch.yml@master
    with:
      # The name of the Python package (required)
      module_name: openwisp_utils
      # Whether to install the Python package. Defaults to false.
      install_package: true

Note

If the master branch is force-pushed, this workflow will fail due to conflicts. To resolve this, you must manually synchronize the version branch with the master branch. You can use the following commands to perform this synchronization:

VERSION=<enter-version-number> # e.g. 1.2
git fetch origin
git checkout $VERSION
git reset --hard origin/master
git push origin $VERSION --force-with-lease

Backport Fixes to Stable Branch

This re-usable workflow automates cherry-picking fixes from master or main to stable release branches.

It supports two triggers:

  • Commit message: Add [backport X.Y] or [backport: X.Y] to the squash merge commit body to automatically backport when merged to master or main.

  • Comment: Comment /backport X.Y on a merged PR (org members only).

If the cherry-pick fails due to conflicts, the bot comments on the PR with manual resolution steps. If the target branch does not exist or the PR is not yet merged, the workflow exits safely without failing.

name: Backport fixes to stable branch

on:
  push:
    branches:
      - master
  issue_comment:
    types: [created]

concurrency:
  group: backport-${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: false

permissions:
  contents: write
  pull-requests: write

jobs:
  backport-on-push:
    if: github.event_name == 'push'
    uses: openwisp/openwisp-utils/.github/workflows/reusable-backport.yml@master
    with:
      commit_sha: ${{ github.sha }}
    secrets:
      app_id: ${{ secrets.OPENWISP_BOT_APP_ID }}
      private_key: ${{ secrets.OPENWISP_BOT_PRIVATE_KEY }}

  backport-on-comment:
    if: >
      github.event_name == 'issue_comment' &&
      github.event.issue.pull_request &&
      github.event.issue.pull_request.merged_at != null &&
      github.event.issue.state == 'closed' &&
      contains(fromJSON('["MEMBER", "OWNER"]'), github.event.comment.author_association) &&
      startsWith(github.event.comment.body, '/backport')
    uses: openwisp/openwisp-utils/.github/workflows/reusable-backport.yml@master
    with:
      pr_number: ${{ github.event.issue.number }}
      comment_body: ${{ github.event.comment.body }}
    secrets:
      app_id: ${{ secrets.OPENWISP_BOT_APP_ID }}
      private_key: ${{ secrets.OPENWISP_BOT_PRIVATE_KEY }}

Automated CI Failure Bot

To assist contributors with debugging, this reusable workflow leverages Google's Gemini API to analyze continuous integration failures in real-time. Upon detecting a failed CI run, it intelligently gathers the relevant source code context (safely bypassing unnecessary assets) alongside the raw error logs. It then posts a concise summary and an actionable remediation plan directly to the Pull Request.

When the bot detects that all failures are transient (e.g., network errors, browser crashes, Coveralls flakiness), it automatically re-runs the failed jobs up to 3 times and posts a short notification instead of the full analysis. This requires actions: write permission in the caller workflow and the GitHub App must have the Actions permission enabled. If the permission is not granted (e.g., in repositories that haven't updated their caller workflow yet), the auto-retry is skipped gracefully and the full analysis is posted instead.

Retry mode configuration

The bot supports a configurable retry classifier mode via CI_RETRY_MODE (repository or organization variable). Accepted values:

  • llm (default): uses the LLM decision; if the LLM fails, falls back to heuristic transient detection.

  • both: retries when either heuristic or LLM indicates transient; the LLM call is skipped when the heuristic already matched.

  • Any other value (including empty/typo): heuristic-only retry.

This workflow is intended to be triggered via the workflow_run event after your primary test suite concludes. It features strict cross-repository concurrency locks and token limits to prevent PR spam on rapid, consecutive commits.

Usage Example

Set up a caller workflow in your repository (e.g., .github/workflows/bot-ci-failure.yml) that monitors your primary CI job:

name: CI Failure Bot (Caller)

on:
  workflow_run:
    workflows: ["CI Build"]
    types:
      - completed

permissions:
  pull-requests: read
  actions: read
  contents: read

concurrency:
  group: ci-failure-${{ github.repository }}-${{ github.event.workflow_run.pull_requests[0].number || github.event.workflow_run.head_branch }}
  cancel-in-progress: true

jobs:
  find-pr:
    runs-on: ubuntu-latest
    if: ${{ github.event.workflow_run.conclusion == 'failure' && github.event.workflow_run.event == 'pull_request' }}
    outputs:
      pr_number: ${{ steps.pr.outputs.number }}
      pr_author: ${{ steps.pr.outputs.author }}
    steps:
      - name: Find PR Number
        id: pr
        env:
          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
          REPO: ${{ github.repository }}
          PR_NUMBER_PAYLOAD: ${{ github.event.workflow_run.pull_requests[0].number }}
          EVENT_HEAD_SHA: ${{ github.event.workflow_run.head_sha }}
        run: |
          emit_pr() {
            local pr_number="$1"
            local pr_author
            pr_author=$(gh pr view "$pr_number" --repo "$REPO" --json author --jq '.author.login // empty' 2>/dev/null || echo "")
            if [ -z "$pr_author" ] || [ "$pr_author" = "null" ]; then
              echo "::warning::Could not fetch PR author for PR #$pr_number"
            fi
            echo "number=$pr_number" >> "$GITHUB_OUTPUT"
            echo "author=$pr_author" >> "$GITHUB_OUTPUT"
          }
          PR_NUMBER="$PR_NUMBER_PAYLOAD"
          if [ -n "$PR_NUMBER" ]; then
            echo "Found PR #$PR_NUMBER from workflow payload."
            emit_pr "$PR_NUMBER"
            exit 0
          fi
          HEAD_SHA="$EVENT_HEAD_SHA"
          echo "Payload empty. Searching for PR via Commits API..."
          PR_NUMBER=$(gh api repos/$REPO/commits/$HEAD_SHA/pulls -q '.[0].number' 2>/dev/null || true)
          if [ -n "$PR_NUMBER" ] && [ "$PR_NUMBER" != "null" ]; then
             echo "Found PR #$PR_NUMBER using Commits API."
             emit_pr "$PR_NUMBER"
             exit 0
          fi
          echo "API lookup failed/empty. Scanning open PRs for matching head SHA..."
          PR_NUMBER=$(gh pr list --repo "$REPO" --state open --limit 100 --json number,headRefOid --jq ".[] | select(.headRefOid == \"$HEAD_SHA\") | .number" | head -n 1)
          if [ -n "$PR_NUMBER" ]; then
             echo "Found PR #$PR_NUMBER by scanning open PRs."
             emit_pr "$PR_NUMBER"
             exit 0
          fi
          echo "::warning::No open PR found. This workflow run might not be attached to an open PR."
          exit 0

  call-ci-failure-bot:
    needs: find-pr
    if: ${{ needs.find-pr.outputs.pr_number != '' }}
    permissions:
      pull-requests: write
      actions: write
      contents: read
    uses: openwisp/openwisp-utils/.github/workflows/reusable-bot-ci-failure.yml@master
    with:
      pr_number: ${{ needs.find-pr.outputs.pr_number }}
      head_sha: ${{ github.event.workflow_run.head_sha }}
      head_repo: ${{ github.event.workflow_run.head_repository.full_name }}
      base_repo: ${{ github.repository }}
      run_id: ${{ github.event.workflow_run.id }}
      pr_author: ${{ needs.find-pr.outputs.pr_author }}
      actor: ${{ github.event.workflow_run.actor.login }}
    secrets:
      GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }}
      APP_ID: ${{ secrets.OPENWISP_BOT_APP_ID }}
      PRIVATE_KEY: ${{ secrets.OPENWISP_BOT_PRIVATE_KEY }}

Changelog Bot

This workflow automatically generates changelog entry suggestions for Pull Requests using Google Gemini. It gets triggered when a PR with a title prefixed with [feature], [fix], or [change] is approved by a maintainer. It analyzes the PR's title, description, code changes, and linked issues, then posts a properly formatted changelog entry as a comment on the PR.

The bot uses a two-workflow pattern to support PRs from forks. The first workflow is triggered by pull_request_review: it checks whether the PR qualifies and uploads the PR number as an artifact, but requires no secrets. The second workflow executes via workflow_run once the first completes: it runs in the context of the base repository, has full access to secrets, and is the one that generates and posts the changelog comment.

Secrets

  • GEMINI_API_KEY (required): Google Gemini API key.

  • OPENWISP_BOT_APP_ID (required): OpenWISP Bot GitHub App ID.

  • OPENWISP_BOT_PRIVATE_KEY (required): OpenWISP Bot GitHub App private key.

Setup for Other Repositories

To enable the changelog bot in any OpenWISP repository, create the following two workflow files.

To enable the changelog bot in any OpenWISP repository, create the following two workflow files under .github/workflows/.

The trigger workflow runs when a PR review is submitted. If the PR is approved by a maintainer and its title starts with [feature], [fix], or [change], it stores the PR number as workflow metadata.

1. Changelog Bot Trigger (.github/workflows/bot-changelog-trigger.yml)

name: Changelog Bot Trigger

on:
  pull_request_review:
    types: [submitted]

permissions: {}

jobs:
  check:
    if: |
      github.event.review.state == 'approved' &&
      (github.event.review.author_association == 'OWNER' ||
        github.event.review.author_association == 'MEMBER' ||
        github.event.review.author_association == 'COLLABORATOR')
    runs-on: ubuntu-latest
    steps:
      - name: Check for noteworthy PR
        id: check
        env:
          PR_TITLE: ${{ github.event.pull_request.title }}
        run: |
          if echo "$PR_TITLE" | grep -qiE '^\[(feature|fix|change)\]'; then
            echo "has_noteworthy=true" >> $GITHUB_OUTPUT
          fi

      - name: Save PR metadata
        if: steps.check.outputs.has_noteworthy == 'true'
        env:
          PR_NUMBER: ${{ github.event.pull_request.number }}
        run: echo "$PR_NUMBER" > pr_number

      - name: Upload PR metadata
        if: steps.check.outputs.has_noteworthy == 'true'
        uses: actions/upload-artifact@v7
        with:
          name: changelog-metadata
          path: pr_number

The runner workflow is triggered after the trigger workflow completes. It retrieves the PR metadata and calls the reusable changelog workflow.

2. Changelog Bot Runner (.github/workflows/bot-changelog-runner.yml)

name: Changelog Bot Runner

on:
  workflow_run:
    workflows: ["Changelog Bot Trigger"]
    types:
      - completed

permissions:
  actions: read

jobs:
  fetch-metadata:
    runs-on: ubuntu-latest
    if: github.event.workflow_run.conclusion == 'success'
    permissions:
      actions: read
    outputs:
      pr_number: ${{ steps.metadata.outputs.pr_number }}
    steps:
      - name: Download PR metadata
        id: download
        uses: actions/download-artifact@v8
        with:
          name: changelog-metadata
          github-token: ${{ secrets.GITHUB_TOKEN }}
          run-id: ${{ github.event.workflow_run.id }}
        continue-on-error: true

      - name: Read PR metadata
        if: steps.download.outcome == 'success'
        id: metadata
        run: |
          PR_NUMBER=$(cat pr_number)
          if ! [[ "$PR_NUMBER" =~ ^[0-9]+$ ]]; then
            echo "::error::Invalid PR number: $PR_NUMBER"
            exit 1
          fi
          echo "pr_number=$PR_NUMBER" >> $GITHUB_OUTPUT

  changelog:
    needs: fetch-metadata
    if: needs.fetch-metadata.outputs.pr_number != ''
    permissions:
      contents: read
      pull-requests: write
      issues: write
    uses: openwisp/openwisp-utils/.github/workflows/reusable-bot-changelog.yml@master
    with:
      pr_number: ${{ needs.fetch-metadata.outputs.pr_number }}
    secrets:
      GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }}
      OPENWISP_BOT_APP_ID: ${{ secrets.OPENWISP_BOT_APP_ID }}
      OPENWISP_BOT_PRIVATE_KEY: ${{ secrets.OPENWISP_BOT_PRIVATE_KEY }}

Note

The name field in the trigger workflow must be exactly Changelog Bot Trigger. The runner watches for this name via workflow_run. Changing it will silently break the connection between the two workflows.

Both bot-changelog-trigger.yml and bot-changelog-runner.yml must be committed to the default branch of the repository. GitHub only activates workflow_run listeners that exist on the default branch: adding the runner only to a feature branch will cause it to silently do nothing.