⚠️ IMPORTANT: THIS ARTICLE IS SUPERSEDED FOR CI GENERATION

This article is no longer the generation specification. Use the new canonical reference at /blog/post/2026/043-canonical-github-actions-ci-cd-super-reference/ to create or update this repository’s CI.

This is the current general-purpose version of my single GitHub Actions CI/CD workflow guidance. It supersedes 028-simplified-github-ci-updated as the default document to give an agent when creating or upgrading a repository CI workflow.

For the focused explanation of duplicate draft releases, migration checks, and the single-release-owner rule, also see:

  • 041-release-safe-single-owner-github-ci

The resulting workflow should remain bespoke to the repository. This document describes the structure, routing, safety rules, and preferred implementation patterns; it is not an instruction to add irrelevant language or packaging lanes to every project.

The target outcome is one understandable ci.yml which can handle:

  • push validation,
  • pull-request validation,
  • semantic-version tags,
  • explicit manual build/release modes,
  • scheduled maintenance,
  • autofix PRs where appropriate,
  • language-specific lint/test/build lanes,
  • release artifacts,
  • GoReleaser where appropriate,
  • Docker/container publication where appropriate,
  • source package generation where appropriate,
  • post-release notification work,
  • and cleanup lifecycle.

Most importantly, this revision fixes an unsafe release architecture used by older versions of this guide:

For one tag, exactly one job or tool owns creation/publication of the GitHub Release.

A workflow must not create the same release independently from manual dispatch, tag push, release: published, GoReleaser, and softprops/action-gh-release.


Agent pointer

Generated workflows should identify the guidance used to create them:

1# Agent rules for generation:
2# https://arran4.github.io/blog/post/2026/042-simplified-github-ci-release-safe/
3# Release safety details:
4# https://arran4.github.io/blog/post/2026/041-release-safe-single-owner-github-ci/
5# Built using these posts as reference/guidance.
6name: CI/CD

When upgrading an existing repository, inspect the existing workflow and preserve useful repository-specific behaviour. Do not blindly replace a mature workflow with a generic example.


Non-negotiable design rules

  1. Route events explicitly. Jobs should not infer release intent independently.
  2. One GitHub Release owner per tag. This is the release-safety invariant.
  3. The default target is one centralized .github/workflows/ci.yaml or ci.yml containing the repository’s coherent CI/CD dependency graph.
  4. The desired topology is the fewest workflow files technically necessary, not merely preservation of historical workflow boundaries. Existing boundaries do not justify retention.
  5. Consolidate safely. If test, lint, build, tag validation, release gating, publication, and maintenance can safely coexist in one workflow, they should be consolidated.
  6. Multiple workflows require a concrete boundary. Valid exceptions include a genuinely independent reusable workflow_call, a materially different trust boundary, a platform/event limitation, or genuinely independent scheduled/administrative automation. If multiple workflows remain, the agent should explain why.
  7. Superseded workflow files must be deleted. Do not leave disabled legacy workflows, duplicate test/lint workflows, release wrappers, compatibility shells, or dead YAML files preserving the old structure.
  8. Prefer one native GitHub Actions dependency graph. Where release publication depends on validation, use jobs in the same workflow and native needs: edges rather than disconnected cross-workflow assumptions.
  9. Event routing in a single workflow must be deliberate. Share one workflow without granting release privileges to ordinary CI, running release jobs on branch/PR events, making valid publication impossible due to skipped needs:, or treating skipped/failed prerequisites as successful validation.
  10. Manual release dispatch explicitly dispatches the publisher workflow using GITHUB_TOKEN, or uses a specific PAT/App to trigger a downstream release run.
  11. release: published is downstream. It must not route back into the primary publisher.
  12. If GoReleaser publishes the GitHub Release, GoReleaser is the sole release owner.
  13. Do not create a parallel draft: true release merely to collect artifacts. Artifact jobs can use Actions artifacts until the release owner publishes them.
  14. If human-reviewed drafts are intentionally required, promote the exact same release ID. Never create a second release for the tag.
  15. Do not hide release-creation conflicts with || true. A duplicate creation attempt is a real pipeline error.
  16. Project-type decisions are mostly install/template-time. Runtime discovery is a safety net, not an excuse to make every job dynamically generic. Do not encourage blindly generic workflows: the jobs remain repository-specific, while their orchestration should be centralized when practical.
  17. Repository visibility is auto-detected using github.event.repository.private where cost policy differs.
  18. Public repositories normally run broader checks. Private repositories may use a conservative profile.
  19. Keep PR-visible tests on PR events. Do not deduplicate so aggressively that reviewers lose useful checks.
  20. Autofix lanes are language-aware. They should make deterministic mechanical changes and create focused PRs.
  21. Release artifacts come only from tested build paths where practical.
  22. Do not invent binary release lanes for libraries/config-only repositories.
  23. Scheduled maintenance should not accidentally publish a release.
  24. Use explicit permissions and reduce them per job where practical.
  25. Verify current GitHub Action major versions externally before generation. Examples in this article can age.
  26. Preserve intentional prerelease semantics (rc, alpha, beta, test, etc.).
  27. Keep unrelated release systems from racing. Multiple workflow files count as multiple possible owners too.

Step 1: triggers and manual modes

A useful baseline is:

 1name: CI/CD
 2
 3on:
 4  push:
 5    branches: [main, master]
 6    tags:
 7      - 'v*'
 8      - 'v*.*.*'
 9      - 'v*.*.*-rc*'
