Simplified Single GitHub Actions CI/CD File (Updated)
Note: This article has been superseded. Use /blog/post/2026/043-canonical-github-actions-ci-cd-super-reference/ to create or update this repository’s CI.
This is a practical build-up guide for creating one large ci.yml that is still readable, maintainable, and tailored to real repositories. Note that if this is provided to a repo with a pipeline already in place it’s generally an upgrade request. The resulting pipeline should be bespoke to each repo, but consistently in approach, logic, parts, etc.
The target outcome:
- One workflow file handles push, PR open/update, PR close, tags, release publish, manual dispatch, and monthly/nightly schedules.
- It supports mixed repos: Go, Node, Dart, Flutter, Qt/C++, classic C/Makefile, and Dockerized components.
- It can run in public mode (broader checks) or private mode (cost-controlled), while keeping default test runners Ubuntu unless cross-OS coverage is explicitly needed.
- It includes autofix PR creation + cleanup, security checks, artifact fan-out, and release lanes.
- It accounts for packaging outputs beyond standard app bundles, including source Debian and source RPM pipeline hooks.
The point is not tiny YAML. The point is one intelligent CI/CD platform per repo.
Before the workflow body, add a top-of-file pointer comment so agents and humans know where the generation rules live:
1# Agent rules for generation:
2# https://arran4.github.io/blog/post/2026/028-simplified-github-ci-updated/ (or whatever URL was provided. In this case it will be the end url for this file)
3# Built using this post as a reference/guide.
4name: CI/CD
Why one file (when multiple files are common)
Multiple files can work, but they drift over time:
- duplicated setup steps,
- inconsistent event triggers,
- fragmented release logic,
- duplicated policy logic for private/public repos.
A single file gives one policy and one dependency graph. You can still keep complexity sane by:
- sectioned jobs,
- capability outputs,
- profile outputs,
- event routing,
- reusable local scripts/config files.
Non-negotiable design rules
- Event routing first (avoid accidental duplicate work).
- Project-type decisions should mostly be install/template-time (human comments + toggles), with lightweight runtime detection as a safety net.
- Repo visibility is auto-detected (
github.event.repository.private) rather than manually toggled. - Public repos run broader checks by default; private repos are conservative unless manual mode asks for full.
- Autofix lanes are language-aware (go fmt/go fix, dart format, flutter format, prettier, etc).
- Release lanes are split (GoReleaser, container release, source package release).
- Monthly maintenance exists by default.
- Difficulty gating based on success of others.
Always verify GitHub Action major versions
Always check externally what the latest major release of a GitHub Action is and use that. Ignore what you feel is right. You must go to the release page and check. For example:
| Action | URL | Example Latest (as of writing) |
|---|---|---|
| checkout | https://github.com/actions/checkout/releases | v7 |
| setup-go | https://github.com/actions/setup-go/releases | v7 |
| setup-node | https://github.com/actions/setup-node/releases | v7 |
| golangci-lint-action | https://github.com/golangci/golangci-lint-action/releases | v9 |
Always use this logic when generating workflows.
Step 1: Triggers and modes (copy/paste)
This event model supports normal validation, releases, and cleanup lifecycle.
1name: CI/CD
2
3on:
4 push:
5 # We know the repo so the trunk branch should be filtered down / pre selected
6 branches: [main, master]
7 # semantic version tags + rc/beta snapshots
8 tags:
9 - 'v*'
10 - 'v*.*.*'
11 - 'v*.*.*-rc*'
12 - 'v*.*.*-beta*'
13 - 'v*.*.*-test*'
14 pull_request:
15 types: [opened, synchronize, reopened, ready_for_review]
16 branches: [main, master]
17 release:
18 types: [published]
19 workflow_dispatch:
20 inputs:
21 mode:
22 description: "Pipeline mode"
23 required: true
24 default: "lint-fix"
25 type: choice
26 options:
27 - lint-fix
28 - build
29 - release-major
30 - release-minor
31 - release-patch
32 - release-test
33 - release-rc
34 - release-alpha
35 - monthly-maintenance
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. Tools that fix like go fmt, go fix, etc, will use this. Also in some projects we do auto bumping of 'next version' this is for those situations too. On public projects that don't have existing failures or frequent failures, often I turn this on and off"
43 required: false
44 default: true
45 type: boolean
46 schedule:
47 # preferred heavy monthly run (quota reset strategy)
48 # Always target the 2nd of each month at 5am AEST ignore dst
49 - cron: '0 19 1 * *'
50 # optional nightly lightweight checks
51 - cron: '41 2 * * *'
Why this is better
- It handles PR close cleanup flows.
- It supports semantic tags and release candidates.
- It exposes explicit manual operational modes (
lint-fix,build, and explicit release modes (release-major,release-minor,release-patch,release-test,release-rc,release-alpha)). - It keeps manual-dispatch states valid by encoding release intent directly into
modevalues.
Step 1.5: Release mode routing (single-input design)
To avoid invalid manual-dispatch state combinations, keep a single release control surface in mode and one optional release_version_override.
1 prepare-release-tag:
2 name: Prepare release tag
3 needs: [route]
4 if: ${{ github.event_name == 'workflow_dispatch' && startsWith(inputs.mode, 'release-') }}
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 uses: arran4/git-tag-inc-action@v1
15 with:
16 mode: install
17 # Do not also run `go install github.com/arran4/git-tag-inc/...` in this job.
18 # Using both is redundant and has caused avoidable CI drift.
19 - id: tag
20 shell: bash
21 run: |
22 set -euo pipefail
23 git config --global user.name "github-actions[bot]"
24 git config --global user.email "41898282+github-actions[bot]@users.noreply.github.com" # Note: you can get this ID using the GitHub API: curl -s https://api.github.com/users/github-actions%5Bbot%5D | jq .id
25 MODE="${{ inputs.mode }}"
26 OVERRIDE="${{ inputs.release_version_override }}"
27
28 if [[ -n "$OVERRIDE" ]]; then
29 # Accept "1.2.3" or "v1.2.3" override input.
30 OVERRIDE="${OVERRIDE#v}"
31 next_tag="v$OVERRIDE"
32 else
33 case "$MODE" in
34 release-major) level="major"; suffix="" ;;
35 release-minor) level="minor"; suffix="" ;;
36 release-patch) level="patch"; suffix="" ;;
37 release-test) level="patch"; suffix="test" ;;
38 release-rc) level="patch"; suffix="rc" ;;
39 release-alpha) level="patch"; suffix="alpha" ;;
40 *) echo "Unsupported release mode: $MODE"; exit 1 ;;
41 esac
42 if command -v git-tag-inc >/dev/null 2>&1; then
43 # git-tag-inc uses positional commands (patch/major/minor/test/rc...)
44 # and NOT flag forms like -patch.
45 level="${level#-}"
46 args=(-print-version-only "$level")
47 [[ -n "$suffix" ]] && args+=("$suffix")
48 next_tag=$(git-tag-inc "${args[@]}")
49 else
50 # Fallback implementation when git-tag-inc is not available.
51 git fetch --tags --force
52 latest=$(git tag -l 'v*' | sed 's/^v//' | sort -V | tail -n 1)
53 [[ -z "$latest" ]] && latest='0.0.0'
54
55 # Prefer npx semver if available (same pattern used in g2 fixes).
56 if command -v npx >/dev/null 2>&1; then
57 case "$level" in
58 major) bumped=$(npx --yes semver "$latest" -i major) ;;
59 minor) bumped=$(npx --yes semver "$latest" -i minor) ;;
60 *) bumped=$(npx --yes semver "$latest" -i patch) ;;
61 esac
62 next_tag="v${bumped}"
63 else
64 base="${latest%%-*}"
65 IFS='.' read -r maj min pat <<< "$base"
66 case "$level" in
67 major) maj=$((maj+1)); min=0; pat=0 ;;
68 minor) min=$((min+1)); pat=0 ;;
69 *) pat=$((pat+1)) ;;
70 esac
71 next_tag="v${maj}.${min}.${pat}"
72 fi
73
74 if [[ -n "$suffix" ]]; then
75 next_tag="${next_tag}-${suffix}.1"
76 fi
77 fi
78 fi
79
80 # Tagging safety guards to avoid duplicate/invalid release states.
81 [[ "$next_tag" =~ ^v[0-9]+\.[0-9]+\.[0-9]+([-.][0-9A-Za-z.]+)?$ ]] || {
82 echo "Invalid tag format: $next_tag" >&2
83 exit 1
84 }
85 git fetch --tags --force
86 if git rev-parse "$next_tag" >/dev/null 2>&1; then
87 echo "Tag already exists: $next_tag" >&2
88 echo "Choose a new mode or set release_version_override." >&2
89 exit 1
90 fi
91
92 echo "release_tag=$next_tag" >> "$GITHUB_OUTPUT"
93 clean_tag="${next_tag#v}"; clean_tag="${clean_tag%%-*}"
94 IFS='.' read -r maj min pat <<< "$clean_tag"
95 echo "next_version=${maj:-0}.${min:-0}.$(( ${pat:-0} + 1 ))-SNAPSHOT" >> "$GITHUB_OUTPUT"
With this approach, snapshot/prerelease is inferred from the selected release mode and tag suffix, not from separate toggles. It also fixes common tagging issues by normalizing override input (v prefix optional), validating tag shape, hard-failing on existing tags before publish jobs run, and reminding you to fetch tags before version math. It explicitly installs git-tag-inc via arran4/git-tag-inc-action@v1 (mode: install) and includes fallback bump paths (npx semver, then pure shell semver math) if the binary is not found. Do not double-install with a separate manual go install in the same job. Use git-tag-inc -print-version-only <major|minor|patch> [test|rc|alpha] positional arguments to avoid the recurring argument-format mistake. Never use -patch/-major/-minor as flags; those are invalid. Also configure git user/email in the job before running release tag tooling so CI tag operations do not fail on identity checks.
Language-specific Version Bumping
We should break up version bumping into individual components based on the languages in the repo, as different tools have different bump logic (e.g. pubspec.yaml has a different bump logic). Here are some quick copy/pasteables for each language:
Node (package.json):
1npm version patch --no-git-tag-version
Dart/Flutter (pubspec.yaml):
1PUBSPEC_VERSION=$(awk '/^version:/ {print $2}' pubspec.yaml)
2# increment logic ...
3sed -i "s/^version: .*/version: $NEW_VERSION/" pubspec.yaml
CMake (CMakeLists.txt):
1sed -i -E "s/(project\([^ ]+ VERSION )[^ )]+/\1$NEW_VERSION/" CMakeLists.txt
If your repository keeps a version in source files as well as tags (for example CMakeLists.txt, pubspec.yaml, package.json, or similar), compute the next version from the highest of source version and fetched tag version. That avoids the recurring failure mode where CI bumps from stale source state, reuses an already-published version, and collides on tag creation.
Step 2: Event routing to reduce duplicate runs
You noted a real issue: push + PR can duplicate work. We fix it with routing-first, state-aware if: behavior, but keep an important practical rule: lint/format/vet/test should still appear on PRs so reviewers get direct PR check visibility.
1concurrency:
2 # Keep this as a safety net, not the primary dedupe mechanism.
3 group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.event.pull_request.number || github.ref }}
4 cancel-in-progress: true
5
6permissions:
7 # Remember to only provide permissions where necessary
8 contents: write
9 discussions: write
10 pull-requests: write
11 checks: write
12 packages: write
13 security-events: write
14
15jobs:
16 route:
17 name: Route event
18 runs-on: ubuntu-latest
19 outputs:
20 run_code_checks: ${{ steps.route.outputs.run_code_checks }}
21 run_pr_meta_checks: ${{ steps.route.outputs.run_pr_meta_checks }}
22 run_cleanup: ${{ steps.route.outputs.run_cleanup }}
23 run_release: ${{ steps.route.outputs.run_release }}
24 is_monthly: ${{ steps.route.outputs.is_monthly }}
25 is_nightly: ${{ steps.route.outputs.is_nightly }}
26 steps:
27 - id: route
28 shell: bash
29 run: |
30 set -euo pipefail
31
32 run_code_checks=false
33 run_pr_meta_checks=false
34 run_cleanup=false
35 run_release=false
36 is_monthly=false
37 is_nightly=false
38
39 case "${{ github.event_name }}" in
40 push)
41 run_code_checks=true
42 if [[ "${{ github.ref }}" == refs/tags/v* ]]; then
43 if [[ "${{ github.ref }}" == *"-test"* ]]; then
44 run_release=false
45 else
46 run_release=true
47 fi
48 fi
49 ;;
50 pull_request)
51 if [[ "${{ github.event.action }}" == "closed" ]]; then
52 # We need to reduce the number of reruns we are getting on "closed"
53 # I am not sure following closed is even necessary
54 # Skip execution if the PR was closed by being merged
55 if [[ "${{ github.event.pull_request.merged }}" == "true" ]]; then
56 exit 0
57 fi
58 run_cleanup=true
59 else
60 run_pr_meta_checks=true
61 # In practice, also run code checks on PRs so lint/fmt/vet/test
62 # show up directly in the PR UI. Use concurrency to collapse churn.
63 run_code_checks=true
64 fi
65 ;;
66 release)
67 # Do not set run_release=true here. Genuine tag push or manual workflow_dispatch release-* owns publication.
68 # Use a separate run_republish flag if you intend to use GitHub UI release events as a recovery mechanism.
69 ;;
70 workflow_dispatch)
71 run_code_checks=true
72 if [[ "${{ inputs.mode }}" == release-* ]]; then
73 if [[ "${{ inputs.mode }}" == "release-test" ]]; then
74 run_release=false
75 else
76 run_release=true
77 fi
78 fi
79 if [[ "${{ inputs.mode }}" == "monthly-maintenance" ]]; then
80 is_monthly=true
81 fi
82 if [[ "${{ inputs.mode }}" == "lint-fix" ]]; then
83 # Manual lint-fix acts as an on-demand nightly-style maintenance pass.
84 is_nightly=true
85 fi
86 ;;
87 schedule)
88 run_code_checks=true
89 if [[ "${{ github.event.schedule }}" == "0 19 1 * *" ]]; then
90 is_monthly=true
91 fi
92 if [[ "${{ github.event.schedule }}" == "41 2 * * *" ]]; then
93 is_nightly=true
94 fi
95 ;;
96 esac
97
98 echo "run_code_checks=$run_code_checks" >> "$GITHUB_OUTPUT"
99 echo "run_pr_meta_checks=$run_pr_meta_checks" >> "$GITHUB_OUTPUT"
100 echo "run_cleanup=$run_cleanup" >> "$GITHUB_OUTPUT"
101 echo "run_release=$run_release" >> "$GITHUB_OUTPUT"
102 echo "is_monthly=$is_monthly" >> "$GITHUB_OUTPUT"
103 echo "is_nightly=$is_nightly" >> "$GITHUB_OUTPUT"
High-confidence manual-dispatch router baseline
If you want a known-good baseline for manual dispatch semantics, use the same three-route-output shape proven in arran4/mlocate_explorer (run_code_checks, run_build, run_release) and then layer project-specific lanes onto it.
1 route:
2 name: Event Router
3 runs-on: ubuntu-latest
4 outputs:
5 run_code_checks: ${{ steps.decide.outputs.run_code_checks }}
6 run_build: ${{ steps.decide.outputs.run_build }}
7 run_release: ${{ steps.decide.outputs.run_release }}
8 steps:
9 - id: decide
10 shell: bash
11 run: |
12 set -euo pipefail
13 if [[ "${{ github.event_name }}" == "pull_request" && "${{ github.event.action }}" == "closed" ]]; then
14 # We need to reduce the number of reruns we are getting on "closed"
15 # I am not sure following closed is even necessary
16 if [[ "${{ github.event.pull_request.merged }}" == "true" ]]; then
17 exit 0
18 fi
19 echo "run_code_checks=false" >> "$GITHUB_OUTPUT"
20 echo "run_build=false" >> "$GITHUB_OUTPUT"
21 echo "run_release=false" >> "$GITHUB_OUTPUT"
22 exit 0
23 fi
24
25 echo "run_code_checks=true" >> "$GITHUB_OUTPUT"
26
27 if [[ "${{ github.ref }}" == refs/tags/* || ("${{ github.event_name }}" == "workflow_dispatch" && "${{ inputs.mode }}" != "lint-fix") ]]; then
28 echo "run_build=true" >> "$GITHUB_OUTPUT"
29 else
30 echo "run_build=false" >> "$GITHUB_OUTPUT"
31 fi
32
33 if [[ "${{ github.event_name }}" == "workflow_dispatch" && startsWith("${{ inputs.mode }}", "release-") ]]; then
34 if [[ "${{ inputs.mode }}" == "release-test" ]]; then
35 echo "run_release=false" >> "$GITHUB_OUTPUT"
36 else
37 echo "run_release=true" >> "$GITHUB_OUTPUT"
38 fi
39 elif [[ "${{ github.ref }}" == refs/tags/v* ]]; then
40 if [[ "${{ github.ref }}" == *"-test"* ]]; then
41 echo "run_release=false" >> "$GITHUB_OUTPUT"
42 else
43 echo "run_release=true" >> "$GITHUB_OUTPUT"
44 fi
45 else
46 echo "run_release=false" >> "$GITHUB_OUTPUT"
47 fi
This gives you predictable manual dispatch behavior: lint-fix runs checks only, build runs build lanes, and release-* runs build + release.
This gives explicit behavior control instead of relying only on cancellation.
Practical rule: keep code checks on both push and pull_request when you want lint/format/vet/test results visible in the PR itself. Let concurrency and event routing reduce churn, rather than hiding the checks from reviewers.
Clarification from a working real-world result (fork-qip style)
A practical setup that matches your intent closely uses these additional rules:
- Conditional cancellation for concurrency:
- cancel in-progress runs on non-main/non-tag refs,
- avoid cancelling
main,master, and tag builds.
- Dedicated
formatjob that always runs for Go repos during code-check events:- on manual
lint-fix, it opens an autofix PR, - otherwise it fails with diff output (forcing dev-side formatting).
- on manual
- Separate
go-lint,go-test, andgo-vetjobs for cleaner diagnostics and release gating. - Release job guarded with
!failure() && !cancelled()andneedsfan-in.
Copy/paste concurrency pattern from that style:
1concurrency:
2 group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
3 cancel-in-progress: ${{ github.ref != 'refs/heads/main' && github.ref != 'refs/heads/master' && !startsWith(github.ref, 'refs/tags/') }}
This is stricter and often better than blanket cancel-in-progress: true in busy repos.
Step 3: Project profile decisions (config-time first, minimal runtime checks)
You are right that most tailoring should be done when installing the workflow. Do both:
- template comments/toggles for expected project types,
- runtime detection as guard rails.
1 language:
2 strict-casts: true
3
4linter:
5 rules:
6 - avoid_print
7 - prefer_single_quotes
Additional copy/paste config starters:
.golangci.yml
1run:
2 timeout: 5m
3
4linters:
5 enable:
6 - govet
7 - staticcheck
8 - errcheck
9 - ineffassign
10 - revive
.prettierrc.json
1{
2 "semi": false,
3 "singleQuote": true,
4 "printWidth": 100
5}
.clang-format
1BasedOnStyle: LLVM
2IndentWidth: 2
3ColumnLimit: 100
packaging/rpm/app.spec (source rpm compatible starter)
1Name: app
2Version: %{?version}%{!?version:0.0.0}
3Release: 1%{?dist}
4Summary: App summary
5License: MIT
6Source0: %{name}-%{version}.tar.gz
7
8%description
9App description.
10
11%prep
12%autosetup
13
14%build
15# build steps here
16
17%install
18mkdir -p %{buildroot}/usr/bin
19
20%files
21/usr/bin/*
22
23%changelog
24* Thu Mar 04 2026 CI Bot <ci@example.com> - %{version}-1
25- Automated source build
Step 5: Security jobs (automatic profile behavior)
1 gitleaks:
2 name: Secret scan
3 needs: [route]
4 if: ${{ needs.route.outputs.run_cleanup != 'true' && (needs.route.outputs.is_nightly == 'true' || needs.route.outputs.is_monthly == 'true') }}
5 runs-on: ubuntu-latest
6 steps:
7 - uses: actions/checkout@v7
8 with:
9 fetch-depth: 0
10 - uses: gitleaks/gitleaks-action@v2
11 env:
12 GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
13
14 dependency-review:
15 name: Dependency review (public/full)
16 needs: [route]
17 if: ${{ github.event_name == 'pull_request' && github.event.action != 'closed' }}
18 runs-on: ubuntu-latest
19 steps:
20 - uses: actions/dependency-review-action@v4
Public repos can afford broader checks by default. Private repos keep monthly/full-mode heavy scans.
Leak check policy: run secret/leak scans as part of nightly/monthly maintenance only (including manual lint-fix maintenance dispatch).
Step 5.5: Java/Maven lane (from kagura-style repos)
If a repo has pom.xml, add this lane. It is useful for polyglot repos where Java packaging coexists with Go/Node/others.
1 java-build-test:
2 name: Java build/test
3 needs: [route]
4 if: ${{ needs.route.outputs.run_code_checks == 'true' && hashFiles('pom.xml') != '' }}
5 runs-on: ubuntu-latest
6 steps:
7 - uses: actions/checkout@v7
8 - uses: actions/setup-java@v4
9 with:
10 java-version: '11'
11 distribution: temurin
12 cache: maven
13 - run: mvn spotless:check
14 - run: mvn test -DskipITs=false
This mirrors the style in your referenced workflow and can be chained into release fan-in if Java artifacts are part of your release.
Step 5.6: Hugo Pages integration pattern
If the repository includes a Hugo docs/site directory (example: site/mydocs), add a Pages lane that builds and deploys docs on main/master, tag releases, and manual dispatch.
Deployment targets to note:
- GitHub Pages Action: Preferred over committing to a branch.
- GitHub Pages Branch: Preferred action if using legacy methods.
- Cloudflare: Preview builds need to be supported for Cloudflare deployments.
Key ideas from the referenced workflow:
- route-level
run_pagesoutput (separate from code-check/release output), - workflow permissions include
pages: writeandid-token: write, - split build and deploy jobs (
hugo-build->hugo-deploy), - deployment concurrency uses the
pagesgroup.
Route additions (copy/paste)
1permissions:
2 # Remember to only provide permissions where necessary
3 contents: write
4 pull-requests: write
5 checks: write
6 packages: write
7 security-events: write
8 pages: write
9 id-token: write
10
11jobs:
12 route:
13 outputs:
14 run_pages: ${{ steps.route.outputs.run_pages }}
15 steps:
16 - id: route
17 run: |
18 run_pages=false
19 if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then
20 run_pages=true
21 elif [[ "${{ github.ref }}" == refs/tags/* || "${{ github.ref }}" == "refs/heads/main" || "${{ github.ref }}" == "refs/heads/master" ]]; then
22 run_pages=true
23 fi
24 echo "run_pages=$run_pages" >> "$GITHUB_OUTPUT"
25
26 hugo-build:
27 name: Build Hugo site
28 needs: [route]
29 if: ${{ needs.route.outputs.run_pages == 'true' }}
30 runs-on: ubuntu-latest
31 env:
32 HUGO_VERSION: ${{ inputs.hugo_version || '0.123.7' }}
33 steps:
34 - name: Install Hugo CLI
35 run: |
36 wget -O ${{ runner.temp }}/hugo.deb https://github.com/gohugoio/hugo/releases/download/v${HUGO_VERSION}/hugo_extended_${HUGO_VERSION}_linux-amd64.deb
37 sudo dpkg -i ${{ runner.temp }}/hugo.deb
38 - name: Install Dart Sass
39 run: sudo snap install dart-sass
40 - uses: actions/checkout@v7
41 with:
42 submodules: recursive
43 - id: pages
44 uses: actions/configure-pages@v5
45 - name: Install Node dependencies
46 working-directory: ./site/mydocs
47 run: "[[ -f package-lock.json || -f npm-shrinkwrap.json ]] && npm ci || true"
48 - name: Build with Hugo
49 working-directory: ./site/mydocs
50 env:
51 HUGO_ENVIRONMENT: production
52 HUGO_ENV: production
53 run: |
54 hugo --minify --baseURL "${{ steps.pages.outputs.base_url }}/"
55 - uses: actions/upload-pages-artifact@v3
56 with:
57 path: ./site/mydocs/public
58
59 hugo-deploy:
60 name: Deploy to GitHub Pages
61 needs: [route, hugo-build]
62 if: ${{ needs.route.outputs.run_pages == 'true' }}
63 runs-on: ubuntu-latest
64 concurrency:
65 group: pages
66 cancel-in-progress: false
67 environment:
68 name: github-pages
69 url: ${{ steps.deployment.outputs.page_url }}
70 steps:
71 - name: Deploy to GitHub Pages
72 id: deployment
73 uses: actions/deploy-pages@v4
This keeps docs/site deployment first-class without mixing it into language build jobs.
Step 6: Go lane (tests, lint, vet, release prep)
Use setup-go built-in caching instead of manual actions/cache.
1 # Requested baseline snippet (modern versions)
2 golangci:
3 name: lint
4 needs: [route]
5 if: ${{ needs.route.outputs.run_code_checks == 'true' }}
6 runs-on: ubuntu-latest
7 steps:
8 - uses: actions/checkout@v7
9 - uses: actions/setup-go@v7
10 with:
11 go-version-file: go.main
12 - name: golangci-lint
13 uses: golangci/golangci-lint-action@v9
14 with:
15 version: latest
16
17 go-test:
18 name: Go lint/test (${{ matrix.os }})
19 needs: [route, golangci]
20 if: ${{ needs.route.outputs.run_code_checks == 'true' }}
21 runs-on: ${{ matrix.os }}
22 strategy:
23 fail-fast: false
24 matrix:
25 # Cost-aware default: Ubuntu only.
26 # Add windows-latest/macos-latest only for true platform-specific behavior.
27 os: [ubuntu-latest]
28 steps:
29 - uses: actions/checkout@v7
30 - uses: actions/setup-go@v7
31 with:
32 go-version-file: go.main
33 cache: true
34 - name: Test
35 run: go test ./... -v
36
37 go-vet:
38 name: Go vet
39 needs: [route]
40 if: ${{ needs.route.outputs.run_code_checks == 'true' }}
41 runs-on: ubuntu-latest
42 steps:
43 - uses: actions/checkout@v7
44 - uses: actions/setup-go@v7
45 with:
46 go-version-file: go.main
47 cache: true
48 - run: go vet ./...
49
50 go-fmt-pr:
51 name: go fmt -> PR (manual dispatch)
52 needs: [route]
53 if: ${{ github.event_name == 'workflow_dispatch' && inputs.mode == 'lint-fix' && inputs.allow_prs == true }}
54 runs-on: ubuntu-latest
55 steps:
56 - uses: actions/checkout@v7
57 - uses: actions/setup-go@v7
58 with:
59 go-version-file: go.main
60 - name: Run go fmt
61 run: go fmt ./...
62 - name: Create PR if go fmt changed files
63 env:
64 GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
65 run: |
66 set -euo pipefail
67 git diff --quiet && { echo "No fmt changes"; exit 0; }
68 git config user.name "github-actions[bot]"
69 git config user.email "41898282+github-actions[bot]@users.noreply.github.com" # Note: you can get this ID using the GitHub API: curl -s https://api.github.com/users/github-actions%5Bbot%5D | jq .id
70 BRANCH="ci/gofmt/${{ github.run_id }}"
71 git checkout -b "$BRANCH"
72 git add -A
73 git commit -m "ci: go fmt"
74 git push origin "$BRANCH"
75 gh pr create --title "ci: go fmt" --body "Automated go fmt from manual dispatch." --base main --head "$BRANCH" --label "ci-autofix"
This separates lint, test, and vet while keeping a dedicated manual-dispatch go fmt -> PR path.
Recommended behavior (from your working result):
- Keep
formatas a required job in normal CI. - If
mode == lint-fix, auto-open PR with fixes. - Otherwise fail the job and print diff so formatting is corrected in source branches.
Optional cross-OS lane (only when it really matters):
1 go-cross-os-smoke:
2 name: Go cross-OS smoke (${{ matrix.os }})
3 needs: [route, golangci]
4 if: ${{ needs.route.outputs.run_code_checks == 'true' && (github.event_name == 'workflow_dispatch' && inputs.mode == 'build') }}
5 runs-on: ${{ matrix.os }}
6 strategy:
7 fail-fast: false
8 matrix:
9 os: [ubuntu-latest, windows-latest, macos-latest]
10 steps:
11 - uses: actions/checkout@v7
12 - uses: actions/setup-go@v7
13 with:
14 go-version-file: go.main
15 - run: go build ./...
Step 7: Node lane (tests, lint, source package + versioning integration)
1 node-lint-test:
2 name: Node lint/test
3 needs: [route]
4 if: ${{ needs.route.outputs.run_code_checks == 'true' }}
5 runs-on: ubuntu-latest
6 steps:
7 - uses: actions/checkout@v7
8 - uses: actions/setup-node@v7
9 with:
10 node-version: '22'
11 cache: 'npm'
12 - run: npm ci
13 - run: npm run lint --if-present
14 - run: npm test --if-present
15 - name: Build source npm package
16 run: npm pack --json > npm-pack-result.json
17
18 - uses: actions/upload-artifact@v4
19 with:
20 name: npm-source-package
21 retention-days: 1
22 path: |
23 *.tgz
24 npm-pack-result.json
Use this baseline with Step 7.5 below as the release/versioning extension for npm publish.
Step 7.5: Node/TS version automation (integrated publish path)
For npm packages, the tsobjectutils workflow has strong production ideas worth reusing:
- manual bump levels (
patch|minor|major|prerelease), - optional prerelease creation (
-next), - compute whether publish is allowed (
-nextcan skip public publish), - idempotent tag/release creation (
actions/github-scriptchecks if they already exist), - publish with correct npm dist-tag (
latestvsnext), - create a PR for the next development iteration version.
Copy/paste control snippet:
1on:
2 workflow_dispatch:
3 inputs:
4 level:
5 description: Version Bump Level
6 required: true
7 default: patch
8 type: choice
9 options: [patch, minor, major, prerelease]
10 create_prerelease:
11 description: Create as prerelease (e.g. -next.0)
12 required: false
13 default: false
14 type: boolean
15
16jobs:
17 version-and-release:
18 runs-on: ubuntu-latest
19 steps:
20 - uses: actions/checkout@v7
21 with:
22 fetch-depth: 0
23 - uses: actions/setup-node@v7
24 with:
25 node-version: '18'
26
27 - name: Manual Version Bump
28 if: github.event_name == 'workflow_dispatch'
29 run: |
30 LEVEL="${{ inputs.level }}"
31 CREATE_PRE="${{ inputs.create_prerelease }}"
32 if [ "$LEVEL" = "prerelease" ]; then
33 npm version prerelease --preid=next --no-git-tag-version
34 elif [ "$CREATE_PRE" = "true" ]; then
35 npm version pre$LEVEL --preid=next --no-git-tag-version
36 else
37 npm version $LEVEL --no-git-tag-version
38 fi
39
40 - name: Determine npm tag and prerelease state
41 id: versions
42 run: |
43 CURRENT_VERSION=$(node -p "require('./package.json').version")
44 if [[ "$CURRENT_VERSION" == *-* ]]; then
45 echo "npm_tag=next" >> "$GITHUB_OUTPUT"
46 echo "is_prerelease=true" >> "$GITHUB_OUTPUT"
47 else
48 echo "npm_tag=latest" >> "$GITHUB_OUTPUT"
49 echo "is_prerelease=false" >> "$GITHUB_OUTPUT"
50 fi
This pattern reduces accidental duplicate tags/releases and gives predictable npm channel behavior.
Step 8: Dart + Flutter lanes (including libraries)
You asked to include Dart libs and Flutter libs specifically, with analysis.
1 dart-analyze-test:
2 name: Dart analyze/test
3 needs: [route]
4 if: ${{ needs.route.outputs.run_code_checks == 'true' }}
5 runs-on: ubuntu-latest
6 steps:
7 - uses: actions/checkout@v7
8 - uses: dart-lang/setup-dart@v1
9 - run: dart --version
10 - run: dart pub get
11 - run: dart format --set-exit-if-changed .
12 - run: dart analyze
13 - run: dart test
14
15 flutter-analyze-test:
16 name: Flutter analyze/test (fast path)
17 needs: [route]
18 if: ${{ needs.route.outputs.run_code_checks == 'true' }}
19 runs-on: ubuntu-latest
20 steps:
21 - uses: actions/checkout@v7
22 - uses: subosito/flutter-action@v2
23 with:
24 channel: stable
25 - run: flutter --version
26 - run: flutter pub get
27 - run: dart format --set-exit-if-changed .
28 - run: flutter analyze
29 - run: flutter test
30
31 flutter-format-pr:
32 name: Flutter format -> PR (manual lint-fix)
33 needs: [route]
34 if: ${{ github.event_name == 'workflow_dispatch' && inputs.mode == 'lint-fix' }}
35 runs-on: ubuntu-latest
36 steps:
37 - uses: actions/checkout@v7
38 - uses: subosito/flutter-action@v2
39 with:
40 channel: stable
41 - run: flutter pub get
42 - id: format
43 run: |
44 dart format .
45 if [[ -n $(git status --porcelain -- '*.dart') ]]; then
46 echo "changes=true" >> "$GITHUB_OUTPUT"
47 git status --porcelain -- '*.dart'
48 else
49 echo "changes=false" >> "$GITHUB_OUTPUT"
50 fi
51 - name: Open PR with formatting fixes
52 if: steps.format.outputs.changes == 'true' && inputs.allow_prs == true
53 uses: peter-evans/create-pull-request@v7
54 with:
55 token: ${{ secrets.GITHUB_TOKEN }}
56 commit-message: "style: apply dart format"
57 title: "style: apply dart format"
58 body: "Automated PR for Flutter/Dart formatting fixes."
59 branch: "automated/dart-format-${{ github.ref_name }}"
60 base: ${{ github.ref_name }}
61 delete-branch: true
62 - name: Fail if formatting drift exists
63 if: steps.format.outputs.changes == 'true'
64 run: |
65 echo "Formatting drift detected. Fix directly or merge the generated PR."
66 exit 1
67
68 flutter-build-artifacts:
69 name: Flutter build artifacts (release/monthly only)
70 needs: [route, flutter-analyze-test]
71 if: ${{ (needs.route.outputs.run_release == 'true' || needs.route.outputs.is_monthly == 'true' || (github.event_name == 'workflow_dispatch' && inputs.mode == 'build')) }}
72 runs-on: ubuntu-latest
73 steps:
74 - uses: actions/checkout@v7
75 - uses: subosito/flutter-action@v2
76 with:
77 channel: stable
78 - run: flutter pub get
79 - run: flutter build linux --release
80 - run: flutter build apk --release || true
81
82 - uses: actions/upload-artifact@v4
83 with:
84 name: flutter-release-bundles
85 retention-days: 1
86 path: |
87 build/linux/**
88 build/app/outputs/flutter-apk/*.apk
Fastforge note
Fastforge is optional. Keep it if you want it; remove it if you don’t. The key pattern is to keep release outputs available through independent lanes (flatpak, source packages, container artifacts, GoReleaser outputs) so your pipeline doesn’t depend on a single packaging tool.
Confidence note (tested manual dispatch)
The Flutter manual-dispatch pattern above is based on a working pipeline where mode=lint-fix plus allow_prs has been tested in practice (arran4/mlocate_explorer, commit 9ce9d36). Treat this as a higher-confidence baseline for Flutter than untested snippets, then add platform build lanes (Linux/Windows/macOS) only when your project actually needs cross-OS deliverables.
When you do add cross-OS Flutter builds, follow this same split:
- keep format/analyze/test as the fast Ubuntu path,
- gate expensive Linux/Windows/macOS artifact lanes behind
run_buildor release, - upload artifacts per platform and publish only from release-scoped jobs.
Dart release/version-sync pattern (from dartobjectutils)
For Dart-first repos, one practical pattern is:
- run
dart analyze/dart test, - on manual dispatch, compute the next version (
patch|minor|major|manual), - update
pubspec.yaml, commit, tag, push, - verify tag version matches
pubspec.yamland auto-fix with PR fallback when direct push fails.
Copy/paste release prep snippet:
1 dart-release-prep:
2 name: Dart release prep
3 if: ${{ github.event_name == 'workflow_dispatch' }}
4 runs-on: ubuntu-latest
5 steps:
6 - uses: actions/checkout@v7
7 with:
8 fetch-depth: 0
9 - uses: dart-lang/setup-dart@v1
10 - name: Compute version and tag
11 env:
12 INCREMENT: ${{ inputs.increment }}
13 MANUAL_VERSION_INPUT: ${{ inputs.manual_version }}
14 run: |
15 set -euo pipefail
16 PUBSPEC_VERSION=$(awk '/^version:/ {print $2}' pubspec.yaml)
17 git fetch --tags
18 HIGHEST_TAG=$(git tag -l "v*" | sed 's/^v//' | sort -V | tail -n 1)
19 [ -z "$HIGHEST_TAG" ] && HIGHEST_TAG="0.0.0"
20
21 CURRENT_VERSION=$(echo -e "$PUBSPEC_VERSION
22$HIGHEST_TAG" | sort -V | tail -n 1)
23
24 if [ "$INCREMENT" = "manual" ]; then
25 [ -z "$MANUAL_VERSION_INPUT" ] && { echo "manual_version required"; exit 1; }
26 NEW_VERSION="$MANUAL_VERSION_INPUT"
27 else
28 IFS='.' read -r MAJOR MINOR PATCH <<< "$CURRENT_VERSION"
29 case "$INCREMENT" in
30 major) NEW_VERSION="$((MAJOR+1)).0.0" ;;
31 minor) NEW_VERSION="$MAJOR.$((MINOR+1)).0" ;;
32 *) NEW_VERSION="$MAJOR.$MINOR.$((PATCH+1))" ;;
33 esac
34 fi
35
36 sed -i "s/^version: .*/version: $NEW_VERSION/" pubspec.yaml
37 git config user.name "github-actions[bot]"
38 git config user.email "41898282+github-actions[bot]@users.noreply.github.com" # Note: you can get this ID using the GitHub API: curl -s https://api.github.com/users/github-actions%5Bbot%5D | jq .id
39 git checkout -b "release/v$NEW_VERSION"
40 git add pubspec.yaml
41 git commit -m "Bump version to $NEW_VERSION"
42 git tag "v$NEW_VERSION"
43 git push origin "v$NEW_VERSION"
44 git push origin "release/v$NEW_VERSION"
Step 9: Qt/C++ and classic C lane
Include both Qt/CMake and Makefile detection paths.
1 cpp-qt-build-test:
2 name: Qt/C++ build
3 needs: [route]
4 if: ${{ needs.route.outputs.run_code_checks == 'true' }}
5 runs-on: ubuntu-latest
6 steps:
7 - uses: actions/checkout@v7
8 - run: sudo apt-get update
9 - run: sudo apt-get install -y cmake ninja-build build-essential qt6-base-dev qt6-tools-dev clang-format cppcheck
10 - name: Lint style and static checks
11 run: |
12 find . \( -name '*.cpp' -o -name '*.cc' -o -name '*.h' -o -name '*.hpp' \) -print0 | xargs -0 -r clang-format --dry-run --Werror
13 cppcheck --enable=warning,style,performance,portability --error-exitcode=1 .
14 - run: cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=Release
15 - run: cmake --build build --parallel
16 - run: ctest --test-dir build --output-on-failure
17
18 c-make-build-test:
19 name: Classic C Makefile build
20 needs: [route]
21 if: ${{ needs.route.outputs.run_code_checks == 'true' }}
22 runs-on: ubuntu-latest
23 steps:
24 - uses: actions/checkout@v7
25 - run: make -j"$(nproc)" all
26 - run: make test || true
Step 10: Integrated autofix + PR automation lane
You wanted this wired to real formatters and branch-name guessable behavior.
1 autofix:
2 name: Auto-format and open PR
3 needs: [route]
4 if: ${{ github.event_name == 'workflow_dispatch' && inputs.mode == 'lint-fix' && inputs.allow_prs == true }}
5 runs-on: ubuntu-latest
6 steps:
7 - uses: actions/checkout@v7
8
9 - name: Setup Go (if needed)
10 uses: actions/setup-go@v7
11 with:
12 go-version-file: go.main
13
14 - name: Setup Node (if needed)
15 uses: actions/setup-node@v7
16 with:
17 node-version: '22'
18 cache: npm
19
20 - name: Setup Dart/Flutter (if needed)
21
22 uses: subosito/flutter-action@v2
23 with:
24 channel: stable
25
26 - name: Run autofix formatters
27 shell: bash
28 run: |
29 set -euo pipefail
30 if true; then
31 go fix ./... || true
32 go fmt ./... || true
33 fi
34 if true; then
35 npm ci || true
36 npx prettier . --write || true
37 fi
38 if true; then
39 dart format . || true
40 fi
41
42 - name: Create PR if changes exist
43 env:
44 GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
45 shell: bash
46 run: |
47 set -euo pipefail
48 if git diff --quiet; then
49 echo "No changes; exiting."
50 exit 0
51 fi
52
53 git config user.name "github-actions[bot]"
54 git config user.email "41898282+github-actions[bot]@users.noreply.github.com" # Note: you can get this ID using the GitHub API: curl -s https://api.github.com/users/github-actions%5Bbot%5D | jq .id
55
56 PARENT_PR="${{ github.event.pull_request.number || 'none' }}"
57 BRANCH="ci/autofix/${{ github.run_id }}-parent-${PARENT_PR}"
58
59 git checkout -b "$BRANCH"
60 git add -A
61 git commit -m "ci: automated formatting fixes"
62 git push origin "$BRANCH"
63
64 gh pr create \
65 --title "ci: automated formatting fixes" \
66 --body "Automated formatting pass. Parent-PR: ${PARENT_PR}" \
67 --base main \
68 --head "$BRANCH" \
69 --label "ci-autofix"
Cleanup on parent PR close (specific)
1 cleanup-autofix-prs:
2 name: Cleanup autofix PRs on parent close
3 needs: [route]
4 if: ${{ needs.route.outputs.run_cleanup == 'true' }}
5 runs-on: ubuntu-latest
6 steps:
7 - uses: actions/checkout@v7
8 - env:
9 GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
10 PARENT_PR: ${{ github.event.pull_request.number }}
11 run: |
12 set -euo pipefail
13 gh pr list --state open --search "label:ci-autofix in:title" --json number,headRefName,body | \
14 jq -r '.[] | select(.body | contains("Parent-PR: '"$PARENT_PR"'")) | [.number, .headRefName] | @tsv' | \
15 while IFS=$'\t' read -r pr branch; do
16 gh pr close "$pr" --comment "Closing auto-fix PR because parent PR #$PARENT_PR was closed."
17 git push origin --delete "$branch" || true
18 done
This uses both a label and a guessable branch pattern with parent linkage. Also note the checkout step: if the cleanup job deletes remote branches with git push origin --delete, it needs a repository checkout first.
Repeated gotcha: any git-mutating CI job needs checkout first
If a job runs commands like git push, git push origin --delete, git commit, or branch operations, add checkout as the first step.
1steps:
2 - uses: actions/checkout@v7
3 - run: git push origin --delete "$BRANCH"
Treat this as a hard rule in generated workflows. The same issue repeatedly appears in real repos when cleanup jobs omit checkout.
Step 11: Docker as a release publish step
Rule of Thumb for Container Ownership: Choose exactly one production publisher for a given container image: either Docker Actions (
docker/build-push-action) or GoReleaserdockers_v2. Do not enable both for the same image/tag set.A Docker Actions validation build with
push: falsemay coexist with GoReleaser-owned publication. Ifdockers_v2owns GHCR publication, ensure the workflow has the required registry/package authentication/login setup as applicable.
If repo has Go + Dockerfile or standalone Docker service, build and (optionally) push.
1 docker-build:
2 name: Docker build
3 needs: [route]
4 if: ${{ needs.route.outputs.run_release == 'true' || (github.event_name == 'workflow_dispatch' && inputs.mode == 'release-test') }}
5 runs-on: ubuntu-latest
6 steps:
7 - uses: actions/checkout@v7
8 - uses: docker/setup-qemu-action@v4
9 - uses: docker/setup-buildx-action@v4
10 - uses: docker/build-push-action@v7
11 with:
12 context: .
13 file: ${{ hashFiles('Dockerfile.goreleaser') != '' && 'Dockerfile.goreleaser' || 'Dockerfile' }}
14 push: false
15 tags: ghcr.io/${{ github.repository }}:ci-${{ github.run_id }}
16
17 docker-release:
18 name: Docker release
19 needs: [route, docker-build, prepare-release-tag, publish-release-tag]
20 if: |
21 always() &&
22 needs.route.result == 'success' &&
23 needs.docker-build.result == 'success' &&
24 (needs.prepare-release-tag.result == 'success' || needs.prepare-release-tag.result == 'skipped') &&
25 (needs.publish-release-tag.result == 'success' || needs.publish-release-tag.result == 'skipped') &&
26 needs.route.outputs.run_release == 'true' &&
27 inputs.mode != 'release-test'
28 runs-on: ubuntu-latest
29 permissions:
30 contents: read
31 packages: write
32 steps:
33 - uses: actions/checkout@v7
34 - name: Docker metadata
35 id: meta
36 uses: docker/metadata-action@v6
37 with:
38 images: ghcr.io/${{ github.repository }}
39 tags: |
40 type=semver,pattern={{version}},value=${{ github.event_name == 'workflow_dispatch' && needs.prepare-release-tag.outputs.release_tag || github.ref_name }}
41 type=semver,pattern={{major}}.{{minor}},value=${{ github.event_name == 'workflow_dispatch' && needs.prepare-release-tag.outputs.release_tag || github.ref_name }}
42 flavor: |
43 latest=${{ !contains(github.event_name == 'workflow_dispatch' && needs.prepare-release-tag.outputs.release_tag || github.ref_name, '-') }}
44 - uses: docker/setup-qemu-action@v4
45 - uses: docker/setup-buildx-action@v4
46 - uses: docker/login-action@v4
47 with:
48 registry: ghcr.io
49 username: ${{ github.actor }}
50 password: ${{ secrets.GITHUB_TOKEN }}
51 - name: Build and push Docker image
52 uses: docker/build-push-action@v7
53 with:
54 context: .
55 push: true
56 tags: ${{ steps.meta.outputs.tags }}
57 labels: ${{ steps.meta.outputs.labels }}
58 platforms: linux/amd64,linux/arm64
Dotfiles/Chezmoi/Docker lessons (high-value niche pattern)
For most app repos, the generic Docker section above is enough. For dotfiles + chezmoi style repos, a few extra checks from a working pipeline are worth copying:
- Shell config validity is a real test target (bash/zsh parse checks after apply).
- Run
chezmoi applyin CI to catch template/rendering regressions early. - Use Docker
build-contextswhen your Dockerfile expects the repo contents as a named context. - Use
docker/metadata-actiontag strategy so manual override tags and semver tags stay consistent. - Package and publish a dotfiles archive (
chezmoi archive) as a first-class release artifact.
Copy/paste pattern:
1 shell-check-and-chezmoi-apply:
2 name: ShellCheck + chezmoi apply
3 needs: [route]
4 if: ${{ needs.route.outputs.run_code_checks == 'true' }}
5 runs-on: ubuntu-latest
6 steps:
7 - uses: actions/checkout@v7
8 - name: Install shellcheck and zsh
9 run: sudo apt-get update && sudo apt-get install -y shellcheck zsh
10 - name: ShellCheck scripts
11 run: |
12 shopt -s globstar
13 shellcheck **/*.sh
14 - name: Apply chezmoi source into CI home
15 run: |
16 yes "" | sh -c "$(curl -fsLS get.chezmoi.io)" -- init --no-tty --debug --source=$PWD --apply
17 - name: Verify rendered shell files parse
18 run: |
19 for f in ~/.bashrc ~/.bash_profile ~/.bash_login ~/.bash_logout ~/.profile; do
20 [[ -f "$f" ]] && bash -n "$f"
21 done
22 for f in ~/.zshrc ~/.zprofile ~/.zlogin ~/.zlogout ~/.zshenv; do
23 [[ -f "$f" ]] && zsh -n "$f"
24 done
25
26 docker-release:
27 name: Docker release
28 needs: [route, docker-build, prepare-release-tag, publish-release-tag]
29 if: |
30 always() &&
31 needs.route.result == 'success' &&
32 (needs.docker-build.result == 'success' || needs.docker-build.result == 'skipped') &&
33 (needs.prepare-release-tag.result == 'success' || needs.prepare-release-tag.result == 'skipped') &&
34 (needs.publish-release-tag.result == 'success' || needs.publish-release-tag.result == 'skipped') &&
35 needs.route.outputs.run_release == 'true' &&
36 inputs.mode != 'release-test'
37 runs-on: ubuntu-latest
38 steps:
39 - uses: actions/checkout@v7
40 - uses: docker/setup-qemu-action@v4
41 - uses: docker/setup-buildx-action@v4
42 - uses: docker/login-action@v4
43 with:
44 registry: ghcr.io
45 username: ${{ github.actor }}
46 password: ${{ secrets.GITHUB_TOKEN }}
47 - name: Determine Docker Tag
48 id: docker-tag
49 run: |
50 if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then
51 echo "TAG=${{ needs.prepare-release-tag.outputs.release_tag }}" >> "$GITHUB_OUTPUT"
52 else
53 echo "TAG=${{ github.ref_name }}" >> "$GITHUB_OUTPUT"
54 fi
55 - name: Docker metadata
56 id: meta
57 uses: docker/metadata-action@v6
58 with:
59 images: ghcr.io/${{ github.repository_owner }}/dev-dotfiles-debian
60 tags: |
61 type=raw,value=${{ steps.docker-tag.outputs.TAG }}
62 type=raw,value=latest,enable=${{ !contains(steps.docker-tag.outputs.TAG, 'rc') && !contains(steps.docker-tag.outputs.TAG, 'alpha') && !contains(steps.docker-tag.outputs.TAG, 'beta') && !contains(steps.docker-tag.outputs.TAG, 'test') }}
63 - uses: docker/build-push-action@v7
64 with:
65 context: .
66 build-contexts: dotfiles=.
67 file: containers/dev-dotfiles-debian/Dockerfile
68 push: true
69 tags: ${{ steps.meta.outputs.tags }}
70
71 package-dotfiles:
72 name: Package dotfiles archive
73 needs: [route]
74 if: ${{ needs.route.outputs.run_release == 'true' }}
75 runs-on: ubuntu-latest
76 steps:
77 - uses: actions/checkout@v7
78 - name: Build dotfiles archive
79 run: |
80 yes "" | sh -c "$(curl -fsLS get.chezmoi.io)" -- init --no-tty --debug --source=$PWD --apply
81 ./bin/chezmoi archive --source=$PWD --format zip --output dotfiles.zip
82 - uses: actions/upload-artifact@v4
83 with:
84 name: dotfiles-archive
85 retention-days: 1
86 path: dotfiles.zip
Treat this as an opt-in lane: niche, but very effective when your repo is configuration-driven.
Step 12: GoReleaser lane (binary + packages)
Important reliability guard (from real-world PR fixes): avoid running GoReleaser on both tag-push and release: published for the same version. If both fire, you can get duplicate upload errors (422 already_exists). Keep GoReleaser scoped to:
- tag push events (
push+refs/tags/v*), or - explicit manual release dispatch modes.
If you publish Homebrew formulas, keep the article generic and parameterized, then provide your real tap as an example. For example, a tap can be OWNER/homebrew-tap (your concrete case: arran4/homebrew-tap). For cross-repo updates, set TAP_GITHUB_TOKEN in secrets and wire it to both the workflow env (TAP_GITHUB_TOKEN) and GoReleaser config ({{ .Env.TAP_GITHUB_TOKEN }}), with PR updates enabled and non-draft (draft: false).
Confidence note: the GoReleaser profile used in arran4/go-playerctl (commit 53e2a00) is a strong practical baseline for manual-dispatch releases and should be preferred over purely theoretical snippets when bootstrapping similar Go projects.
Important scope rule: only add binary build/release lanes when the project actually produces binaries. If the repo is a library, config repo, API schema repo, or another non-binary project, keep:
- tagging,
- GitHub release creation,
- release notes generation,
- discussions,
- lint/test/vet/fix/security checks,
- package-manager publication steps that make sense (
npm publish,dart pub publish, etc),
and skip binary-specific lanes like GoReleaser builds, app bundle packaging, Homebrew formulas for non-binaries, or Docker image publishing unless the repository genuinely ships those deliverables.
1 release-ready:
2 name: Release Quality Gates Passed
3 needs: [route, go-test, golangci, java-build-test, node-lint-test, dart-analyze-test, cpp-qt-build-test] # Add your repo's specific gates here
4 if: always() && !contains(needs.*.result, 'failure') && !contains(needs.*.result, 'cancelled')
5 runs-on: ubuntu-latest
6 steps:
7 - run: echo "All release quality gates passed."
8
9 publish-release-tag:
10 name: Publish Release Tag
11 needs: [route, prepare-release-tag, release-ready]
12 if: ${{ github.event_name == 'workflow_dispatch' && startsWith(inputs.mode, 'release-') && inputs.mode != 'release-test' }}
13 runs-on: ubuntu-latest
14 permissions:
15 contents: write
16 steps:
17 - uses: actions/checkout@v7
18 with:
19 fetch-depth: 0
20 - name: Create and push release tag
21 env:
22 TAG: ${{ needs.prepare-release-tag.outputs.release_tag }}
23 run: |
24 set -euo pipefail
25 git tag "$TAG"
26 git push origin "$TAG"
27
28 goreleaser:
29 name: GoReleaser
30 # In practice, include all quality gates here (for example: go-test, go-vet, go-lint, format).
31 needs: [route, go-test, prepare-release-tag, publish-release-tag]
32 if: |
33 always() &&
34 needs.route.result == 'success' &&
35 needs.go-test.result == 'success' &&
36 (needs.prepare-release-tag.result == 'success' || needs.prepare-release-tag.result == 'skipped') &&
37 (needs.publish-release-tag.result == 'success' || needs.publish-release-tag.result == 'skipped') &&
38 (needs.route.outputs.run_release == 'true' || inputs.mode == 'release-test')
39 runs-on: ubuntu-latest
40 steps:
41 - uses: actions/checkout@v7
42 with:
43 fetch-depth: 0
44 fetch-tags: true
45 - uses: actions/setup-go@v7
46 with:
47 go-version-file: go.main
48 - name: Run GoReleaser
49 uses: goreleaser/goreleaser-action@v7
50 with:
51 distribution: goreleaser
52 version: '~> v2'
53 args: release --clean ${{ (github.event_name == 'workflow_dispatch' && inputs.mode == 'release-test') && '--snapshot' || '' }}
54 env:
55 GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
56 TAP_GITHUB_TOKEN: ${{ secrets.TAP_GITHUB_TOKEN }} # inject secrets.TAP_GITHUB_TOKEN
57 GORELEASER_CURRENT_TAG: ${{ github.event_name == 'workflow_dispatch' && needs.prepare-release-tag.outputs.release_tag || github.ref_name }}
For release robustness, use an if: guard like this when aggregating many needs:
Important GoReleaser v2 note: avoid --tag in action args (it can fail with “unknown flag: –tag”). Instead set GORELEASER_CURRENT_TAG in env when you need to force the tag value.
Do/Don’t quick check:
1# ❌ Don't (fails on v2):
2# args: release --clean --tag v0.0.1
3
4# ✅ Do:
5# args: release --clean
6# env:
7# GORELEASER_CURRENT_TAG: v0.0.1
1if: ${{ !failure() && !cancelled() && needs.route.outputs.run_release == 'true' }}
Example .goreleaser.yml baseline (copy/paste):
If your repo does not emit a binary, do not cargo-cult this whole file. In that case, keep the manual tag/release flow from Step 15 and any relevant package-publish steps, but omit the GoReleaser binary/archive/container sections entirely.
1project_name: your-project
2
3release:
4 prerelease: auto
5
6builds:
7 - id: app
8 binary: app
9 main: ./cmd/app
10 env:
11 - CGO_ENABLED=0
12
13archives:
14 - formats: [tar.gz]
15 format_overrides:
16 - goos: windows
17 formats: [zip]
18
19checksum:
20 name_template: checksums.txt
21
22dockers_v2:
23 - images:
24 - "ghcr.io/{{ .Env.GITHUB_REPOSITORY | tolower }}"
25 tags:
26 - "{{ .Tag }}"
27 - '{{ if eq .Prerelease "" }}latest{{ end }}'
28 dockerfile: Dockerfile.goreleaser
29 platforms:
30 - linux/amd64
31 - linux/arm64
32
33nfpms:
34 -
35 vendor: Ubels Software Development
36 homepage: https://github.com/arran4/
37 maintainer: Arran Ubels <arran@ubels.com.au>
38 description: NA
39 license: Private
40 formats:
41 - apk
42 - deb
43 - rpm
44 - termux.deb
45 - archlinux
46 release: "1"
47 section: default
48 priority: extra
49 contents:
50 - src: doc/g2.1
51 dst: /usr/share/man/man1/g2.1
52 file_info:
53 mode: 0644
54
55brews:
56 -
57 repository:
58 owner: arran4
59 name: homebrew-tap
60 branch: "{{.ProjectName}}-{{.Version}}"
61 token: "{{ .Env.TAP_GITHUB_TOKEN }}"
62 pull_request:
63 enabled: true
64 draft: false
65 base:
66 owner: arran4
67 name: homebrew-tap
68 branch: main
69 commit_author:
70 name: goreleaserbot
71 email: bot@goreleaser.com
72
73homebrew_casks:
74 -
75 repository:
76 owner: arran4
77 name: homebrew-tap
78 branch: "{{.ProjectName}}-{{.Version}}"
79 token: "{{ .Env.TAP_GITHUB_TOKEN }}"
80 pull_request:
81 enabled: true
82 draft: false
83 base:
84 owner: arran4
85 name: homebrew-tap
86 branch: main
87 commit_author:
88 name: goreleaserbot
89 email: bot@goreleaser.com
90
91scoops:
92 - name: app
93 bucket:
94 owner: OWNER
95 name: scoop-bucket
96
97changelog:
98 sort: asc
99 filters:
100 exclude:
101 - '^docs:'
102 - '^test:'
Important: avoid archive name templates for binaries
Do not set fragile custom archive naming templates for multi-arch binary archives unless you have a very strong reason and a tested collision-proof format.
A proven exception is a template that includes enough uniqueness dimensions (at minimum: project, version, os, arch), like the working go-playerctl pattern.
Why:
- New architectures (for example
windows/arm) can appear over time. - A hand-rolled name template that seemed unique can start colliding.
- Typical failure is
archive ... already existsduring release.
Recommendation:
- Keep GoReleaser archive names on defaults.
- Keep only
format_overridesfor Windows zip/tar differences. - If you ever customize names, include enough dimensions (
project,version,os,arch, and architecture variants) and test against the full matrix before release.
Example of a safer template shape used successfully for manual-dispatch releases:
1archives:
2 - formats: [tar.gz]
3 name_template: >-
4 {{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}
Step 13: Source Debian and Source RPM pipelines (separate lane)
You asked for this explicitly: source package generation should be its own lane and file structure.
Recommended repo layout:
1packaging/
2 debian/
3 control
4 rules
5 changelog
6 source/format
7 rpm/
8 app.spec
9 scripts/
10 build-source-deb.sh
11 build-source-rpm.sh
To create the control file for Debian packaging, you will need to define the package metadata and dependencies. A basic template for packaging/debian/control looks like this:
1Source: app
2Section: utils
3Priority: optional
4Maintainer: Your Name <you@example.com>
5Build-Depends: debhelper (>= 11)
6Standards-Version: 4.1.3
7Homepage: https://example.com
8
9Package: app
10Architecture: any
11Depends: ${shlibs:Depends}, ${misc:Depends}
12Description: App description
13 Detailed description of the app goes here.
Ensure this is configured correctly based on your actual dependencies and package information.
Source Debian lane
1 source-deb:
2 name: Build source .dsc/.orig.tar.*
3 needs: [route, prepare-release-tag, publish-release-tag]
4 if: |
5 always() &&
6 needs.route.result == 'success' &&
7 (needs.prepare-release-tag.result == 'success' || needs.prepare-release-tag.result == 'skipped') &&
8 (needs.publish-release-tag.result == 'success' || needs.publish-release-tag.result == 'skipped') &&
9 needs.route.outputs.run_release == 'true' &&
10 inputs.mode != 'release-test'
11 runs-on: ubuntu-latest
12 steps:
13 - uses: actions/checkout@v7
14 - run: sudo apt-get update
15 - run: sudo apt-get install -y devscripts debhelper build-essential fakeroot
16 - name: Build source Debian package
17 env:
18 RELEASE_TAG: ${{ github.event_name == 'workflow_dispatch' && needs.prepare-release-tag.outputs.release_tag || github.ref_name }}
19 run: |
20 chmod +x packaging/scripts/build-source-deb.sh
21 packaging/scripts/build-source-deb.sh
22 - uses: actions/upload-artifact@v4
23 with:
24 name: source-deb
25 retention-days: 1
26 path: |
27 dist/deb-source/*.dsc
28 dist/deb-source/*.debian.tar.*
29 dist/deb-source/*.orig.tar.*
30 dist/deb-source/*.changes
Example packaging/scripts/build-source-deb.sh:
1#!/usr/bin/env bash
2set -euo pipefail
3
4APP_NAME="app"
5VERSION="${RELEASE_TAG#v}"
6WORKDIR="/tmp/${APP_NAME}-${VERSION}"
7OUTDIR="$PWD/dist/deb-source"
8
9rm -rf "$WORKDIR"
10mkdir -p "$WORKDIR" "$OUTDIR"
11
12git archive --format=tar.gz --prefix="${APP_NAME}-${VERSION}/" -o "$OUTDIR/${APP_NAME}_${VERSION}.orig.tar.gz" HEAD
13
14tar -xzf "$OUTDIR/${APP_NAME}_${VERSION}.orig.tar.gz" -C /tmp
15cp -r packaging/debian "/tmp/${APP_NAME}-${VERSION}/debian"
16
17(
18 cd "/tmp/${APP_NAME}-${VERSION}"
19 dch --create -v "${VERSION}-1" --package "$APP_NAME" "Automated source release"
20 dpkg-buildpackage -S -sa
21)
22
23mv /tmp/${APP_NAME}_${VERSION}-1* "$OUTDIR/" || true
Source RPM lane
1 source-rpm:
2 name: Build source .src.rpm
3 needs: [route, prepare-release-tag, publish-release-tag]
4 if: |
5 always() &&
6 needs.route.result == 'success' &&
7 (needs.prepare-release-tag.result == 'success' || needs.prepare-release-tag.result == 'skipped') &&
8 (needs.publish-release-tag.result == 'success' || needs.publish-release-tag.result == 'skipped') &&
9 needs.route.outputs.run_release == 'true' &&
10 inputs.mode != 'release-test'
11 runs-on: ubuntu-latest
12 steps:
13 - uses: actions/checkout@v7
14 - run: sudo apt-get update
15 - run: sudo apt-get install -y rpm
16 - name: Build source RPM
17 env:
18 RELEASE_TAG: ${{ github.event_name == 'workflow_dispatch' && needs.prepare-release-tag.outputs.release_tag || github.ref_name }}
19 run: |
20 chmod +x packaging/scripts/build-source-rpm.sh
21 packaging/scripts/build-source-rpm.sh
22 - uses: actions/upload-artifact@v4
23 with:
24 name: source-rpm
25 retention-days: 1
26 path: dist/rpm-source/*.src.rpm
Example packaging/scripts/build-source-rpm.sh:
1#!/usr/bin/env bash
2set -euo pipefail
3
4APP_NAME="app"
5VERSION="${RELEASE_TAG#v}"
6TOPDIR="$PWD/.rpmbuild"
7OUTDIR="$PWD/dist/rpm-source"
8
9mkdir -p "$TOPDIR"/{BUILD,RPMS,SOURCES,SPECS,SRPMS} "$OUTDIR"
10
11git archive --format=tar.gz --prefix="${APP_NAME}-${VERSION}/" -o "$TOPDIR/SOURCES/${APP_NAME}-${VERSION}.tar.gz" HEAD
12cp packaging/rpm/app.spec "$TOPDIR/SPECS/"
13
14rpmbuild \
15 --define "_topdir $TOPDIR" \
16 --define "version $VERSION" \
17 -bs "$TOPDIR/SPECS/app.spec"
18
19cp "$TOPDIR/SRPMS"/*.src.rpm "$OUTDIR/"
This is intentionally independent from fastforge/GoReleaser so source package publishing is never blocked by app-bundle tooling changes.
Step 14: Flatpak and optional app-store packaging lane
For Flutter/Qt desktop apps, keep a manual lane. If Flutter build artifacts were produced earlier, this lane can package those; if not, it can run from source directly.
1 flatpak-build:
2 name: Flatpak package
3 needs: [route]
4 if: ${{ needs.route.outputs.run_release == 'true' }}
5 runs-on: ubuntu-latest
6 steps:
7 - uses: actions/checkout@v7
8 - run: sudo apt-get update
9 - run: sudo apt-get install -y flatpak flatpak-builder
10 - name: Build Flatpak
11 run: |
12 flatpak-builder --force-clean build-dir packaging/flatpak/app.yaml
13 - uses: actions/upload-artifact@v4
14 with:
15 name: flatpak-bundle
16 retention-days: 1
17 path: build-dir
Step 15: Release fan-in and publish stages
Use multiple deploy stages (package -> publish -> promote).
Manual release creation pattern (non-GoReleaser)
If your repository does not use GoReleaser (or another publisher) as the primary owner of GitHub releases, you can use this generic manual step to create a release from a branch/tag. Do not use this if GoReleaser is already handling releases, to avoid duplicate tag/release conflicts.
Copy/paste CI step style:
1 publish-release:
2 name: Publish Release
3 needs: [route, prepare-release-tag, publish-release-tag]
4 if: |
5 always() &&
6 needs.route.result == 'success' &&
7 (needs.prepare-release-tag.result == 'success' || needs.prepare-release-tag.result == 'skipped') &&
8 (needs.publish-release-tag.result == 'success' || needs.publish-release-tag.result == 'skipped') &&
9 needs.route.outputs.run_release == 'true' &&
10 inputs.mode != 'release-test'
11 runs-on: ubuntu-latest
12 permissions:
13 contents: write
14 discussions: write
15 steps:
16 - uses: actions/checkout@v7
17 with:
18 fetch-depth: 0
19
20 - name: Create release
21 env:
22 GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
23 TAG: ${{ github.event_name == 'workflow_dispatch' && needs.prepare-release-tag.outputs.release_tag || github.ref_name }}
24 run: |
25 set -euo pipefail
26 prerelease=""
27 case "${{ inputs.mode }}" in
28 release-rc|release-alpha) prerelease="--prerelease" ;;
29 esac
30
31 discussion_arg="--discussion-category Announcements"
32
33 # Permissions/discussions can block discussion linking in some repos.
34 # Fall back to plain release creation if category linking fails.
35 if [[ -n "$prerelease" ]]; then
36 gh release create "$TAG" --generate-notes $prerelease
37 else
38 gh release create "$TAG" --generate-notes $discussion_arg || \
39 gh release create "$TAG" --generate-notes
40 fi
Guide requirement: if you include a manual release lane, include both generated notes (--generate-notes) and discussion-category selection fallback logic so the LLM-generated workflow does not omit release discussions in repositories that use them. Use a fixed default discussion category (Announcements) and fall back to plain gh release create --generate-notes when permissions or discussions configuration block category linking. When running in Actions, set permissions.discussions: write (plus contents: write) for this lane.
To avoid duplicate release work, keep artifact publishers scoped by event (for example GoReleaser on tag-push/manual only, not release: published).
Integrate language publishers in the same publish stage:
- Go binaries: GoReleaser publish (GitHub releases + packages)
- Node/TS libraries:
npm publishwithlatest/nextdist-tags - Dart/Flutter libraries:
dart pub publish(or dry-run in non-release modes) - Docker: release-only buildx push to GHCR, but only if the repo actually ships an image
- Non-binary repos: tag + GitHub release + generated notes + discussion flow, without inventing binary artifacts
- Versioned source repos: fetch tags and bump from the maximum of source version and latest tag, not just the checked-in version text
Step 16: Full skeleton (compact but wired)
This is the high-level skeleton to start from. Keep this in .github/workflows/ci.yml and split script details into packaging/scripts and config files.
1name: CI/CD
2
3on:
4 push:
5 # We know the repo so the trunk branch should be filtered down / pre selected
6 branches: [main, master]
7 tags: ['v*', 'v*.*.*', 'v*.*.*-rc*', 'v*.*.*-beta*', 'v*.*.*-test*']
8 pull_request:
9 types: [opened, synchronize, reopened, ready_for_review]
10 branches: [main, master]
11 release:
12 types: [published]
13 workflow_dispatch:
14 inputs:
15 mode:
16 type: choice
17 default: lint-fix
18 options: [lint-fix, build, release-major, release-minor, release-patch, release-test, release-rc, release-alpha, monthly-maintenance]
19 release_version_override:
20 type: string
21 default: ''
22 allow_prs:
23 type: boolean
24 default: true
25 schedule:
26 - cron: '0 19 1 * *'
27 - cron: '41 2 * * *'
28
29concurrency:
30 group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.event.pull_request.number || github.ref }}
31 cancel-in-progress: true
32
33permissions:
34 # Remember to only provide permissions where necessary
35 contents: write
36 pull-requests: write
37 checks: write
38 packages: write
39 security-events: write
40
41jobs:
42 route:
43 # ... from section above
44 prepare-release-tag:
45 needs: [route]
46 # ... from section above
47
48 gitleaks:
49 needs: [route]
50 # ...
51
52 java-build-test:
53 needs: [route]
54 # ...
55
56 hugo-build:
57 needs: [route]
58 # ...
59
60 hugo-deploy:
61 needs: [route, hugo-build]
62 # ...
63
64 golangci:
65 needs: [route]
66 # ...
67
68 go-test:
69 needs: [route, golangci]
70 # ...
71
72 go-vet:
73 needs: [route]
74 # ...
75
76 go-fmt-pr:
77 needs: [route]
78 # ...
79
80 node-lint-test:
81 needs: [route]
82 # ...
83
84 dart-analyze-test:
85 needs: [route]
86 # ...
87
88 flutter-analyze-test:
89 needs: [route]
90 # ...
91
92 flutter-build-artifacts:
93 needs: [route, flutter-analyze-test]
94 # ...
95
96 cpp-qt-build-test:
97 needs: [route]
98 # ...
99
100 c-make-build-test:
101 needs: [route]
102 # ...
103
104 docker-build:
105 needs: [route]
106 # ...
107
108 autofix:
109 needs: [route]
110 # ...
111
112 cleanup-autofix-prs:
113 needs: [route]
114 # ...
115
116 release-ready:
117 name: Release Quality Gates Passed
118 needs: [route, go-test, golangci, java-build-test, node-lint-test, dart-analyze-test, cpp-qt-build-test] # Add your repo's specific gates here
119 if: always() && !contains(needs.*.result, 'failure') && !contains(needs.*.result, 'cancelled')
120 runs-on: ubuntu-latest
121 steps:
122 - run: echo "All release quality gates passed."
123
124 publish-release-tag:
125 name: Publish Release Tag
126 needs: [route, prepare-release-tag, release-ready]
127 if: ${{ github.event_name == 'workflow_dispatch' && startsWith(inputs.mode, 'release-') && inputs.mode != 'release-test' }}
128 runs-on: ubuntu-latest
129 permissions:
130 contents: write
131 steps:
132 - uses: actions/checkout@v7
133 with:
134 fetch-depth: 0
135 - name: Create and push release tag
136 env:
137 TAG: ${{ needs.prepare-release-tag.outputs.release_tag }}
138 run: |
139 set -euo pipefail
140 git tag "$TAG"
141 git push origin "$TAG"
142
143 goreleaser:
144 needs: [route, go-test, prepare-release-tag, publish-release-tag]
145 # ...
146
147 source-deb:
148 needs: [route, prepare-release-tag, publish-release-tag]
149 # ...
150
151 source-rpm:
152 needs: [route, prepare-release-tag, publish-release-tag]
153 # ...
154
155 docker-release:
156 needs: [route, docker-build, prepare-release-tag, publish-release-tag]
157 # ...
158
159 generic-release:
160 needs: [route, prepare-release-tag, publish-release-tag]
161 # ...
What to decide at install time vs runtime
Install/template time (prefer this):
- expected project stacks,
- release channels,
- package targets,
- which jobs are required.
Runtime (safety):
- file presence detection,
- public/private profile,
- event-mode routing,
- monthly/nightly schedule behavior.
This gives sane defaults while still protecting mixed repos.
Public vs private behavior recommendations
| Area | Public | Private |
|---|---|---|
| OS matrix | Linux default (add macOS/Windows only when required) | Linux default |
| Parallelism | wide job fan-out | narrower job fan-out, parallel inside step |
| Security | broader PR scans | monthly/full-mode deep scans |
| Artifact retention | longer | shorter |
| Validation strictness | maximum | practical baseline + release hardening |
Visibility should be auto-detected (github.event.repository.private) and not manually toggled.
Storage guardrail: artifact expiry policy (important)
To prevent GitHub Actions storage overages, set retention-days on every actions/upload-artifact step.
Required policy for this template:
- set
retention-days: 1on everyactions/upload-artifactstep. - publish/promote jobs should consume artifacts immediately in the same workflow run.
Copy/paste baseline:
1- uses: actions/upload-artifact@v4
2 with:
3 name: ci-temp-output
4 path: dist/**
5 retention-days: 1
Optional monthly cleanup (especially useful for private repos with low storage quota):
1 cleanup-old-artifacts:
2 name: Cleanup old CI artifacts
3 if: ${{ needs.route.outputs.is_monthly == 'true' }}
4 runs-on: ubuntu-latest
5 steps:
6 - name: Delete artifacts older than 1 day
7 env:
8 GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
9 run: |
10 set -euo pipefail
11 cutoff=$(date -u -d '1 day ago' +%s)
12 gh api repos/${{ github.repository }}/actions/artifacts --paginate \
13 --jq '.artifacts[] | [.id, .created_at] | @tsv' | \
14 while IFS=$' ' read -r id created; do
15 created_epoch=$(date -u -d "$created" +%s)
16 if (( created_epoch < cutoff )); then
17 gh api -X DELETE repos/${{ github.repository }}/actions/artifacts/$id || true
18 fi
19 done
Final checklist before rollout
- Add config files (
.golangci.yml,.goreleaser.yml,analysis_options.yaml,.gitleaks.toml,.clang-format). - Add packaging scripts under
packaging/scripts/. - Add
packaging/debianandpackaging/rpmmetadata. - Dry-run with
workflow_dispatch mode=buildormode=lint-fix. - Validate
lint-fixcreates/labels branches correctly. - Validate
pull_request.closedcleanup against test PRs. - Validate monthly schedule and release lanes.
- Validate that every
git-mutating job starts withactions/checkout@v7.
README distribution/install checklist (do not skip)
When you add release lanes, don’t forget to update README.md so users know how to install from each release target. They should be at “next” in some cases and “current” in others such as install instructions. This may be done using pull requests if toggled. Match the README to what the repo actually ships; if there is no binary, do not add fake binary install instructions just because the template has them. At minimum, list:
- GitHub Releases (binary/tarball download path),
- Homebrew tap install command,
- Docker image pull/run command,
- Go install command for Go CLIs,
- native package methods (
deb,rpm,apk,archlinux) where available.
Copy/paste template:
1## Install
2
3### GitHub Releases
4Download binaries from: https://github.com/OWNER/REPO/releases
5
6### Homebrew
7brew tap OWNER/homebrew-tap
8brew install app
9
10### Docker
11docker pull ghcr.io/YOUR_OWNER_LOWERCASE/YOUR_REPO_LOWERCASE:latest
12docker run --rm ghcr.io/YOUR_OWNER_LOWERCASE/YOUR_REPO_LOWERCASE:latest --help
13
14### Go install
15go install github.com/OWNER/REPO/cmd/app@latest
16
17### Native packages
18- Debian/Ubuntu (`.deb`): see Releases assets
19- RPM (`.rpm`): see Releases assets
20- Alpine (`.apk`): see Releases assets
21- Arch (`.pkg.tar.zst` or repo): see Releases assets
This keeps release automation and user-facing install documentation aligned.
Closing
If your goal is “one CI file that does everything”, make it explicit, sectioned, and policy-driven.
The winning pattern is:
- route events,
- detect capabilities,
- branch by profile,
- run language lanes in parallel,
- split release lanes by output type,
- and automate cleanup lifecycle.
That gives you the giant file you wanted, with practical behavior for real repos rather than demo YAML.
Troubleshooting: GoReleaser fatal: tag already exists
If you use GoReleaser inside your GitHub Action workflow to automatically build and release binaries, you might occasionally encounter an error that halts your pipeline:
1Run git tag v0.0.2
2
3fatal: tag 'v0.0.2' already exists
4
5Error: Process completed with exit code 128.
Git correctly refuses to create a tag that has already been created. This typically happens when:
- Triggering manually without incrementing the version: If you have a
workflow_dispatchtrigger that takes a version string, running it twice with the same version will fail the second time. - Failed previous releases: If your CI workflow previously ran and successfully created the tag but failed in a later step (e.g., during building or uploading assets), the tag will still exist. Re-running the pipeline hits the same tagging step and fails.
- Duplicate automated tagging: Your script may unconditionally attempt to tag the commit without first checking if the remote branch already has that tag.
Option 1: Delete the conflicting tag and retry (For failed releases)
If you are explicitly trying to retry or overwrite that exact same release version (e.g., v0.0.2) due to a failed pipeline, you must delete the tag both locally and remotely:
1# Delete the tag locally
2git tag -d v0.0.2
3
4# Delete the tag on the remote repository
5git push origin :refs/tags/v0.0.2
Once removed from the remote, you can safely trigger your workflow again.
Option 2: Increment the version (For new releases)
If the previous release was successful and you are actually trying to push new changes, increment your version number according to Semantic Versioning (e.g., to v0.0.3) and trigger the workflow with the new tag:
1git tag v0.0.3
2git push origin v0.0.3
Troubleshooting: GoReleaser template function lower not defined
When configuring GoReleaser to publish Docker images to registries like GitHub Container Registry (ghcr.io), you often want to dynamically inject the repository name using the GITHUB_REPOSITORY environment variable.
However, Docker registries enforce lowercase names for images. A common mistake is attempting to use the lower function in the GoReleaser template to achieve this:
1dockers: # Legacy format shown only to reproduce the error
2 - image_templates:
3 - "ghcr.io/{{ .Env.GITHUB_REPOSITORY | lower }}:latest" # WRONG!
This will result in an error during the release process:
1docker build failed: failed to execute image template 'ghcr.io/{{ .Env.GITHUB_REPOSITORY | lower }}:latest': template: failed to apply "ghcr.io/{{ .Env.GITHUB_REPOSITORY | lower }}:latest": function "lower" not defined
GoReleaser uses the text/template engine but provides its own set of custom template functions. For lowercase conversion, GoReleaser provides tolower, not lower.
The correct configuration is:
1dockers_v2:
2 - images:
3 - "ghcr.io/{{ .Env.GITHUB_REPOSITORY | tolower }}"
4 tags:
5 - "{{ .Tag }}"
6 - "{{ if not .Prerelease }}latest{{ end }}"