From d99b679a290326c2edae047ba6009c5ea2e2b23b Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Sat, 30 May 2026 11:59:40 +0000 Subject: [PATCH] ci: consolidate GitHub Actions workflows into a single file Replaces fragmented workflow files (fmt.yml, golangci-lint.yml, release.yml, test.yml, vet.yml) with a unified `ci.yml`. This implements event routing, deduplication, auto-formatting PRs, and tags-based releases following best practices. Updated README.md to point to the new single CI status badge. Co-authored-by: arran4 <111667+arran4@users.noreply.github.com> --- .github/workflows/ci.yml | 406 ++++++++++++++++++++++++++++ .github/workflows/fmt.yml | 20 -- .github/workflows/golangci-lint.yml | 19 -- .github/workflows/release.yml | 37 --- .github/workflows/test.yml | 18 -- .github/workflows/vet.yml | 18 -- README.md | 5 +- 7 files changed, 407 insertions(+), 116 deletions(-) create mode 100644 .github/workflows/ci.yml delete mode 100644 .github/workflows/fmt.yml delete mode 100644 .github/workflows/golangci-lint.yml delete mode 100644 .github/workflows/release.yml delete mode 100644 .github/workflows/test.yml delete mode 100644 .github/workflows/vet.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..e085dba --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,406 @@ +# Agent rules for generation: +# https://arran4.github.io/blog/post/2026/006-github-ci-and-deploy/ +# Built using this post as a reference/guide. +name: CI/CD + +on: + push: + branches: [main, master] + tags: ['v*', 'v*.*.*', 'v*.*.*-rc*', 'v*.*.*-beta*', 'test-*'] + pull_request: + types: [opened, synchronize, reopened, ready_for_review, closed] + branches: [main, master] + release: + types: [published] + workflow_dispatch: + inputs: + mode: + type: choice + default: lint-fix + options: [lint-fix, build, release-major, release-minor, release-patch, release-test, release-rc, release-alpha, monthly-maintenance] + release_version_override: + type: string + default: '' + allow_prs: + type: boolean + default: true + schedule: + - cron: '17 3 1 * *' + - cron: '41 2 * * *' + +concurrency: + group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +permissions: + contents: write + discussions: write + pull-requests: write + checks: write + packages: write + security-events: write + +jobs: + route: + name: Route event + runs-on: ubuntu-latest + outputs: + run_code_checks: ${{ steps.route.outputs.run_code_checks }} + run_pr_meta_checks: ${{ steps.route.outputs.run_pr_meta_checks }} + run_cleanup: ${{ steps.route.outputs.run_cleanup }} + run_release: ${{ steps.route.outputs.run_release }} + is_monthly: ${{ steps.route.outputs.is_monthly }} + is_nightly: ${{ steps.route.outputs.is_nightly }} + steps: + - id: route + shell: bash + run: | + set -euo pipefail + + run_code_checks=false + run_pr_meta_checks=false + run_cleanup=false + run_release=false + is_monthly=false + is_nightly=false + + case "${{ github.event_name }}" in + push) + run_code_checks=true + ;; + pull_request) + if [[ "${{ github.event.action }}" == "closed" ]]; then + run_cleanup=true + else + run_pr_meta_checks=true + run_code_checks=true + fi + ;; + release) + run_release=true + ;; + workflow_dispatch) + run_code_checks=true + if [[ "${{ inputs.mode }}" == release-* ]]; then + run_release=true + fi + if [[ "${{ inputs.mode }}" == "monthly-maintenance" ]]; then + is_monthly=true + fi + if [[ "${{ inputs.mode }}" == "lint-fix" ]]; then + is_nightly=true + fi + ;; + schedule) + run_code_checks=true + if [[ "${{ github.event.schedule }}" == "17 3 1 * *" ]]; then + is_monthly=true + fi + if [[ "${{ github.event.schedule }}" == "41 2 * * *" ]]; then + is_nightly=true + fi + ;; + esac + + echo "run_code_checks=$run_code_checks" >> "$GITHUB_OUTPUT" + echo "run_pr_meta_checks=$run_pr_meta_checks" >> "$GITHUB_OUTPUT" + echo "run_cleanup=$run_cleanup" >> "$GITHUB_OUTPUT" + echo "run_release=$run_release" >> "$GITHUB_OUTPUT" + echo "is_monthly=$is_monthly" >> "$GITHUB_OUTPUT" + echo "is_nightly=$is_nightly" >> "$GITHUB_OUTPUT" + + prepare-release-tag: + name: Prepare release tag + needs: [route] + if: ${{ github.event_name == 'workflow_dispatch' && startsWith(inputs.mode, 'release-') }} + runs-on: ubuntu-latest + outputs: + release_tag: ${{ steps.tag.outputs.release_tag }} + next_version: ${{ steps.tag.outputs.next_version }} + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Setup git-tag-inc + uses: arran4/git-tag-inc-action@v1 + with: + mode: install + - id: tag + shell: bash + env: + MODE: ${{ inputs.mode }} + OVERRIDE: ${{ inputs.release_version_override }} + run: | + set -euo pipefail + git config --global user.name "github-actions[bot]" + git config --global user.email "41898282+github-actions[bot]@users.noreply.github.com" + + if [[ -n "$OVERRIDE" ]]; then + OVERRIDE="${OVERRIDE#v}" + next_tag="v$OVERRIDE" + else + case "$MODE" in + release-major) level="major"; suffix="" ;; + release-minor) level="minor"; suffix="" ;; + release-patch) level="patch"; suffix="" ;; + release-test) level="patch"; suffix="test" ;; + release-rc) level="patch"; suffix="rc" ;; + release-alpha) level="patch"; suffix="alpha" ;; + *) echo "Unsupported release mode: $MODE"; exit 1 ;; + esac + if command -v git-tag-inc >/dev/null 2>&1; then + level="${level#-}" + args=(-print-version-only "$level") + [[ -n "$suffix" ]] && args+=("$suffix") + next_tag=$(git-tag-inc "${args[@]}") + else + git fetch --tags --force + latest=$(git tag -l 'v*' | sed 's/^v//' | sort -V | tail -n 1) + [[ -z "$latest" ]] && latest='0.0.0' + + if command -v npx >/dev/null 2>&1; then + case "$level" in + major) bumped=$(npx --yes semver "$latest" -i major) ;; + minor) bumped=$(npx --yes semver "$latest" -i minor) ;; + *) bumped=$(npx --yes semver "$latest" -i patch) ;; + esac + next_tag="v${bumped}" + else + base="${latest%%-*}" + IFS='.' read -r maj min pat <<< "$base" + case "$level" in + major) maj=$((maj+1)); min=0; pat=0 ;; + minor) min=$((min+1)); pat=0 ;; + *) pat=$((pat+1)) ;; + esac + next_tag="v${maj}.${min}.${pat}" + fi + + if [[ -n "$suffix" ]]; then + next_tag="${next_tag}-${suffix}.1" + fi + fi + fi + + [[ "$next_tag" =~ ^v[0-9]+\.[0-9]+\.[0-9]+([-.][0-9A-Za-z.]+)?$ ]] || { + echo "Invalid tag format: $next_tag" >&2 + exit 1 + } + git fetch --tags --force + if git rev-parse "$next_tag" >/dev/null 2>&1; then + echo "Tag already exists: $next_tag" >&2 + echo "Choose a new mode or set release_version_override." >&2 + exit 1 + fi + + echo "release_tag=$next_tag" >> "$GITHUB_OUTPUT" + clean_tag="${next_tag#v}"; clean_tag="${clean_tag%%-*}" + IFS='.' read -r maj min pat <<< "$clean_tag" + echo "next_version=${maj:-0}.${min:-0}.$(( ${pat:-0} + 1 ))-SNAPSHOT" >> "$GITHUB_OUTPUT" + + discover: + name: Discover capabilities and cost profile + needs: route + runs-on: ubuntu-latest + outputs: + profile: ${{ steps.profile.outputs.profile }} + has_go: ${{ steps.detect.outputs.has_go }} + has_node: ${{ steps.detect.outputs.has_node }} + has_dart: ${{ steps.detect.outputs.has_dart }} + has_flutter: ${{ steps.detect.outputs.has_flutter }} + has_qt_cpp: ${{ steps.detect.outputs.has_qt_cpp }} + has_make_c: ${{ steps.detect.outputs.has_make_c }} + has_docker: ${{ steps.detect.outputs.has_docker }} + has_goreleaser: ${{ steps.detect.outputs.has_goreleaser }} + has_dart_or_flutter_tests: ${{ steps.detect.outputs.has_dart_or_flutter_tests }} + has_packaging: ${{ steps.detect.outputs.has_packaging }} + steps: + - uses: actions/checkout@v4 + + - id: detect + shell: bash + run: | + set -euo pipefail + + echo "has_go=true" >> "$GITHUB_OUTPUT" + echo "has_node=false" >> "$GITHUB_OUTPUT" + echo "has_dart=false" >> "$GITHUB_OUTPUT" + echo "has_flutter=false" >> "$GITHUB_OUTPUT" + echo "has_qt_cpp=false" >> "$GITHUB_OUTPUT" + echo "has_make_c=false" >> "$GITHUB_OUTPUT" + echo "has_docker=false" >> "$GITHUB_OUTPUT" + echo "has_goreleaser=true" >> "$GITHUB_OUTPUT" + + ([[ -d test ]] || [[ -d tests ]] || [[ -f pubspec.yaml ]]) && echo "has_dart_or_flutter_tests=true" >> "$GITHUB_OUTPUT" || echo "has_dart_or_flutter_tests=false" >> "$GITHUB_OUTPUT" + ([[ -d packaging ]] || [[ -d pkg ]] || [[ -f debian/control ]]) && echo "has_packaging=true" >> "$GITHUB_OUTPUT" || echo "has_packaging=false" >> "$GITHUB_OUTPUT" + + - id: profile + shell: bash + run: | + set -euo pipefail + if [[ "${{ github.event.repository.private }}" == "true" ]]; then + echo "profile=private" >> "$GITHUB_OUTPUT" + else + echo "profile=public" >> "$GITHUB_OUTPUT" + fi + + golangci: + name: lint + needs: [route, discover] + if: ${{ !failure() && !cancelled() && needs.discover.outputs.has_go == 'true' && needs.route.outputs.run_code_checks == 'true' }} + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + - name: golangci-lint + uses: golangci/golangci-lint-action@v6 + with: + version: latest + + go-test: + name: Go lint/test (${{ matrix.os }}) + needs: [route, discover, golangci] + if: ${{ !failure() && !cancelled() && needs.discover.outputs.has_go == 'true' && needs.route.outputs.run_code_checks == 'true' }} + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest] + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + cache: true + - name: Test + run: go test ./... -v + + go-vet: + name: Go vet + needs: [route, discover] + if: ${{ !failure() && !cancelled() && needs.discover.outputs.has_go == 'true' && needs.route.outputs.run_code_checks == 'true' && needs.discover.outputs.profile == 'public' }} + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + cache: true + - run: go vet ./... + + autofix: + name: Auto-format and open PR + needs: [route, discover] + if: ${{ github.event_name == 'workflow_dispatch' && inputs.mode == 'lint-fix' && inputs.allow_prs == true }} + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Setup Go (if needed) + if: ${{ needs.discover.outputs.has_go == 'true' }} + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + - name: Run autofix formatters + shell: bash + run: | + set -euo pipefail + if [[ "${{ needs.discover.outputs.has_go }}" == "true" ]]; then + go fix ./... || true + go fmt ./... || true + fi + - name: Create PR if changes exist + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + shell: bash + run: | + set -euo pipefail + if git diff --quiet; then + echo "No changes; exiting." + exit 0 + fi + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + BRANCH="ci/autofix/${{ github.run_id }}" + git checkout -b "$BRANCH" + git add -A + git commit -m "ci: automated formatting fixes" + git push -u origin "$BRANCH" || true + gh pr create \ + --title "ci: automated formatting fixes" \ + --body "Automated formatting pass." \ + --base main \ + --head "$BRANCH" \ + --label "ci-autofix" || true + + goreleaser: + name: GoReleaser + needs: [route, discover, go-test, prepare-release-tag] + if: ${{ !failure() && !cancelled() && needs.discover.outputs.has_go == 'true' && needs.discover.outputs.has_goreleaser == 'true' && (((github.event_name == 'push') && startsWith(github.ref, 'refs/tags/v')) || (github.event_name == 'workflow_dispatch' && startsWith(inputs.mode, 'release-'))) }} + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + - name: Tag commit for release (workflow_dispatch) + if: ${{ github.event_name == 'workflow_dispatch' && startsWith(inputs.mode, 'release-') }} + run: git tag ${{ needs.prepare-release-tag.outputs.release_tag }} + - name: Run GoReleaser + uses: goreleaser/goreleaser-action@v6 + with: + distribution: goreleaser + version: '~> v2' + args: >- + release --clean + ${{ (github.event_name == 'workflow_dispatch' && (inputs.mode == 'release-test' || inputs.mode == 'release-rc' || inputs.mode == 'release-alpha')) && '--snapshot' || '' }} + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GORELEASER_CURRENT_TAG: ${{ needs.prepare-release-tag.outputs.release_tag != '' && needs.prepare-release-tag.outputs.release_tag || '' }} + + manual-gh-release: + name: Manual release creation + needs: [prepare-release-tag] + if: ${{ !failure() && !cancelled() && github.event_name == 'workflow_dispatch' && startsWith(inputs.mode, 'release-') }} + runs-on: ubuntu-latest + permissions: + contents: write + discussions: write + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Sync version source with highest existing tag first + run: | + set -euo pipefail + git fetch --tags --force + - name: Push prepared tag (retry) + env: + TAG: ${{ needs.prepare-release-tag.outputs.release_tag }} + run: | + set -euo pipefail + git tag "$TAG" + git push origin "$TAG" || { sleep 2; git push origin "$TAG"; } + - name: Create release with generated notes + discussion + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TAG: ${{ needs.prepare-release-tag.outputs.release_tag }} + MODE: ${{ inputs.mode }} + run: | + set -euo pipefail + prerelease="" + case "$MODE" in + release-test|release-rc|release-alpha) prerelease="--prerelease" ;; + esac + + discussion_arg="--discussion-category Announcements" + + if [[ -n "$prerelease" ]]; then + gh release create "$TAG" --generate-notes $prerelease || true + else + gh release create "$TAG" --generate-notes $discussion_arg || \ + gh release create "$TAG" --generate-notes + fi diff --git a/.github/workflows/fmt.yml b/.github/workflows/fmt.yml deleted file mode 100644 index 3d7f8ae..0000000 --- a/.github/workflows/fmt.yml +++ /dev/null @@ -1,20 +0,0 @@ -name: Format - -on: - push: - branches: [ "main", "master" ] - pull_request: - -jobs: - fmt: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - name: Set up Go - uses: actions/setup-go@v5 - with: - go-version-file: go.mod - - name: Format - run: | - go fmt ./... - git diff --exit-code diff --git a/.github/workflows/golangci-lint.yml b/.github/workflows/golangci-lint.yml deleted file mode 100644 index 9bb74b8..0000000 --- a/.github/workflows/golangci-lint.yml +++ /dev/null @@ -1,19 +0,0 @@ -name: golangci-lint - -on: - push: - branches: [ "main", "master" ] - pull_request: - -jobs: - golangci-lint: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-go@v5 - with: - go-version-file: go.mod - - name: golangci-lint - uses: golangci/golangci-lint-action@v6 - with: - version: latest diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml deleted file mode 100644 index 902848b..0000000 --- a/.github/workflows/release.yml +++ /dev/null @@ -1,37 +0,0 @@ -# Generated by github.com/arran4/go-subcommand/cmd/gosubc -name: Release - -permissions: - contents: write - -on: - push: - tags: - - 'v*' - -jobs: - goreleaser: - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - name: Set up Go - uses: actions/setup-go@v5 - with: - go-version: stable - - name: Login to GHCR - uses: docker/login-action@v3 - with: - registry: ghcr.io - username: ${{ github.repository_owner }} - password: ${{ secrets.GITHUB_TOKEN }} - - name: Run GoReleaser - uses: goreleaser/goreleaser-action@v5 - with: - distribution: goreleaser - version: latest - args: release --clean - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml deleted file mode 100644 index ec8d7e7..0000000 --- a/.github/workflows/test.yml +++ /dev/null @@ -1,18 +0,0 @@ -name: Test - -on: - push: - branches: [ "main", "master" ] - pull_request: - -jobs: - test: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - name: Set up Go - uses: actions/setup-go@v5 - with: - go-version-file: go.mod - - name: Test - run: go test ./... diff --git a/.github/workflows/vet.yml b/.github/workflows/vet.yml deleted file mode 100644 index 2702ef6..0000000 --- a/.github/workflows/vet.yml +++ /dev/null @@ -1,18 +0,0 @@ -name: Vet - -on: - push: - branches: [ "main", "master" ] - pull_request: - -jobs: - vet: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - name: Set up Go - uses: actions/setup-go@v5 - with: - go-version-file: go.mod - - name: Vet - run: go vet ./... diff --git a/README.md b/README.md index eaee53f..c67d562 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,6 @@ # strings2 -[![Test Status](https://github.com/arran4/strings2/actions/workflows/test.yml/badge.svg)](https://github.com/arran4/strings2/actions/workflows/test.yml) -[![Vet Status](https://github.com/arran4/strings2/actions/workflows/vet.yml/badge.svg)](https://github.com/arran4/strings2/actions/workflows/vet.yml) -[![Lint Status](https://github.com/arran4/strings2/actions/workflows/golangci-lint.yml/badge.svg)](https://github.com/arran4/strings2/actions/workflows/golangci-lint.yml) -[![Fmt Status](https://github.com/arran4/strings2/actions/workflows/fmt.yml/badge.svg)](https://github.com/arran4/strings2/actions/workflows/fmt.yml) +[![CI Status](https://github.com/arran4/strings2/actions/workflows/ci.yml/badge.svg)](https://github.com/arran4/strings2/actions/workflows/ci.yml) [![Go Reference](https://pkg.go.dev/badge/github.com/arran4/strings2.svg)](https://pkg.go.dev/github.com/arran4/strings2) strings2 provides utilities for converting slices of words into various casing conventions. It is intended to supplement Go's standard library `strings` package with helpers for creating formats such as `camelCase`, `PascalCase`, `snake_case` and `kebab-case`.