10      - 'v*.*.*-beta*'
11      - 'v*.*.*-alpha*'
12      - 'v*.*.*-test*'
13  pull_request:
14    types: [opened, synchronize, reopened, ready_for_review, closed]
15    branches: [main, master]
16  release:
17    types: [published]
18  workflow_dispatch:
19    inputs:
20      mode:
21        description: "Pipeline mode"
22        required: true
23        default: "lint-fix"
24        type: choice
25        options:
26          - lint-fix
27          - build
28          - release-major
29          - release-minor
30          - release-patch
31          - release-test
32          - release-rc
33          - release-alpha
34          - monthly-maintenance
35          - publish-tag
36      release_version_override:
37        description: "Optional explicit release version, for example 2.4.0 or 2.4.0-rc.2"
38        required: false
39        default: ""
40        type: string
41      allow_prs:
42        description: "Allow automation to open pull requests"
43        required: false
44        default: true
45        type: boolean
46  schedule:
47    # Preferred heavy monthly run: the 2nd at about 5am AEST, deliberately ignoring DST.
48    - cron: '0 19 1 * *'
49    # Optional lightweight/nightly maintenance.
50    - cron: '41 2 * * *'

The release: published trigger remains useful, but not as a release publisher. Note that events created using GITHUB_TOKEN are generally subject to the same workflow-recursion suppression, so do not promise that a GitHub Release created using GITHUB_TOKEN will automatically start another release: published workflow. For downstream work, prefer jobs in the existing workflow, or explicitly document that an App/PAT is required when a separate event-triggered workflow is genuinely required.


Step 2: concurrency and permissions

Use concurrency to collapse redundant churn, but do not use it as the only event-routing mechanism:

1concurrency:
2  group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.event.pull_request.number || github.ref }}
3  cancel-in-progress: true
4
5permissions:
6  contents: read
7  pull-requests: read

Grant write permissions only on jobs that need them. For example:

1permissions:
2  contents: write

for a release-publishing job, and:

1permissions:
2  contents: write
3  pull-requests: write

for an autofix PR job.


Step 3: event router

Use one router as the policy authority:

  1jobs:
  2  route:
  3    name: Route event
  4    runs-on: ubuntu-latest
  5    outputs:
  6      run_code_checks: ${{ steps.route.outputs.run_code_checks }}
  7      run_build: ${{ steps.route.outputs.run_build }}
  8      run_release: ${{ steps.route.outputs.run_release }}
  9      run_cleanup: ${{ steps.route.outputs.run_cleanup }}
 10      run_post_release: ${{ steps.route.outputs.run_post_release }}
 11      is_monthly: ${{ steps.route.outputs.is_monthly }}
 12      is_nightly: ${{ steps.route.outputs.is_nightly }}
 13    steps:
 14      - id: route
 15        shell: bash
 16        env:
 17          EVENT_NAME: ${{ github.event_name }}
 18          REF: ${{ github.ref }}
 19          EVENT_ACTION: ${{ github.event.action }}
 20          PR_MERGED: ${{ github.event.pull_request.merged }}
 21          INPUT_MODE: ${{ inputs.mode }}
 22          EVENT_SCHEDULE: ${{ github.event.schedule }}
 23          REF_TYPE: ${{ github.ref_type }}
 24        run: |
 25          set -euo pipefail
 26
 27          run_code_checks=false
 28          run_build=false
 29          run_release=false
 30          run_cleanup=false
 31          run_post_release=false
 32          is_monthly=false
 33          is_nightly=false
 34
 35          case "$EVENT_NAME" in
 36            push)
 37              run_code_checks=true
 38              if [[ "$REF" == refs/tags/v* ]]; then
 39                run_build=true
 40                run_release=true
 41              fi
 42              ;;
 43
 44            pull_request)
 45              if [[ "$EVENT_ACTION" == "closed" ]]; then
 46                if [[ "$PR_MERGED" != "true" ]]; then
 47                  run_cleanup=true
 48                fi
 49              else
 50                run_code_checks=true
 51              fi
 52              ;;
 53
 54            workflow_dispatch)
 55              case "$INPUT_MODE" in
 56                lint-fix)
 57                  run_code_checks=true
 58                  is_nightly=true
 59                  ;;
 60                build)
 61                  run_code_checks=true
 62                  run_build=true
 63                  ;;
 64                release-*)
 65                  # A manual release mode explicitly dispatches the publisher.
 66                  run_code_checks=true
 67                  run_build=true
 68                  run_release=true
 69                  ;;
 70                publish-tag)
 71                  # Internal publisher dispatch mode
 72                  if [[ "$REF_TYPE" != "tag" || ! "$REF" =~ ^refs/tags/v.* ]]; then
 73                    echo "publish-tag mode requires an eligible tag context (e.g. refs/tags/v*)" >&2
 74                    exit 1
 75                  fi
 76                  run_code_checks=true
 77                  run_build=true
 78                  run_release=true
 79                  ;;
 80                monthly-maintenance)
 81                  run_code_checks=true
 82                  is_monthly=true
 83                  ;;
 84              esac
 85              ;;
 86
 87            release)
 88              # The release already exists and is published.
 89              # Never route this event back into release creation.
 90              run_post_release=true
 91              ;;
 92
 93            schedule)
 94              run_code_checks=true
 95              if [[ "$EVENT_SCHEDULE" == "0 19 1 * *" ]]; then
 96                is_monthly=true
 97              else
 98                is_nightly=true
 99              fi
100              ;;
101          esac
102
103          echo "run_code_checks=$run_code_checks" >> "$GITHUB_OUTPUT"
104          echo "run_build=$run_build" >> "$GITHUB_OUTPUT"
105          echo "run_release=$run_release" >> "$GITHUB_OUTPUT"
106          echo "run_cleanup=$run_cleanup" >> "$GITHUB_OUTPUT"
107          echo "run_post_release=$run_post_release" >> "$GITHUB_OUTPUT"
108          echo "is_monthly=$is_monthly" >> "$GITHUB_OUTPUT"
109          echo "is_nightly=$is_nightly" >> "$GITHUB_OUTPUT"          

Step 4: prepare the next release tag

The unified release path requires one validated tag.

Idempotent recovery semantics: If publication fails after a tag has been pushed, a rerun should not blindly attempt to recreate the same tag and fail. Use a state-aware approach: verify whether the expected tag already exists and points at the expected commit, reuse it for recovery when safe, and fail clearly if it points elsewhere. Never silently move an existing release tag.

Once a manual release run has successfully pushed its intended tag, do not recover a later publication failure by simply rerunning the auto-incrementing release mode. Because git-tag-inc reads existing remote tags, an ordinary auto-increment rerun will usually see the failed tag and silently advance to the next semantic version instead of retrying the release.

Recovery must explicitly select the already-created intended tag using release_version_override=<exact failed tag> (accepting either X.Y.Z or vX.Y.Z, normalizing it internally). The workflow must then verify that the remote tag resolves to the exact validated ${GITHUB_SHA} before continuing publication. If it points anywhere else, it fails. Never silently select or create a newer version as recovery. The existing-tag check in the shell script below acts as the secure verification mechanism for this explicit override recovery path.

 1  prepare-release-tag:
 2    name: Prepare release tag
 3    needs: [route]
 4    if: ${{ needs.route.outputs.run_release == 'true' }}
 5    runs-on: ubuntu-latest
 6    outputs:
 7      release_tag: ${{ steps.tag.outputs.release_tag }}
 8      next_version: ${{ steps.tag.outputs.next_version }}
 9    steps:
10      - uses: actions/checkout@v7
11        with:
12          fetch-depth: 0
13      - name: Setup git-tag-inc
14        if: ${{ github.event_name == 'workflow_dispatch' && inputs.mode != 'publish-tag' }}
15        uses: arran4/git-tag-inc-action@v1
16        with:
17          mode: install
18      - id: tag
19        shell: bash
20        env:
21          EVENT_NAME: ${{ github.event_name }}
22          REF_NAME: ${{ github.ref_name }}
23          INPUT_RELEASE_VERSION_OVERRIDE: ${{ inputs.release_version_override }}
24          INPUT_MODE: ${{ inputs.mode }}
25        run: |
26          set -euo pipefail
27
28          if [[ "$EVENT_NAME" == "push" ]]; then
29            echo "release_tag=$REF_NAME" >> "$GITHUB_OUTPUT"
30            echo "next_version=$REF_NAME" >> "$GITHUB_OUTPUT"
31            exit 0
32          fi
33
34          MODE="$INPUT_MODE"
35          OVERRIDE="$INPUT_RELEASE_VERSION_OVERRIDE"
36
37          if [[ "$MODE" == "publish-tag" ]]; then
38            # The publisher run doesn't compute new tags
39            echo "release_tag=$REF_NAME" >> "$GITHUB_OUTPUT"
40            echo "next_version=$REF_NAME" >> "$GITHUB_OUTPUT"
41            exit 0
42          fi
43
44          git fetch --tags --force
45
46          if [[ -n "$OVERRIDE" ]]; then
47            OVERRIDE="${OVERRIDE#v}"
48            next_tag="v$OVERRIDE"
49          else
50            case "$MODE" in
51              release-major) level="major"; suffix="" ;;
52              release-minor) level="minor"; suffix="" ;;
53              release-patch) level="patch"; suffix="" ;;
54              release-test)  level="patch"; suffix="test" ;;
55              release-rc)    level="patch"; suffix="rc" ;;
56              release-alpha) level="patch"; suffix="alpha" ;;
57              *) echo "Unsupported release mode: $MODE" >&2; exit 1 ;;
58            esac
59
60            args=(-print-version-only "$level")
61            [[ -n "$suffix" ]] && args+=("$suffix")
62            next_tag=$(git-tag-inc "${args[@]}")
63          fi
64
65          [[ "$next_tag" =~ ^v[0-9]+\.[0-9]+\.[0-9]+([-.][0-9A-Za-z.]+)?$ ]] || {
66            echo "Invalid tag: $next_tag" >&2
67            exit 1
68          }
69
70          if git rev-parse "$next_tag" >/dev/null 2>&1; then
71            # Safe recovery semantics: verify existing tag against remote
72            REMOTE_TAG_SHA=$(git ls-remote --tags origin "refs/tags/$next_tag" | awk '{print $1}')
73            if [[ "$REMOTE_TAG_SHA" == "${GITHUB_SHA}" ]]; then
74              echo "Tag $next_tag exists and points to GITHUB_SHA. Safe to retry."
75            else
76              echo "Tag already exists and points to $REMOTE_TAG_SHA (expected ${GITHUB_SHA}): $next_tag" >&2
77              echo "To retry a failed publication for this exact version, ensure release_version_override is used and GITHUB_SHA matches." >&2
78              exit 1
79            fi
80          fi
81
82          echo "release_tag=$next_tag" >> "$GITHUB_OUTPUT"
83
84          clean_tag="${next_tag#v}"
85          clean_tag="${clean_tag%%-*}"
86          IFS='.' read -r maj min pat <<< "$clean_tag"
87          echo "next_version=${maj:-0}.${min:-0}.$(( ${pat:-0} + 1 ))-SNAPSHOT" >> "$GITHUB_OUTPUT"          

If the repository stores a source version (CMakeLists.txt, package.json, pubspec.yaml, etc.), do not blindly bump from stale source text. Determine the correct version policy for the project and avoid reusing an already-published tag.


Step 5: release validation gate

To ensure a release is only created if the required validation passes, introduce a strict aggregation gate. The generated release-validation job must directly need every applicable required validation lane. Explicitly require success for each lane that discovery says applies. Do not treat a skipped prerequisite as equivalent to success merely because a downstream job uses always(). Release conditions must explicitly distinguish:

  • success,
  • legitimately skipped event-specific dependencies,
  • skipped because an upstream dependency failed.

(This is an illustrative partial example. You must require every actual required validation job for your repository.)

 1  release-validation:
 2    name: Release Validation Gate
 3    # MUST depend on all language checks discovered dynamically for your repository!
 4    # (e.g., dart-checks, cpp-checks, make-checks)
 5    needs: [route, discover, go-checks, node-checks]
 6    if: |
 7      !failure() && !cancelled() &&
 8      needs.route.result == 'success' &&
 9      needs.discover.result == 'success' &&
10      (needs.discover.outputs.has_go != 'true' || needs.go-checks.result == 'success') &&
11      (needs.discover.outputs.has_node != 'true' || needs.node-checks.result == 'success')      
12    runs-on: ubuntu-latest
13    steps:
14      - run: echo "All required language checks passed."

Step 6: unified release context & gate

Keep the manual and external-tag paths mutually exclusive so they cannot create competing releases. We introduce a common release-context job that runs for BOTH push tags and workflow_dispatch manual releases after all validation gates successfully pass. It normalizes the tag, safely pushes it if it was a manual request, and exports the tag for downstream publishers.

 1  release-context:
 2    name: Release Context
 3    needs: [route, prepare-release-tag, release-validation, build-release-artifacts]
 4    if: ${{ !failure() && !cancelled() && needs.route.outputs.run_release == 'true' }}
 5    runs-on: ubuntu-latest
 6    permissions:
 7      contents: write
 8      actions: write
 9    outputs:
10      release_tag: ${{ steps.export.outputs.release_tag }}
11    steps:
12      - uses: actions/checkout@v7
13        with:
14          fetch-depth: 0
15
16      - name: Normalize and push tag
17        id: export
18        shell: bash
19        env:
20          GH_TOKEN: ${{ github.token }}
21          EVENT_NAME: ${{ github.event_name }}
22          INPUT_MODE: ${{ inputs.mode }}
23          REF_TYPE: ${{ github.ref_type }}
24          NEEDS_RELEASE_TAG: ${{ needs.prepare-release-tag.outputs.release_tag }}
25        run: |
26          set -euo pipefail
27
28          TAG="$NEEDS_RELEASE_TAG"
29          echo "release_tag=$TAG" >> "$GITHUB_OUTPUT"
30
31          if [[ "$EVENT_NAME" == "push" ]]; then
32            exit 0
33          fi
34
35          if [[ "$EVENT_NAME" == "workflow_dispatch" && "$INPUT_MODE" == "publish-tag" ]]; then
36            if [[ "$REF_TYPE" != "tag" ]]; then
37              echo "Error: publish-tag mode invoked on a non-tag ref." >&2
38              exit 1
39            fi
40            echo "Running in internal publisher mode; tag is immutable context."
41            exit 0
42          fi
43
44          if [[ "$EVENT_NAME" == "workflow_dispatch" ]]; then
45            (
46              # 1. git fetch --tags --force
47              git fetch --tags --force origin
48
49              # 2. inspect remote refs/tags/$TAG
50              if git ls-remote --tags origin "refs/tags/$TAG" | grep -q "$TAG"; then
51                # 3. verify an existing tag points at the expected commit
52                # Use ^{} to peel the tag if it's annotated
53                REMOTE_SHA=$(git ls-remote --tags origin "refs/tags/$TAG^{}" | awk '{print $1}')
54                if [[ -z "$REMOTE_SHA" ]]; then
55                  REMOTE_SHA=$(git ls-remote --tags origin "refs/tags/$TAG" | awk '{print $1}')
56                fi
57                LOCAL_SHA=$(git rev-parse HEAD)
58                if [[ "$REMOTE_SHA" != "$LOCAL_SHA" ]]; then
59                  echo "Error: Tag $TAG already exists and points to $REMOTE_SHA, not current commit $LOCAL_SHA." >&2
60                  exit 1
61                fi
62                echo "Tag $TAG exists and points to current commit."
63              else
64                # 4. otherwise create/push the tag explicitly anchored to the validated commit
65                git tag "$TAG" "${GITHUB_SHA}"
66                git push origin "refs/tags/$TAG"
67              fi
68
69              # 5. verify remote state points to expected commit
70              VERIFY_SHA=$(git ls-remote --tags origin "refs/tags/$TAG^{}" | awk '{print $1}')
71              if [[ -z "$VERIFY_SHA" ]]; then
72                VERIFY_SHA=$(git ls-remote --tags origin "refs/tags/$TAG" | awk '{print $1}')
73              fi
74              if [[ "$VERIFY_SHA" != "${GITHUB_SHA}" ]]; then
75                echo "Error: Tag push failed or remote verification failed (expected ${GITHUB_SHA}, got ${VERIFY_SHA})." >&2
76                exit 1
77              fi
78              echo "Tag successfully verified on remote."
79            )
80          fi
81
82          # 6. explicitly dispatch the SAME central ci.yaml at that tag
83          if [[ "$EVENT_NAME" == "workflow_dispatch" ]]; then
84            gh workflow run "ci.yml" --ref "$TAG" -f mode="publish-tag"
85          fi          

Do NOT expect the manual tag push to start another workflow when using GITHUB_TOKEN because ordinary events are suppressed. Instead, this job explicitly dispatches the workflow at the tag, relying on the workflow_dispatch exception to the recursion rule. The publisher mode verifies it is running at an eligible tag and cannot recursively create/push another tag or dispatch itself again.

Alternative: strict tag-push-owner model

If the design specifically requires the pushed tag to start a new workflow and that new release run to be the sole publisher, the tag MUST be pushed using a GitHub App installation token or PAT rather than GITHUB_TOKEN.

  • Use an explicit secret such as TAG_PUSH_TOKEN.
  • Fail clearly when it is absent.
  • Never silently fall back to GITHUB_TOKEN, because that produces a tag without the required follow-up workflow.
  • Explain this is an operational prerequisite that must be configured for each repository unless the credential is otherwise centrally supplied.

Step 7: capability discovery

Discovery should reflect the actual repository and normally remain lightweight:

 1  discover:
 2    name: Discover capabilities
 3    needs: [route]
 4    runs-on: ubuntu-latest
 5    outputs:
 6      profile: ${{ steps.profile.outputs.profile }}
 7      has_go: ${{ steps.detect.outputs.has_go }}
 8      has_node: ${{ steps.detect.outputs.has_node }}
 9      has_dart: ${{ steps.detect.outputs.has_dart }}
10      has_flutter: ${{ steps.detect.outputs.has_flutter }}
11      has_qt_cpp: ${{ steps.detect.outputs.has_qt_cpp }}
12      has_make_c: ${{ steps.detect.outputs.has_make_c }}
13      has_docker: ${{ steps.detect.outputs.has_docker }}
14      has_goreleaser: ${{ steps.detect.outputs.has_goreleaser }}
15    steps:
16      - uses: actions/checkout@v7
17      - id: detect
18        shell: bash
19        run: |
20          set -euo pipefail
21          [[ -f go.mod ]] && echo "has_go=true" || echo "has_go=false"
22          [[ -f package.json ]] && echo "has_node=true" || echo "has_node=false"
23          [[ -f pubspec.yaml ]] && echo "has_dart=true" || echo "has_dart=false"
24          if [[ -f pubspec.yaml ]] && grep -q '^  flutter:' pubspec.yaml; then
25            echo "has_flutter=true"
26          else
27            echo "has_flutter=false"
28          fi
29          if [[ -f CMakeLists.txt ]] && grep -qiE 'Qt|KF[56]|ECM' CMakeLists.txt; then
30            echo "has_qt_cpp=true"
31          else
32            echo "has_qt_cpp=false"
33          fi
34          [[ -f Makefile || -f makefile ]] && echo "has_make_c=true" || echo "has_make_c=false"
35          [[ -f Dockerfile || -f docker-compose.yml || -f compose.yml ]] && echo "has_docker=true" || echo "has_docker=false"
36          if [[ -f .goreleaser.yml || -f .goreleaser.yaml || -f goreleaser.yml || -f goreleaser.yaml ]]; then
37            echo "has_goreleaser=true"
38          else
39            echo "has_goreleaser=false"
40          fi          
41      - id: profile
42        shell: bash
43        env:
44          IS_PRIVATE: ${{ github.event.repository.private }}
45        run: |
46          if [[ "$IS_PRIVATE" == "true" ]]; then
47            echo "profile=private" >> "$GITHUB_OUTPUT"
48          else
49            echo "profile=public" >> "$GITHUB_OUTPUT"
50          fi          

If the repository type is already obvious, hard-coded comments/outputs are often clearer than elaborate runtime discovery.


Step 8: language checks

Each language lane should be explicit enough to understand and debug.

Go

 1  go-checks:
 2    needs: [route, discover]
 3    if: ${{ needs.route.outputs.run_code_checks == 'true' && needs.discover.outputs.has_go == 'true' }}
 4    runs-on: ubuntu-latest
 5    steps:
 6      - uses: actions/checkout@v7
 7      - uses: actions/setup-go@v7
 8        with:
 9          go-version-file: go.mod
10          cache: true
11      - run: go test ./...
12      - run: go vet ./...

Add repository-specific generated-code checks, staticcheck, golangci-lint, integration tests, race tests, or matrices when useful. Avoid multiplying expensive jobs without a reason.

