Simplified Single GitHub Actions CI/CD File (Release-Safe Update)
⚠️ 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
- Route events explicitly. Jobs should not infer release intent independently.
- One GitHub Release owner per tag. This is the release-safety invariant.
- The default target is one centralized
.github/workflows/ci.yamlorci.ymlcontaining the repository’s coherent CI/CD dependency graph. - The desired topology is the fewest workflow files technically necessary, not merely preservation of historical workflow boundaries. Existing boundaries do not justify retention.
- Consolidate safely. If test, lint, build, tag validation, release gating, publication, and maintenance can safely coexist in one workflow, they should be consolidated.
- 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. - 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.
- 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. - 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. - Manual release dispatch explicitly dispatches the publisher workflow using
GITHUB_TOKEN, or uses a specific PAT/App to trigger a downstream release run. release: publishedis downstream. It must not route back into the primary publisher.- If GoReleaser publishes the GitHub Release, GoReleaser is the sole release owner.
- Do not create a parallel
draft: truerelease merely to collect artifacts. Artifact jobs can use Actions artifacts until the release owner publishes them. - If human-reviewed drafts are intentionally required, promote the exact same release ID. Never create a second release for the tag.
- Do not hide release-creation conflicts with
|| true. A duplicate creation attempt is a real pipeline error. - 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.
- Repository visibility is auto-detected using
github.event.repository.privatewhere cost policy differs. - Public repositories normally run broader checks. Private repositories may use a conservative profile.
- Keep PR-visible tests on PR events. Do not deduplicate so aggressively that reviewers lose useful checks.
- Autofix lanes are language-aware. They should make deterministic mechanical changes and create focused PRs.
- Release artifacts come only from tested build paths where practical.
- Do not invent binary release lanes for libraries/config-only repositories.
- Scheduled maintenance should not accidentally publish a release.
- Use explicit permissions and reduce them per job where practical.
- Verify current GitHub Action major versions externally before generation. Examples in this article can age.
- Preserve intentional prerelease semantics (
rc,alpha,beta,test, etc.). - 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:
- one job creates the draft,
- record/resolve its release ID,
- upload all assets to that exact release,
- promotion patches that same release to
draft=false, - 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
latestfor 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
--snapshotdoes 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:
- Enumerate all workflow files (
.github/workflows/*). - Identify duplicate/overlapping responsibilities across those files.
- Determine the smallest coherent workflow set.
- 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. - Delete superseded files. Do not leave dead YAML files.
- 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:
- What creates the tag?
- What creates the GitHub Release?
- What uploads release assets?
- Can manual dispatch and tag push both publish?
- Can
release: publishedtrigger publication again? - Does GoReleaser already own publication?
- Is another workflow file also publishing the same tags?
- Does any
|| truehide a creation conflict? - 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(andzizmorwhen appropriate/available), - run the repository’s normal test/lint/build validation,
- inspect every
needsdependency and referenced output, - ensure job conditions are valid for every triggering event,
- ensure manual inputs are not referenced unsafely on unrelated events,
- ensure
release: publishedcannot reach the publisher, - ensure only one GitHub Release creator exists for a semantic tag,
- ensure GoReleaser and
softprops/action-gh-releaseare 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.
Recommended release event flow
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 createwith manual tag push + explicit redispatch to a unified external-tag lane, or ensureTAG_PUSH_TOKENis used, - remove independent
publish-draftrelease creators when another publisher exists, - remove placeholder
promote-releasejobs, - stop routing
release: publishedinto primaryrun_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.