From 993817d750495846f0f4def639bb6d2079dff6d8 Mon Sep 17 00:00:00 2001 From: Frederic BIDON Date: Fri, 12 Dec 2025 21:08:28 +0100 Subject: [PATCH] feat: go-test workflow for mono-repos Signed-off-by: Frederic BIDON --- .claude/CLAUDE.md | 14 ++ .claude/skills/github-actions.md | 86 ++++++-- .github/workflows/go-test-monorepo.yml | 221 +++++++++++++++++++ .github/workflows/local-go-test-monorepo.yml | 19 ++ .gitignore | 4 +- README.md | 11 +- go.work | 6 + sample-monorepo/doc.go | 2 + sample-monorepo/go.mod | 5 + sample-monorepo/go.sum | 2 + sample-monorepo/pkg/pkg.go | 14 ++ sample-monorepo/pkg/pkg_test.go | 24 ++ 12 files changed, 382 insertions(+), 26 deletions(-) create mode 100644 .github/workflows/go-test-monorepo.yml create mode 100644 .github/workflows/local-go-test-monorepo.yml create mode 100644 go.work create mode 100644 sample-monorepo/doc.go create mode 100644 sample-monorepo/go.mod create mode 100644 sample-monorepo/go.sum create mode 100644 sample-monorepo/pkg/pkg.go create mode 100644 sample-monorepo/pkg/pkg_test.go diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 6d3c1bf..62957eb 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -6,6 +6,20 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co This repository provides shared, reusable GitHub Actions workflows for the go-openapi organization. The workflows are designed to be called from other go-openapi repositories to standardize CI/CD processes across the entire project family. +## GitHub Actions Skills + +**IMPORTANT**: When working with GitHub Actions workflows in this repository, refer to the comprehensive GitHub Actions skill: + +📖 **See `.claude/skills/github-actions.md`** for: +- Code style and formatting requirements (expression spacing, workflow commands) +- Security best practices (avoiding `secrets[inputs.name]` vulnerability) +- Race condition handling patterns (optimistic execution with error handling) +- Common workflow patterns (bot-credentials, wait-pending-jobs, auto-merge) +- Action definition best practices +- Documentation standards + +When proposing new reusable actions, always create them in `go-openapi/gh-actions` (not in this repo). + ## Testing & Development Commands ### Running Tests diff --git a/.claude/skills/github-actions.md b/.claude/skills/github-actions.md index cc20d22..c32e17d 100644 --- a/.claude/skills/github-actions.md +++ b/.claude/skills/github-actions.md @@ -57,18 +57,26 @@ if: inputs.enable-signing == 'true' ### GitHub Workflow Commands -Use workflow commands for user-visible messages: +Use workflow commands for user-visible messages with **double colon separator**: -```yaml -# ✅ CORRECT - Shows as annotation in GitHub UI +```bash +# ✅ CORRECT - Double colon (::) separator after title echo "::notice title=build::Build completed successfully" echo "::warning title=race-condition::Merge already in progress" echo "::error title=deployment::Failed to deploy" -# ❌ WRONG - Just logs to console +# ❌ WRONG - Single colon separator +echo "::notice title=build:Build completed" # Missing second ':' +echo "::warning title=x:message" # Won't display correctly + +# ❌ WRONG - Just logs to console (no annotation) echo "Build completed" ``` +**Syntax pattern:** `::LEVEL title=TITLE::MESSAGE` +- `LEVEL`: notice, warning, or error +- Double `::` separator is required between title and message + ## Security Best Practices ### The secrets[inputs.name] Vulnerability @@ -342,14 +350,15 @@ on: workflow_call: inputs: # Use inputs for configuration + # IMPORTANT: Use type: string for boolean-like values (never type: boolean) enable-signing: - type: boolean + type: string # ✅ Use string, not boolean required: false - default: true + default: 'true' # String value bump-major: - type: boolean + type: string # ✅ Use string, not boolean required: false - default: false + default: 'false' # String value secrets: # Use secrets for sensitive data @@ -427,33 +436,64 @@ Brief description of what the action does. ## Common Gotchas -1. **Boolean input comparisons**: GitHub Actions inputs are strongly typed, with no "JS-like" truthy logic +1. **Workflow command syntax**: GitHub Actions workflow commands require **double colon separator** + ```bash + # ✅ CORRECT - Double :: separator + echo "::notice title=success::All tests passed" + echo "::warning title=deprecated::This feature is deprecated" + echo "::error title=failed::Build failed" + + # ❌ WRONG - Single : separator (won't display correctly) + echo "::notice title=success:All tests passed" + echo "::warning title=x:message" + + # Pattern: ::LEVEL title=TITLE::MESSAGE + # The double :: between title and message is mandatory + ``` + +2. **Boolean inputs are forbidden**: NEVER use `type: boolean` for workflow inputs due to unpredictable type coercion ```yaml - # ❌ WRONG - Boolean true is NOT equal to string 'true' + # ❌ FORBIDDEN - Boolean inputs have type coercion issues on: workflow_call: inputs: enable-feature: - type: boolean + type: boolean # ❌ NEVER USE THIS default: true + # The pattern `x == 'true' || x == true` seems safe but fails when: + # - x is not a boolean: `x == true` evaluates to true if x != null + # - Type coercion is unpredictable and error-prone + + # ✅ CORRECT - Always use string type for boolean-like inputs + on: + workflow_call: + inputs: + enable-feature: + type: string # ✅ Use string instead + default: 'true' # String value + jobs: my-job: - if: ${{ inputs.enable-feature == 'true' }} # FALSE when input is boolean true! - - # ✅ CORRECT - Handle both boolean and string values - if: ${{ inputs.enable-feature == 'true' || inputs.enable-feature == true }} + # Simple, reliable comparison + if: ${{ inputs.enable-feature == 'true' }} - # Note: In bash, this works fine because bash converts to string: - if [[ '${{ inputs.enable-feature }}' == 'true' ]]; then # Works in bash + # ✅ In bash, this works perfectly (inputs are always strings in bash): + if [[ '${{ inputs.enable-feature }}' == 'true' ]]; then + echo "Feature enabled" + fi ``` -2. **Expression evaluation in descriptions**: Don't use `${{ }}` in action.yml description fields -3. **Race conditions**: Always use optimistic execution + error handling, never check-then-act -4. **Secret exposure**: Never use `secrets[inputs.name]` - always use explicit secret parameters -5. **Branch deletion**: Use `wait-pending-jobs` before merging to prevent failures in non-required jobs -6. **Idempotency**: `gh pr merge --auto` is NOT idempotent - handle "Merge already in progress" error -7. **TOCTOU vulnerabilities**: State can change between check and action - handle at runtime + **Rule**: Use `type: string` with values `'true'` or `'false'` for all boolean-like workflow inputs. + + **Note**: Step outputs and bash variables are always strings, so `x == 'true'` works fine for those. + +3. **Expression evaluation in descriptions**: Don't use `${{ }}` in action.yml description fields +4. **Race conditions**: Always use optimistic execution + error handling, never check-then-act +5. **Secret exposure**: Never use `secrets[inputs.name]` - always use explicit secret parameters +6. **Branch deletion**: Use `wait-pending-jobs` before merging to prevent failures in non-required jobs +7. **Idempotency**: `gh pr merge --auto` is NOT idempotent - handle "Merge already in progress" error +8. **TOCTOU vulnerabilities**: State can change between check and action - handle at runtime ## Testing Workflows diff --git a/.github/workflows/go-test-monorepo.yml b/.github/workflows/go-test-monorepo.yml new file mode 100644 index 0000000..1640953 --- /dev/null +++ b/.github/workflows/go-test-monorepo.yml @@ -0,0 +1,221 @@ +name: go test [monorepo] + +permissions: + contents: read + pull-requests: read + +on: + workflow_call: + +defaults: + run: + shell: bash + +jobs: + lint: + name: Lint + runs-on: ubuntu-latest + outputs: + is_monorepo: ${{ steps.detect-monorepo.outputs.is_monorepo }} + steps: + - + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + with: + fetch-depth: 0 + - + uses: actions/setup-go@4dc6199c7b1a012772edbd06daecab0f50c9053c # v6.1.0 + with: + go-version: stable + check-latest: true + cache: true + cache-dependency-path: '**/go.sum' + - + name: Detect go mono-repo + id: detect-monorepo + run: | + count_modules=$(go list -m|wc -l) + if [[ "${count_modules}" -gt 1 ]] ; then + echo "is_monorepo=true" >> "${GITHUB_OUTPUT}" + echo "::notice title=is_monorepo::true" + exit + fi + echo "is_monorepo=false" >> "${GITHUB_OUTPUT}" + echo "::notice title=is_monorepo::false" + - + name: golangci-lint [mono-repo] + # golangci-action v9.1+ has an experimental built-in mono repo detection setup. + if: ${{ steps.detect-monorepo.outputs.is_monorepo == 'true' }} + uses: golangci/golangci-lint-action@e7fa5ac41e1cf5b7d48e45e42232ce7ada589601 # v9.1.0 + with: + version: latest + skip-cache: true + experimental: "automatic-module-directories" + - + name: golangci-lint + if: ${{ steps.detect-monorepo.outputs.is_monorepo != 'true' }} + uses: golangci/golangci-lint-action@1e7e51e771db61008b38414a730f564565cf7c20 # v9.2.0 + with: + version: latest + only-new-issues: true + skip-cache: true + + # Carry out the linting the traditional way, within a shell loop + #- + # name: Lint multiple modules + # if: ${{ steps.detect-monorepo.outputs.is_monorepo == 'true' }} + # # golangci-lint doesn't support go.work to lint multiple modules in one single pass + # run: | + # set -euxo pipefail + # git fetch origin master + # git show --no-patch --oneline origin/master + # while read -r module_location ; do + # pushd "${module_location}" + # golangci-lint run --new-from-rev origin/master + # popd + # done < <(go list -f '{{.Dir}}' -m) + + test: + name: Unit tests mono-repo + needs: [ lint ] + runs-on: ${{ matrix.os }} + strategy: + matrix: + os: [ ubuntu-latest, macos-latest, windows-latest ] + go: ['oldstable', 'stable' ] + steps: + - + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + - + uses: actions/setup-go@4dc6199c7b1a012772edbd06daecab0f50c9053c # v6.1.0 + id: go-setup + with: + go-version: '${{ matrix.go }}' + check-latest: true + cache: true + cache-dependency-path: '**/go.sum' + - + name: Detect go version + id: detect-test-work + run: | + go_minor_version=$(echo '${{ steps.go-setup.outputs.go-version }}'|cut -d' ' -f3|cut -d'.' -f2) + echo "go-minor-version=${go_minor_version}" >> "${GITHUB_OUTPUT}" + + if [[ "${go_minor_version}" -ge 25 && -f "go.work" ]] ; then + echo "supported=true" >> "${GITHUB_OUTPUT}" + echo "::notice title=go test work supported::true" + else + echo "supported=false" >> "${GITHUB_OUTPUT}" + echo "::notice title=go test work supported::false" + fi + echo "::notice title=go minor version::${go_minor_version}" + - + name: Install gotestsum + uses: go-openapi/gh-actions/install/gotestsum@6c7952706aa7afa9141262485767d9270ef5b00b # v1.3.0 + - + name: Run unit tests on all modules in this repo (go1.25+ with go.work) + if: ${{ needs.lint.outputs.is_monorepo == 'true' && steps.detect-test-work.outputs.supported == 'true' }} + # with go.work file enabled, go test recognizes sub-modules and collects all packages to be covered + # without specifying -coverpkg. + # + # This requires: + # * go.work properly initialized with use of all known modules + # * go.work committed to git + run: > + gotestsum + --jsonfile 'unit.report.${{ matrix.os }}-${{ matrix.go }}.json' + -- + work + -race + -p 2 + -count 1 + -timeout=20m + -coverprofile='unit.coverage.${{ matrix.os }}-${{ matrix.go }}.out' + -covermode=atomic + ./... + - + name: Run unit tests on all modules in this repo ( + ${{ + needs.lint.outputs.is_monorepo == 'true' && steps.detect-test-work.outputs.supported != 'true' + }} + run: | + declare -a ALL_MODULES + BASH_MAJOR=$(echo "${BASH_VERSION}"|cut -d'.' -f1) + if [[ "${BASH_MAJOR}" -ge 4 ]] ; then + mapfile ALL_MODULES < <(go list -f '{{.Dir}}/...' -m) + else + # for older bash versions, e.g. on macOS runner. This fallback will eventually disappear. + while read -r line ; do + ALL_MODULES+=("${line}") + done < <(go list -f '{{.Dir}}/...' -m) + fi + echo "::notice title=Modules found::${ALL_MODULES[@]}" + + gotestsum \ + --jsonfile 'unit.report.${{ matrix.os }}-${{ matrix.go }}.json' \ + -- \ + -race \ + -p 2 \ + -count 1 \ + -timeout=20m \ + -coverprofile='unit.coverage.${{ matrix.os }}-${{ matrix.go }}.out' \ + -covermode=atomic \ + ${ALL_MODULES[@]} + - + name: Run unit tests + if: ${{ needs.lint.outputs.is_monorepo != 'true' }} + run: > + gotestsum + --jsonfile 'unit.report.${{ matrix.os }}-${{ matrix.go }}.json' + -- + -race + -p 2 + -count 1 + -timeout=20m + -coverprofile='unit.coverage.${{ matrix.os }}-${{ matrix.go }}.out' + -covermode=atomic + -coverpkg="$(go list)"/... + ./... + - + name: Upload coverage artifacts + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 + with: + # *.coverage.* pattern is automatically detected by codecov + path: '**/*.coverage.*.out' + name: 'unit.coverage.${{ matrix.os }}-${{ matrix.go }}' + retention-days: 1 + - + name: Upload test report artifacts + # upload report even if tests fail. BTW, this is when they are valuable. + if: ${{ !cancelled() }} + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 + with: + path: '**/unit.report.*.json' + name: 'unit.report.${{ matrix.os }}-${{ matrix.go }}' + retention-days: 1 + + fuzz-test: + # fuzz-test supports go monorepos + uses: ./.github/workflows/fuzz-test.yml + + test-complete: + # description: | + # Be explicit about all tests being passed. This allows for setting up only a few status checks on PRs. + name: tests completed + needs: [test,fuzz-test] + runs-on: ubuntu-latest + steps: + - + name: Tests completed + run: | + echo "::notice title=Success::All tests passed" + + collect-coverage: + needs: [test-complete] + if: ${{ !cancelled() && needs.test-complete.result == 'success' }} + uses: ./.github/workflows/collect-coverage.yml + + collect-reports: + needs: [test] + if: ${{ !cancelled() }} + uses: ./.github/workflows/collect-reports.yml diff --git a/.github/workflows/local-go-test-monorepo.yml b/.github/workflows/local-go-test-monorepo.yml new file mode 100644 index 0000000..d4d7a99 --- /dev/null +++ b/.github/workflows/local-go-test-monorepo.yml @@ -0,0 +1,19 @@ +name: go test [mono-repo, Test only] + +permissions: + contents: read + pull-requests: read + +on: + push: + tags: + - v* + branches: + - master + + pull_request: + +jobs: + go-monorepo-test: + uses: ./.github/workflows/go-test-monorepo.yml + secrets: inherit diff --git a/.gitignore b/.gitignore index aaadf73..b0ea418 100644 --- a/.gitignore +++ b/.gitignore @@ -21,8 +21,8 @@ profile.cov # vendor/ # Go workspace file -go.work -go.work.sum +#go.work +#go.work.sum # env file .env diff --git a/README.md b/README.md index 516d78d..c2e4ebd 100644 --- a/README.md +++ b/README.md @@ -101,12 +101,16 @@ jobs: ### Test automation -* go-test.yml: go unit tests **TODO** support for mono-repos +* go-test.yml: go unit tests * includes: * fuzz-test.yml: orchestrates fuzz testing with a cached corpus * collect-coverage.yml: (common) collect & publish test coverage (to codecov) * collect-reports.yml: (common) collect & publish test reports (to codecov and github) +* go-test-monorepo.yml: go unit tests, with support for go mono-repos (same features) + +>NOTE: for mono-repos, the workflow works best with go1.25 and go.work declaring all your modules and committed to git. + ### Security * codeql.yml: CodeQL workflow for go and github actions @@ -118,6 +122,11 @@ jobs: * tag-release.yml: cut a release on push tag * release.yml: (common) release & release notes build +>NOTE: mono-repos are not supported yet. + +Release notes are produced using `git-cliff`. The configuration may be set using a `.cliff.toml` file. +The default configuration is the `.cliff.toml` in this repo (uses remote config). + ### Documentation quality * contributors.yml: updates CONTRIBUTORS.md diff --git a/go.work b/go.work new file mode 100644 index 0000000..c2d019b --- /dev/null +++ b/go.work @@ -0,0 +1,6 @@ +go 1.24.0 + +use ( + . + ./sample-monorepo +) diff --git a/sample-monorepo/doc.go b/sample-monorepo/doc.go new file mode 100644 index 0000000..2093717 --- /dev/null +++ b/sample-monorepo/doc.go @@ -0,0 +1,2 @@ +// Package monorepo is used to test CI workflows for monorepos. +package monorepo diff --git a/sample-monorepo/go.mod b/sample-monorepo/go.mod new file mode 100644 index 0000000..e774a3f --- /dev/null +++ b/sample-monorepo/go.mod @@ -0,0 +1,5 @@ +module github.com/go-openapi/ci-workflows/sample-monorepo + +go 1.24.0 + +require github.com/go-openapi/testify/v2 v2.0.2 diff --git a/sample-monorepo/go.sum b/sample-monorepo/go.sum new file mode 100644 index 0000000..1876434 --- /dev/null +++ b/sample-monorepo/go.sum @@ -0,0 +1,2 @@ +github.com/go-openapi/testify/v2 v2.0.2 h1:X999g3jeLcoY8qctY/c/Z8iBHTbwLz7R2WXd6Ub6wls= +github.com/go-openapi/testify/v2 v2.0.2/go.mod h1:HCPmvFFnheKK2BuwSA0TbbdxJ3I16pjwMkYkP4Ywn54= diff --git a/sample-monorepo/pkg/pkg.go b/sample-monorepo/pkg/pkg.go new file mode 100644 index 0000000..2909ec0 --- /dev/null +++ b/sample-monorepo/pkg/pkg.go @@ -0,0 +1,14 @@ +// Package pkg exercises CI pipelines. +package pkg + +func Pkg() string { + return "" +} + +func fuzzable(input []byte) string { + if len(input) > 0 { + return string(input) + } + + return "0" +} diff --git a/sample-monorepo/pkg/pkg_test.go b/sample-monorepo/pkg/pkg_test.go new file mode 100644 index 0000000..1b95444 --- /dev/null +++ b/sample-monorepo/pkg/pkg_test.go @@ -0,0 +1,24 @@ +package pkg + +import ( + "testing" + + "github.com/go-openapi/testify/v2/assert" + "github.com/go-openapi/testify/v2/require" +) + +func TestPkg(t *testing.T) { + assert.Empty(t, Pkg()) +} + +func FuzzMonorepo(f *testing.F) { + f.Add([]byte(nil)) + f.Add([]byte{}) + f.Add([]byte{'x'}) + + f.Fuzz(func(t *testing.T, input []byte) { + require.NotPanics(t, func() { + _ = fuzzable(input) + }) + }) +}