Node

 1  node-checks:
 2    needs: [route, discover]
 3    if: ${{ needs.route.outputs.run_code_checks == 'true' && needs.discover.outputs.has_node == 'true' }}
 4    runs-on: ubuntu-latest
 5    steps:
 6      - uses: actions/checkout@v7
 7      - uses: actions/setup-node@v7
 8        with:
 9          node-version: lts/*
10          cache: npm
11      - run: npm ci
12      - run: npm test --if-present
13      - run: npm run lint --if-present

Dart / Flutter

Use the project-appropriate setup action and run formatting analysis/tests. Do not add Flutter when the repository is plain Dart.

Qt/C++ / CMake

Use an environment with the project’s actual Qt/KDE dependencies. Typical work includes:

1cmake -S . -B build -DCMAKE_BUILD_TYPE=Release -DCMAKE_EXPORT_COMPILE_COMMANDS=ON
2cmake --build build -j"$(nproc)"
3ctest --test-dir build --output-on-failure

Where useful, add clang-format --dry-run --Werror, clang-tidy, or cppcheck, but tune them to the repository rather than generating a noisy theoretical configuration.

Make/C

Use the project’s existing build/test interface. Prefer make, make test, project scripts, or established targets instead of inventing a second build system.


Step 9: autofix lane

Autofix is for deterministic mechanical fixes. It should be explicit manual/scheduled automation, not surprise commits from ordinary PR validation.

Typical condition:

1if: ${{ github.event_name == 'workflow_dispatch' && inputs.mode == 'lint-fix' && inputs.allow_prs == true }}

Possible fixes include:

  • gofmt / safe Go fixes,
  • Prettier,
  • Dart/Flutter formatting,
  • clang-format,
  • generated-file refreshes with deterministic tooling.

Open a focused PR only when the working tree actually changed. Use a stable branch naming convention and avoid duplicate autofix PRs.


Step 10: build artifacts

Build jobs should be separate from GitHub Release creation. This lets validation/release policy stay clear and prevents builders from becoming accidental competing publishers.

A build lane can upload short-lived Actions artifacts:

 1  build-release-artifacts:
 2    needs: [route, discover]
 3    if: ${{ needs.route.outputs.run_build == 'true' || needs.route.outputs.run_release == 'true' }}
 4    runs-on: ubuntu-latest
 5    steps:
 6      - uses: actions/checkout@v7
 7      # ... project-specific build ...
 8      - uses: actions/upload-artifact@v4
 9        with:
10          name: app-release
11          path: dist/
12          retention-days: 1

These Actions artifacts are staging inputs. They are not a reason to create an independent draft GitHub Release.


Step 11: release ownership decision

Before writing publisher jobs, choose one of these paths:

A. GoReleaser project (sole release owner)

Ensure the manual same-run publisher has the correct tag context. For example, if GoReleaser requires GORELEASER_CURRENT_TAG or a local tag, configure it with the correct pattern. GoReleaser owns the GitHub Release.

B. Non-GoReleaser binary/artifact project

One github-release job owns the GitHub Release.

C. Library/config/non-binary project

The release owner may create a notes-only GitHub Release if releases are desired. Do not invent binary artifacts.

D. Intentional human-reviewed draft process

One job creates the draft and records the release ID. Promotion modifies that same release. This is an explicit exception, not the default architecture.

Never combine A and B for the same tag.


Step 11A: GoReleaser as sole release owner

Run GoReleaser as the sole publisher in the unified release lane:

 1  goreleaser:
 2    name: Run GoReleaser
 3    needs: [route, discover, release-context]
 4    if: ${{ !failure() && !cancelled() && needs.route.outputs.run_release == 'true' && needs.discover.outputs.has_goreleaser == 'true' && startsWith(github.ref, 'refs/tags/v') && (github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && inputs.mode == 'publish-tag')) }}
 5    runs-on: ubuntu-latest
 6    permissions:
 7      contents: write
 8      # packages: write # (Uncomment if GoReleaser publishes to GHCR/GitHub Packages)
 9    steps:
10      - uses: actions/checkout@v7
11        with:
12          fetch-depth: 0
13          fetch-tags: true
14      - uses: actions/setup-go@v7
15        with:
16          go-version: stable
17      - uses: goreleaser/goreleaser-action@v7
18        with:
19          distribution: goreleaser
20          version: latest
21          args: release --clean
22        env:
23          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
24          GORELEASER_CURRENT_TAG: ${{ needs.release-context.outputs.release_tag }}

Prerelease classification: If the repository accepts SemVer prerelease tags (e.g. v1.2.3-rc.1, v1.2.3-beta.2), its .goreleaser.yaml configuration must explicitly preserve prerelease classification. Ensure your .goreleaser.yaml contains:

1release:
2  prerelease: auto

If GoReleaser needs Homebrew, package registries, signing credentials, or another repository token, inject those secrets into this owner job/config as appropriate.

Do not add another softprops/action-gh-release publisher after GoReleaser.

Do not add a manual gh release create job before GoReleaser.


Step 11B: non-GoReleaser GitHub Release owner

One job collects tested artifacts and publishes the release:

 1  github-release:
 2    name: Publish GitHub release
 3    needs: [route, discover, release-context, build-release-artifacts]
 4    if: ${{ !failure() && !cancelled() && needs.route.outputs.run_release == 'true' && needs.discover.outputs.has_goreleaser != 'true' && startsWith(github.ref, 'refs/tags/v') && (github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && inputs.mode == 'publish-tag')) }}
 5    runs-on: ubuntu-latest
 6    permissions:
 7      contents: write
 8    steps:
 9      - uses: actions/download-artifact@v5
10        with:
11          path: dist-release
12          pattern: '*-release'
13          merge-multiple: true
14
15      - name: Publish release
16        uses: softprops/action-gh-release@v2
17        with:
18          draft: false
19          generate_release_notes: true
20          tag_name: ${{ needs.release-context.outputs.release_tag }}
21          prerelease: ${{ contains(needs.release-context.outputs.release_tag, '-rc') || contains(needs.release-context.outputs.release_tag, '-alpha') || contains(needs.release-context.outputs.release_tag, '-beta') || contains(needs.release-context.outputs.release_tag, '-test') }}
22          files: dist-release/**

For a notes-only project, omit files: and the artifact dependency.

The key is not which GitHub release action is used; the key is that there is one publisher.


Step 12: what NOT to generate

Older versions of this guidance could produce something like:

 1manual-gh-release:
 2  run: gh release create "$TAG" --generate-notes || true
 3
 4publish-draft:
 5  uses: softprops/action-gh-release@v2
 6  with:
 7    draft: true
 8
 9promote-release:
10  run: echo "Promotion step placeholder (gh api patch release draft=false)"

Do not generate that architecture.

It can leave orphaned untagged-* drafts when one path creates a draft and another path publishes the canonical release.

Do not repair it by simply changing draft: true to draft: false; first determine who should own the release and remove the competing publisher paths.


Step 13: intentional draft releases

A draft process is valid when review before publication is genuinely required. In that case:

  1. one job creates the draft,
  2. record/resolve its release ID,
  3. upload all assets to that exact release,
  4. promotion patches that same release to draft=false,
  5. no other job creates a GitHub Release for the tag.

A placeholder echo is not promotion.

If the repository does not need human-reviewed drafts, prefer direct publication from the release owner.


Step 14: release: published downstream jobs

Post-publication work can use the release event safely:

1  post-release:
2    name: Post-release work
3    needs: [route]
4    if: ${{ needs.route.outputs.run_post_release == 'true' }}
5    runs-on: ubuntu-latest
6    steps:
7      - run: echo "Consume the already-published release here"

Examples:

  • refresh release pages,
  • send notifications,
  • update metadata/indexes,
  • trigger documentation deployment,
  • publish a monthly/reporting entry.

It must not call gh release create, GoReleaser release publication, or a GitHub Release creation API for the same version.


Step 15: Docker/container release

Container publication is separate from GitHub Release ownership. It may consume the same semantic tag without creating another GitHub Release.

A typical container release lane can:

  • authenticate to GHCR/another registry,
  • build multi-platform images when useful,
  • tag the image with the semantic version,
  • optionally add latest for stable non-prerelease tags.

Keep registry publication idempotent and clearly separate from gh release create.


Step 16: source Debian/RPM packages

If the repository genuinely produces source packages, create them as artifact-producing lanes and feed their outputs into the one release owner when GitHub Release attachment is desired.

Do not let a source-package job create another GitHub Release independently.

Package publishing to a distro/package registry is its own distribution action; GitHub Release creation remains single-owner.


Step 17: prerelease semantics

Derive prerelease state from the selected manual mode/tag suffix, not from unrelated toggles.

Examples:

  • v1.2.3 → stable,
  • v1.2.3-rc.1 → prerelease,
  • v1.2.3-alpha.1 → prerelease,
  • v1.2.3-beta.1 → prerelease,
  • v1.2.3-test.1 → pre-release or non-public test lane according to project policy.
  • Note: GoReleaser --snapshot does not publish a normal GitHub Release, so do not use it to publish normal artifacts.

If a project deliberately treats test tags as artifact-only and not GitHub Releases, encode that in the router/owner condition rather than creating then abandoning drafts.


Step 18: cleanup lifecycle

PR cleanup should be narrowly scoped. For example, if an autofix branch was created for a PR that is later closed without merge, a cleanup job may close/delete the derived autofix branch.

Do not run expensive test/release work on a pull_request: closed event just because the workflow was triggered.


Step 19: monthly/nightly maintenance

Scheduled runs may perform:

  • dependency freshness checks,
  • generated-file consistency,
  • lint/fmt drift checks,
  • security scans,
  • repository reports,
  • optional autofix PRs.

They should never accidentally set run_release=true.


Step 20: release safety and topology audit when upgrading an existing repo

Before changing an existing workflow, an agent or maintainer must explicitly audit the topology and release paths:

  1. Enumerate all workflow files (.github/workflows/*).
  2. Identify duplicate/overlapping responsibilities across those files.
  3. Determine the smallest coherent workflow set.
  4. Consolidate where practical into one central ci.yaml. Making fragmented workflows individually safe is not sufficient when their responsibilities can reasonably be represented in one coherent CI workflow.
  5. Delete superseded files. Do not leave dead YAML files.
  6. Explain every workflow that remains separate.

Add a concise failure example:

1BAD migration result:
2.github/workflows/ci.yaml
3but ci.yaml only has:
4  push
5  pull_request
6  external tag release
7and the previous/manual target architecture included workflow_dispatch.
8This is centralized, but functionally incomplete.

Then the good result:

1.github/workflows/ci.yaml
2contains:
3  push/PR validation
4  external tag path
5  manual release-* preparation
6  explicit publish-tag redispatch
7  one gated publisher

Search all workflow files and release configuration for release collisions:

 1softprops/action-gh-release
 2gh release create
 3goreleaser/goreleaser-action
 4/release
 5release:
 6types: [published]
 7publish-draft
 8promote-release
 9run_release=true
10draft: true

Then answer these questions:

  1. What creates the tag?
  2. What creates the GitHub Release?
  3. What uploads release assets?
  4. Can manual dispatch and tag push both publish?
  5. Can release: published trigger publication again?
  6. Does GoReleaser already own publication?
  7. Is another workflow file also publishing the same tags?
  8. Does any || true hide a creation conflict?
  9. Are historical untagged-* drafts evidence of an older duplicate path?

Choose one owner and make every other lane either a producer of inputs or a downstream consumer.


Step 21: repository-specific version bumping

Only add version-file mutation if the repository actually keeps a version in source control.

Examples include:

  • Node package.json,
  • Dart/Flutter pubspec.yaml,
  • CMake project version,
  • Java/Gradle/Maven version,
  • custom source constants.

When source state and tags can drift, compute the intended version carefully and refuse to reuse an existing tag. A release pipeline should fail safely rather than silently create a second representation of the same version.


Step 22: validation

Before opening/merging a CI change:

  • explicitly require workflow-aware validation with actionlint (and zizmor when appropriate/available),
  • run the repository’s normal test/lint/build validation,
  • inspect every needs dependency and referenced output,
  • ensure job conditions are valid for every triggering event,
  • ensure manual inputs are not referenced unsafely on unrelated events,
  • ensure release: published cannot reach the publisher,
  • ensure only one GitHub Release creator exists for a semantic tag,
  • ensure GoReleaser and softprops/action-gh-release are not both owners,
  • ensure build artifacts required by the publisher exist on the release run,
  • and preserve the repository’s existing useful CI semantics.

If CI is unavailable because of account/billing/quota failures, still perform static validation and document what could not be exercised.


Explicit publisher-mode invariant

Document and demonstrate that publish-tag:

1publish-tag:
2  MUST be invoked at a tag ref
3  MUST NOT calculate a new tag
4  MUST NOT create a tag
5  MUST NOT push a tag
6  MUST NOT redispatch the workflow
7  MAY run required validation
8  MAY proceed to the sole publisher after validation

If someone manually selects publish-tag while the workflow ref is a branch, the run must fail clearly. A successful no-op is not acceptable.

The preferred default flow is:

 1manual workflow_dispatch (release-major/minor/patch)
 2        |
 3        +-- run all required release gates
 4        +-- calculate the exact release tag
 5        +-- tag the exact validated commit
 6        +-- push the tag using GITHUB_TOKEN
 7        |      `-- no second workflow is implicitly expected
 8        `-- explicitly dispatch `gh workflow run ci.yml --ref "$TAG" -f mode=publish-tag`
 9
10external/user-created semantic tag or mode=publish-tag dispatch anchored at refs/tags/v...
11        |
12        `-- normal release gates
13        `-- exactly one publisher

Explicitly note that publish-tag cannot tag or redispatch itself.

In either path:

1   ONE release owner only
2      /             \
3GoReleaser      github-release job
4   (one or the other, never both)
5        |
6        v
7GitHub Release published
8        |
9        +-- (Downstream jobs in SAME run)

The old “tag-owner” architecture where manual dispatch pushes a tag to implicitly start a release run is not intrinsically wrong; however, it has an external credential prerequisite (e.g. TAG_PUSH_TOKEN) because GITHUB_TOKEN tag pushes do not trigger recursive workflows. Using GITHUB_TOKEN to push a tag and then explicitly dispatching the workflow via workflow_dispatch completely avoids this PAT prerequisite. Do not require a PAT/GitHub App token in the canonical explicit-dispatch design solely to trigger another workflow.

This separation makes the event graph easier to reason about and prevents the duplicate/orphaned draft releases seen when manual creation, draft creation, and release-event publication are combined.


Migration from 006, 011, and 028

When a repository says it was generated from one of the older CI articles, do not merely update the pointer comment. Audit the existing workflow and migrate its release graph and file topology.

Example topology migration:

Before:

1.github/workflows/
2  tests.yaml
3  golint.yaml
4  releaser.yml

After:

1.github/workflows/
2  ci.yaml

with ci.yaml containing PR/branch checks, tag validation, release validation, and the single privileged publisher with explicit event conditions and needs: relationships.

In particular:

  • replace manual gh release create with manual tag push + explicit redispatch to a unified external-tag lane, or ensure TAG_PUSH_TOKEN is used,
  • remove independent publish-draft release creators when another publisher exists,
  • remove placeholder promote-release jobs,
  • stop routing release: published into primary run_release,
  • make GoReleaser sole owner where it already publishes GitHub Releases,
  • keep artifact-build jobs but feed their results to the owner,
  • preserve intentional prerelease modes,
  • and leave historical release cleanup as an explicit separate administrative action.

Use 041-release-safe-single-owner-github-ci for the focused rationale and migration checklist.


Final agent rule

When an agent is asked to create or upgrade a repository workflow from this article, it should understand:

“Audit the whole workflow directory, preserve repository-specific behavior, and converge on the smallest coherent workflow topology—normally one central ci.yaml.”

It cannot reasonably complete a CI modernization task by saying:

“The existing workflow files are now individually safe, therefore no consolidation is required.”

The required steps are:

  • inspect the repository first,
  • preserve project-specific behaviour,
  • converge on the fewest workflow files technically necessary,
  • use current Action majors after external verification,
  • keep checks visible on PRs,
  • keep release/build lanes appropriate to the project,
  • and prove from the event graph that one semantic tag can produce at most one GitHub Release owner path.

That last condition is part of correctness, not an optional cleanup.

The agent must also answer:

“Did the intended manual release dispatcher survive the migration?”

If not, the task is incomplete.