Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions .claude/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
86 changes: 63 additions & 23 deletions .claude/skills/github-actions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down
221 changes: 221 additions & 0 deletions .github/workflows/go-test-monorepo.yml
Original file line number Diff line number Diff line change
@@ -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 (<go1.25 or no go.work)
if: >
${{
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
19 changes: 19 additions & 0 deletions .github/workflows/local-go-test-monorepo.yml
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading