diff --git a/.github/workflows/create-release.yml b/.github/workflows/create-release.yml index 0feb321..f797fc4 100644 --- a/.github/workflows/create-release.yml +++ b/.github/workflows/create-release.yml @@ -27,8 +27,8 @@ on: type: boolean jobs: - tag: - name: Create Version Tag + release: + name: Create Version Tag and Release runs-on: ubuntu-latest permissions: @@ -41,102 +41,34 @@ jobs: ref: ${{ github.event.inputs.branch || github.event.repository.default_branch }} fetch-depth: 0 - - name: Get target commit SHA - id: commit - run: | - if [ "${{ github.event.inputs.commit }}" = "HEAD" ] || [ -z "${{ github.event.inputs.commit }}" ]; then - COMMIT_SHA=$(git rev-parse HEAD) - else - COMMIT_SHA="${{ github.event.inputs.commit }}" - # Verify the commit exists - if ! git cat-file -e "$COMMIT_SHA^{commit}" 2>/dev/null; then - echo "Error: Commit $COMMIT_SHA does not exist" - exit 1 - fi - fi - echo "sha=$COMMIT_SHA" >> $GITHUB_OUTPUT - echo "Target commit: $COMMIT_SHA" - - - name: Get latest version tag - id: latest_version - uses: ./get-latest-semver-tag - with: - prefix: 'v' - default-version: 'v0.1.0' - - - name: Calculate new version - id: new_version - uses: ./get-next-semver + - name: Tag and create semver release + id: tag-and-create-release + uses: ./tag-and-create-semver-release with: - current-version: ${{ steps.latest_version.outputs.tag }} + branch: ${{ github.event.inputs.branch }} + commit: ${{ github.event.inputs.commit }} increment-major: ${{ github.event.inputs.increment_major }} increment-minor: ${{ github.event.inputs.increment_minor }} prefix: 'v' + default-version: 'v0.1.0' + github-token: ${{ secrets.GITHUB_TOKEN }} - - name: Check if tag already exists - run: | - if git tag -l | grep -q "^${{ steps.new_version.outputs.version }}$"; then - echo "Error: Tag ${{ steps.new_version.outputs.version }} already exists" - exit 1 - fi - - - name: Create and push tags - run: | - git config user.name "github-actions[bot]" - git config user.email "github-actions[bot]@users.noreply.github.com" - - # Create and push the semver tag - git tag -a "${{ steps.new_version.outputs.version }}" ${{ steps.commit.outputs.sha }} -m "Release ${{ steps.new_version.outputs.version }}" - git push origin "${{ steps.new_version.outputs.version }}" - - echo "✅ Created and pushed tag ${{ steps.new_version.outputs.version }} for commit ${{ steps.commit.outputs.sha }}" - - # Create major version tag if major version is 1 or more - MAJOR=${{ steps.new_version.outputs.major }} - - if [ "$MAJOR" -ge 1 ]; then - MAJOR_TAG="v${MAJOR}" - echo "Creating major version tag: $MAJOR_TAG" - - # Delete existing major tag if it exists (force update) - if git tag -l | grep -q "^${MAJOR_TAG}$"; then - git tag -d "$MAJOR_TAG" || true - git push origin ":refs/tags/$MAJOR_TAG" || true - echo "Deleted existing major tag $MAJOR_TAG" - fi - - # Create and push new major tag - git tag -a "$MAJOR_TAG" ${{ steps.commit.outputs.sha }} -m "Major version $MAJOR_TAG (latest: ${{ steps.new_version.outputs.version }})" - git push origin "$MAJOR_TAG" - - echo "✅ Created and pushed major version tag $MAJOR_TAG" - else - echo "Major version is 0, skipping major version tag creation" - fi - - - name: Create release with auto-generated notes + - name: Generate job summary run: | - # Create release notes file with metadata - cat > release_notes.md << 'EOF' - ## What's Changed - - This release was created from commit ${{ steps.commit.outputs.sha }} on branch `${{ github.event.inputs.branch || github.event.repository.default_branch }}`. + cat >> $GITHUB_STEP_SUMMARY << 'EOF' + ## 🎉 Release Created Successfully! - ### Version Details - - **Previous version**: ${{ steps.latest_version.outputs.found == 'true' && steps.latest_version.outputs.tag || 'none' }} - - **New version**: ${{ steps.new_version.outputs.version }} - - **Version type**: ${{ steps.new_version.outputs.increment-type }} release + | Detail | Value | + |--------|-------| + | **Previous Version** | `${{ steps.tag-and-create-release.outputs.previous-version }}` | + | **New Version** | `${{ steps.tag-and-create-release.outputs.new-version }}` | + | **Increment Type** | `${{ steps.tag-and-create-release.outputs.increment-type }}` | + | **Target Commit** | `${{ steps.tag-and-create-release.outputs.target-commit }}` | + | **Release URL** | [${{ steps.tag-and-create-release.outputs.new-version }}](${{ steps.tag-and-create-release.outputs.release-url }}) | - EOF + ### 📋 Summary - # Create release with auto-generated notes - gh release create "${{ steps.new_version.outputs.version }}" \ - --title "${{ steps.new_version.outputs.version }}" \ - --notes-file release_notes.md \ - --generate-notes \ - --latest \ - --target "${{ steps.commit.outputs.sha }}" + Successfully created a **${{ steps.tag-and-create-release.outputs.increment-type }} release** from `${{ steps.tag-and-create-release.outputs.previous-version }}` to `${{ steps.tag-and-create-release.outputs.new-version }}`. - echo "✅ Created release ${{ steps.new_version.outputs.version }} with auto-generated notes" - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} \ No newline at end of file + The release is now available at: ${{ steps.tag-and-create-release.outputs.release-url }} + EOF \ No newline at end of file diff --git a/.gitignore b/.gitignore index 9473728..82a4de1 100644 --- a/.gitignore +++ b/.gitignore @@ -21,6 +21,7 @@ close-issue/close-issue comment-issue/comment-issue get-latest-semver-tag/get-latest-semver-tag get-next-semver/get-next-semver +tag-and-create-semver-release/tag-and-create-semver-release # IDE files .vscode/ diff --git a/Makefile b/Makefile index c914b66..880d24e 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: build clean test help build-create-issue build-find-issue build-close-issue build-comment-issue build-get-latest-semver-tag build-get-next-semver +.PHONY: build clean test help build-create-issue build-find-issue build-close-issue build-comment-issue build-get-latest-semver-tag build-get-next-semver build-tag-and-create-semver-release # Default target help: @@ -9,7 +9,7 @@ help: @echo " help - Show this help message" # Build all actions -build: build-create-issue build-find-issue build-close-issue build-comment-issue build-get-latest-semver-tag build-get-next-semver +build: build-create-issue build-find-issue build-close-issue build-comment-issue build-get-latest-semver-tag build-get-next-semver build-tag-and-create-semver-release build-create-issue: @echo "Building create-issue..." @@ -35,6 +35,10 @@ build-get-next-semver: @echo "Building get-next-semver..." cd get-next-semver && go build -o get-next-semver main.go +build-tag-and-create-semver-release: + @echo "Building tag-and-create-semver-release..." + cd tag-and-create-semver-release && go build -o tag-and-create-semver-release main.go + # Clean all built binaries clean: @echo "Cleaning built binaries..." @@ -44,6 +48,7 @@ clean: rm -f comment-issue/comment-issue rm -f get-latest-semver-tag/get-latest-semver-tag rm -f get-next-semver/get-next-semver + rm -f tag-and-create-semver-release/tag-and-create-semver-release # Run tests for all actions and go-kit test: @@ -62,4 +67,8 @@ test: @cd get-latest-semver-tag && go test -v ./... @echo "Testing get-next-semver..." @cd get-next-semver && go test -v ./... + @echo "Testing semveractions..." + @cd internal/semveractions && go test -v ./... + @echo "Testing tag-and-create-semver-release..." + @cd tag-and-create-semver-release && go test -v ./... @echo "All tests completed successfully!" \ No newline at end of file diff --git a/README.md b/README.md index 10509e6..b11c01b 100644 --- a/README.md +++ b/README.md @@ -39,16 +39,7 @@ For detailed documentation on each action, click the action name in the table ab ## Versioning -This repository follows [Semantic Versioning (SemVer)](https://semver.org/) for all releases: - -- **MAJOR** version for incompatible API changes -- **MINOR** version for backwards-compatible functionality additions -- **PATCH** version for backwards-compatible bug fixes - -Each release creates two types of tags: - -1. **Full semantic version** (e.g., `v1.2.3`) -2. **Major version tag** (e.g., `v1`) - automatically updated to point to the latest release within that major version +This repository use [Semantic Versioning (SemVer)](https://semver.org/) for versioning. Each release will be tagged with its full version (e.g., `v1.2.3`). The latest release of each major version will also be tagged with `v{Major}` (e.g., `v1`) and that tag will move to the latest version as new versions are released. ## License diff --git a/go.work b/go.work index 3e674c9..5becf7f 100644 --- a/go.work +++ b/go.work @@ -2,10 +2,12 @@ go 1.24 use ( ./internal/go-kit + ./internal/semveractions ./create-issue ./find-issue ./close-issue ./comment-issue ./get-latest-semver-tag ./get-next-semver + ./tag-and-create-semver-release ) \ No newline at end of file diff --git a/internal/semveractions/go.mod b/internal/semveractions/go.mod new file mode 100644 index 0000000..91d3812 --- /dev/null +++ b/internal/semveractions/go.mod @@ -0,0 +1,3 @@ +module github.com/half-ogre-games/hog-actions/internal/semveractions + +go 1.24 \ No newline at end of file diff --git a/internal/semveractions/semveractions.go b/internal/semveractions/semveractions.go new file mode 100644 index 0000000..7fc363c --- /dev/null +++ b/internal/semveractions/semveractions.go @@ -0,0 +1,256 @@ +package semveractions + +import ( + "fmt" + "os/exec" + "sort" + "strings" + + "github.com/half-ogre/go-kit/actionskit" + "github.com/half-ogre/go-kit/versionkit" +) + +// SemverConfig holds common configuration for semver operations +type SemverConfig struct { + Prefix string +} + +// SemverResult represents the result of a semver operation +type SemverResult struct { + Tag string // Full tag with prefix + Version string // Version without prefix + Major int + Minor int + Patch int + Prerelease string + Build string + Found bool // Whether a tag was found (for get-latest operations) + Success bool + Error error +} + +// TagWithVersion pairs a git tag with its parsed semantic version +type TagWithVersion struct { + Tag string + Version versionkit.SemanticVersion +} + +// GetSemverPrefix reads prefix from GitHub Actions input with fallback to "v" +func GetSemverPrefix() string { + prefix := actionskit.GetInput("prefix") + if prefix == "" { + prefix = "v" + } + return prefix +} + +// ParseVersionWithPrefix parses a version string, handling prefix removal +func ParseVersionWithPrefix(versionStr, prefix string) (*versionkit.SemanticVersion, string, error) { + // Remove prefix if present + versionWithoutPrefix := strings.TrimPrefix(versionStr, prefix) + + // Parse version using versionkit + semver, err := versionkit.ParseSemanticVersion(versionWithoutPrefix) + if err != nil { + return nil, "", fmt.Errorf("error parsing version %s: %v", versionStr, err) + } + + return semver, versionWithoutPrefix, nil +} + +// FormatVersionWithPrefix combines version and prefix +func FormatVersionWithPrefix(version *versionkit.SemanticVersion, prefix string) string { + versionStr := fmt.Sprintf("%d.%d.%d", version.MajorVersion, version.MinorVersion, version.PatchVersion) + + // Add prerelease if present + if version.PreReleaseVersion != "" { + versionStr += "-" + version.PreReleaseVersion + } + + // Add build metadata if present + if version.BuildMetadata != "" { + versionStr += "+" + version.BuildMetadata + } + + return prefix + versionStr +} + +// CreateSemverResult creates standardized result from parsed version +func CreateSemverResult(tag, versionWithoutPrefix string, semver *versionkit.SemanticVersion, found bool) *SemverResult { + return &SemverResult{ + Tag: tag, + Version: versionWithoutPrefix, + Major: int(semver.MajorVersion), + Minor: int(semver.MinorVersion), + Patch: int(semver.PatchVersion), + Prerelease: semver.PreReleaseVersion, + Build: semver.BuildMetadata, + Found: found, + Success: true, + } +} + +// GetAllTags retrieves all git tags from current repository +func GetAllTags() ([]string, error) { + // Use git command to list all tags + cmd := exec.Command("git", "tag", "-l") + output, err := cmd.Output() + if err != nil { + return nil, fmt.Errorf("failed to run git tag command: %v", err) + } + + // Parse output into tag names + tagOutput := strings.TrimSpace(string(output)) + if tagOutput == "" { + // No tags found + return []string{}, nil + } + + tags := strings.Split(tagOutput, "\n") + + // Filter out empty strings + var validTags []string + for _, tag := range tags { + tag = strings.TrimSpace(tag) + if tag != "" { + validTags = append(validTags, tag) + } + } + + return validTags, nil +} + +// FilterTagsByPrefix filters tags that match the given prefix +func FilterTagsByPrefix(tags []string, prefix string) []string { + var filteredTags []string + for _, tag := range tags { + if strings.HasPrefix(tag, prefix) { + filteredTags = append(filteredTags, tag) + } + } + return filteredTags +} + +// FindLatestSemverTag finds the latest semantic version from a list of tags +func FindLatestSemverTag(tags []string, prefix string) (string, bool, error) { + var validVersions []TagWithVersion + + for _, tag := range tags { + // Check if tag starts with prefix + if !strings.HasPrefix(tag, prefix) { + continue + } + + // Extract version without prefix + versionStr := strings.TrimPrefix(tag, prefix) + + // Try to parse as semantic version + semver, err := versionkit.ParseSemanticVersion(versionStr) + if err != nil { + continue // Skip invalid versions + } + + validVersions = append(validVersions, TagWithVersion{ + Tag: tag, + Version: *semver, + }) + } + + if len(validVersions) == 0 { + return "", false, nil + } + + // Sort by semantic version using versionkit's Compare method + sort.Slice(validVersions, func(i, j int) bool { + return validVersions[i].Version.Compare(validVersions[j].Version) < 0 + }) + + // Return the latest (last in sorted order) + return validVersions[len(validVersions)-1].Tag, true, nil +} + +// SetSemverOutputs sets all standard semver outputs for GitHub Actions +func SetSemverOutputs(result *SemverResult) error { + outputs := map[string]string{ + "tag": result.Tag, + "version": result.Version, + "major": fmt.Sprintf("%d", result.Major), + "minor": fmt.Sprintf("%d", result.Minor), + "patch": fmt.Sprintf("%d", result.Patch), + "prerelease": result.Prerelease, + "build": result.Build, + "found": fmt.Sprintf("%t", result.Found), + } + + for name, value := range outputs { + if err := actionskit.SetOutput(name, value); err != nil { + return fmt.Errorf("failed to set %s output: %v", name, err) + } + } + + return nil +} + +// SetVersionOutputs sets version component outputs for get-next-semver +func SetVersionOutputs(result *SemverResult, incrementType string) error { + outputs := map[string]string{ + "version": result.Tag, + "version-core": result.Version, + "major": fmt.Sprintf("%d", result.Major), + "minor": fmt.Sprintf("%d", result.Minor), + "patch": fmt.Sprintf("%d", result.Patch), + "increment-type": incrementType, + } + + for name, value := range outputs { + if err := actionskit.SetOutput(name, value); err != nil { + return fmt.Errorf("failed to set %s output: %v", name, err) + } + } + + return nil +} + +// IncrementVersion calculates next version based on increment type +func IncrementVersion(current *versionkit.SemanticVersion, incrementMajor, incrementMinor bool) (*versionkit.SemanticVersion, string, error) { + // Validate increment flags + if incrementMajor && incrementMinor { + return nil, "", fmt.Errorf("cannot increment both major and minor versions simultaneously") + } + + // Create a copy of the current version to avoid modifying the original + newVersion := versionkit.SemanticVersion{ + MajorVersion: current.MajorVersion, + MinorVersion: current.MinorVersion, + PatchVersion: current.PatchVersion, + PreReleaseVersion: "", // Always remove pre-release and build metadata + BuildMetadata: "", + } + + var incrementType string + + if incrementMajor { + newVersion.MajorVersion++ + newVersion.MinorVersion = 0 + newVersion.PatchVersion = 0 + incrementType = "major" + } else if incrementMinor { + newVersion.MinorVersion++ + newVersion.PatchVersion = 0 + incrementType = "minor" + } else { + // Default to patch increment + newVersion.PatchVersion++ + incrementType = "patch" + } + + return &newVersion, incrementType, nil +} + +// ValidateIncrementFlags ensures only one increment type is specified +func ValidateIncrementFlags(incrementMajor, incrementMinor bool) error { + if incrementMajor && incrementMinor { + return fmt.Errorf("cannot increment both major and minor versions simultaneously") + } + return nil +} \ No newline at end of file diff --git a/internal/semveractions/semveractions_test.go b/internal/semveractions/semveractions_test.go new file mode 100644 index 0000000..0cd88af --- /dev/null +++ b/internal/semveractions/semveractions_test.go @@ -0,0 +1,377 @@ +package semveractions + +import ( + "testing" + + "github.com/half-ogre/go-kit/versionkit" +) + +func TestGetSemverPrefix(t *testing.T) { + tests := []struct { + name string + envValue string + expected string + }{ + { + name: "default prefix when not set", + envValue: "", + expected: "v", + }, + { + name: "custom prefix", + envValue: "release-", + expected: "release-", + }, + { + name: "empty prefix", + envValue: "", + expected: "v", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Note: This test would need to mock actionskit.GetInput to be fully testable + // For now, we'll just test the default behavior + if tt.envValue == "" { + result := "v" // Default behavior + if result != tt.expected { + t.Errorf("Expected %q, got %q", tt.expected, result) + } + } + }) + } +} + +func TestParseVersionWithPrefix(t *testing.T) { + tests := []struct { + name string + versionStr string + prefix string + expectError bool + expectedMajor uint + expectedMinor uint + expectedPatch uint + }{ + { + name: "version with v prefix", + versionStr: "v1.2.3", + prefix: "v", + expectError: false, + expectedMajor: 1, + expectedMinor: 2, + expectedPatch: 3, + }, + { + name: "version without prefix", + versionStr: "1.2.3", + prefix: "v", + expectError: false, + expectedMajor: 1, + expectedMinor: 2, + expectedPatch: 3, + }, + { + name: "version with custom prefix", + versionStr: "release-2.5.1", + prefix: "release-", + expectError: false, + expectedMajor: 2, + expectedMinor: 5, + expectedPatch: 1, + }, + { + name: "invalid version", + versionStr: "invalid", + prefix: "v", + expectError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + semver, versionWithoutPrefix, err := ParseVersionWithPrefix(tt.versionStr, tt.prefix) + + if tt.expectError { + if err == nil { + t.Error("Expected error but got none") + } + return + } + + if err != nil { + t.Errorf("Unexpected error: %v", err) + return + } + + if semver.MajorVersion != tt.expectedMajor { + t.Errorf("Major version = %d, want %d", semver.MajorVersion, tt.expectedMajor) + } + if semver.MinorVersion != tt.expectedMinor { + t.Errorf("Minor version = %d, want %d", semver.MinorVersion, tt.expectedMinor) + } + if semver.PatchVersion != tt.expectedPatch { + t.Errorf("Patch version = %d, want %d", semver.PatchVersion, tt.expectedPatch) + } + + expectedVersionWithoutPrefix := "1.2.3" + if tt.name == "version with custom prefix" { + expectedVersionWithoutPrefix = "2.5.1" + } + if versionWithoutPrefix != expectedVersionWithoutPrefix { + t.Errorf("Version without prefix = %q, want %q", versionWithoutPrefix, expectedVersionWithoutPrefix) + } + }) + } +} + +func TestFormatVersionWithPrefix(t *testing.T) { + tests := []struct { + name string + version *versionkit.SemanticVersion + prefix string + expected string + }{ + { + name: "basic version with v prefix", + version: &versionkit.SemanticVersion{ + MajorVersion: 1, + MinorVersion: 2, + PatchVersion: 3, + }, + prefix: "v", + expected: "v1.2.3", + }, + { + name: "version with prerelease", + version: &versionkit.SemanticVersion{ + MajorVersion: 1, + MinorVersion: 2, + PatchVersion: 3, + PreReleaseVersion: "alpha.1", + }, + prefix: "v", + expected: "v1.2.3-alpha.1", + }, + { + name: "version with build metadata", + version: &versionkit.SemanticVersion{ + MajorVersion: 1, + MinorVersion: 2, + PatchVersion: 3, + BuildMetadata: "build.456", + }, + prefix: "v", + expected: "v1.2.3+build.456", + }, + { + name: "version with prerelease and build", + version: &versionkit.SemanticVersion{ + MajorVersion: 2, + MinorVersion: 0, + PatchVersion: 0, + PreReleaseVersion: "beta.2", + BuildMetadata: "build.789", + }, + prefix: "release-", + expected: "release-2.0.0-beta.2+build.789", + }, + { + name: "no prefix", + version: &versionkit.SemanticVersion{ + MajorVersion: 3, + MinorVersion: 1, + PatchVersion: 4, + }, + prefix: "", + expected: "3.1.4", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := FormatVersionWithPrefix(tt.version, tt.prefix) + if result != tt.expected { + t.Errorf("FormatVersionWithPrefix() = %q, want %q", result, tt.expected) + } + }) + } +} + +func TestIncrementVersion(t *testing.T) { + tests := []struct { + name string + current *versionkit.SemanticVersion + incrementMajor bool + incrementMinor bool + expectedMajor uint + expectedMinor uint + expectedPatch uint + expectedType string + expectError bool + }{ + { + name: "patch increment", + current: &versionkit.SemanticVersion{ + MajorVersion: 1, + MinorVersion: 2, + PatchVersion: 3, + }, + incrementMajor: false, + incrementMinor: false, + expectedMajor: 1, + expectedMinor: 2, + expectedPatch: 4, + expectedType: "patch", + expectError: false, + }, + { + name: "minor increment", + current: &versionkit.SemanticVersion{ + MajorVersion: 1, + MinorVersion: 2, + PatchVersion: 3, + }, + incrementMajor: false, + incrementMinor: true, + expectedMajor: 1, + expectedMinor: 3, + expectedPatch: 0, + expectedType: "minor", + expectError: false, + }, + { + name: "major increment", + current: &versionkit.SemanticVersion{ + MajorVersion: 1, + MinorVersion: 2, + PatchVersion: 3, + }, + incrementMajor: true, + incrementMinor: false, + expectedMajor: 2, + expectedMinor: 0, + expectedPatch: 0, + expectedType: "major", + expectError: false, + }, + { + name: "removes prerelease and build metadata", + current: &versionkit.SemanticVersion{ + MajorVersion: 1, + MinorVersion: 2, + PatchVersion: 3, + PreReleaseVersion: "alpha.1", + BuildMetadata: "build.456", + }, + incrementMajor: false, + incrementMinor: false, + expectedMajor: 1, + expectedMinor: 2, + expectedPatch: 4, + expectedType: "patch", + expectError: false, + }, + { + name: "both major and minor - should error", + current: &versionkit.SemanticVersion{ + MajorVersion: 1, + MinorVersion: 2, + PatchVersion: 3, + }, + incrementMajor: true, + incrementMinor: true, + expectError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + newVersion, incrementType, err := IncrementVersion(tt.current, tt.incrementMajor, tt.incrementMinor) + + if tt.expectError { + if err == nil { + t.Error("Expected error but got none") + } + return + } + + if err != nil { + t.Errorf("Unexpected error: %v", err) + return + } + + if newVersion.MajorVersion != tt.expectedMajor { + t.Errorf("Major version = %d, want %d", newVersion.MajorVersion, tt.expectedMajor) + } + if newVersion.MinorVersion != tt.expectedMinor { + t.Errorf("Minor version = %d, want %d", newVersion.MinorVersion, tt.expectedMinor) + } + if newVersion.PatchVersion != tt.expectedPatch { + t.Errorf("Patch version = %d, want %d", newVersion.PatchVersion, tt.expectedPatch) + } + if incrementType != tt.expectedType { + t.Errorf("Increment type = %q, want %q", incrementType, tt.expectedType) + } + + // Verify prerelease and build metadata are removed + if newVersion.PreReleaseVersion != "" { + t.Errorf("Expected prerelease to be empty, got %q", newVersion.PreReleaseVersion) + } + if newVersion.BuildMetadata != "" { + t.Errorf("Expected build metadata to be empty, got %q", newVersion.BuildMetadata) + } + }) + } +} + +func TestFilterTagsByPrefix(t *testing.T) { + tests := []struct { + name string + tags []string + prefix string + expected []string + }{ + { + name: "filter v prefix", + tags: []string{"v1.0.0", "v1.1.0", "release-1.0.0", "v2.0.0"}, + prefix: "v", + expected: []string{"v1.0.0", "v1.1.0", "v2.0.0"}, + }, + { + name: "filter custom prefix", + tags: []string{"v1.0.0", "release-1.0.0", "release-1.1.0", "v2.0.0"}, + prefix: "release-", + expected: []string{"release-1.0.0", "release-1.1.0"}, + }, + { + name: "no matching tags", + tags: []string{"v1.0.0", "v1.1.0"}, + prefix: "release-", + expected: []string{}, + }, + { + name: "empty tags", + tags: []string{}, + prefix: "v", + expected: []string{}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := FilterTagsByPrefix(tt.tags, tt.prefix) + + if len(result) != len(tt.expected) { + t.Errorf("Expected %d tags, got %d", len(tt.expected), len(result)) + return + } + + for i, expected := range tt.expected { + if result[i] != expected { + t.Errorf("Tag[%d] = %q, want %q", i, result[i], expected) + } + } + }) + } +} \ No newline at end of file diff --git a/tag-and-create-semver-release/acceptance_test.go b/tag-and-create-semver-release/acceptance_test.go new file mode 100644 index 0000000..b72acee --- /dev/null +++ b/tag-and-create-semver-release/acceptance_test.go @@ -0,0 +1,339 @@ +package main + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +// Acceptance tests that build and run the actual binary +func TestAcceptanceTagAndCreateSemverRelease(t *testing.T) { + // Create temporary directory for building + tempBuildDir, err := os.MkdirTemp("", "build-test-*") + if err != nil { + t.Fatalf("Failed to create temp build dir: %v", err) + } + defer os.RemoveAll(tempBuildDir) + + // Build the binary in temp directory + binaryPath := filepath.Join(tempBuildDir, "tag-and-create-semver-release") + + buildCmd := exec.Command("go", "build", "-o", binaryPath, "main.go") + if err := buildCmd.Run(); err != nil { + t.Fatalf("Failed to build binary: %v", err) + } + + tests := []struct { + name string + setupEnv func() + cleanupEnv func() + setupGit func(t *testing.T, tempDir string) + expectedOutput string + expectedError bool + expectStderr bool + }{ + { + name: "missing github token", + setupEnv: func() { + os.Setenv("INPUT_PREFIX", "v") + os.Setenv("INPUT_DEFAULT_VERSION", "v0.1.0") + // Don't set github token + }, + cleanupEnv: func() { + os.Unsetenv("INPUT_PREFIX") + os.Unsetenv("INPUT_DEFAULT_VERSION") + }, + setupGit: func(t *testing.T, tempDir string) { + // Initialize minimal git repo + cmd := exec.Command("git", "init") + cmd.Dir = tempDir + if err := cmd.Run(); err != nil { + t.Fatalf("Failed to initialize git: %v", err) + } + + // Configure git user + cmd = exec.Command("git", "config", "user.email", "test@example.com") + cmd.Dir = tempDir + cmd.Run() + cmd = exec.Command("git", "config", "user.name", "Test User") + cmd.Dir = tempDir + cmd.Run() + }, + expectedError: true, + expectStderr: true, + }, + { + name: "both major and minor increment", + setupEnv: func() { + os.Setenv("INPUT_INCREMENT_MAJOR", "true") + os.Setenv("INPUT_INCREMENT_MINOR", "true") + os.Setenv("INPUT_PREFIX", "v") + os.Setenv("INPUT_DEFAULT_VERSION", "v0.1.0") + os.Setenv("INPUT_GITHUB_TOKEN", "fake-token") + }, + cleanupEnv: func() { + os.Unsetenv("INPUT_INCREMENT_MAJOR") + os.Unsetenv("INPUT_INCREMENT_MINOR") + os.Unsetenv("INPUT_PREFIX") + os.Unsetenv("INPUT_DEFAULT_VERSION") + os.Unsetenv("INPUT_GITHUB_TOKEN") + }, + setupGit: func(t *testing.T, tempDir string) { + // Initialize minimal git repo + cmd := exec.Command("git", "init") + cmd.Dir = tempDir + if err := cmd.Run(); err != nil { + t.Fatalf("Failed to initialize git: %v", err) + } + + // Configure git user + cmd = exec.Command("git", "config", "user.email", "test@example.com") + cmd.Dir = tempDir + cmd.Run() + cmd = exec.Command("git", "config", "user.name", "Test User") + cmd.Dir = tempDir + cmd.Run() + }, + expectedError: true, + expectStderr: true, + }, + { + name: "no git repository", + setupEnv: func() { + os.Setenv("INPUT_PREFIX", "v") + os.Setenv("INPUT_DEFAULT_VERSION", "v0.1.0") + os.Setenv("INPUT_GITHUB_TOKEN", "fake-token") + }, + cleanupEnv: func() { + os.Unsetenv("INPUT_PREFIX") + os.Unsetenv("INPUT_DEFAULT_VERSION") + os.Unsetenv("INPUT_GITHUB_TOKEN") + }, + setupGit: func(t *testing.T, tempDir string) { + // Don't initialize git - should cause error when trying to get tags + }, + expectedError: true, + expectStderr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Create temporary directory for git repo + tempDir, err := os.MkdirTemp("", "git-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(tempDir) + + // Set up environment + tt.setupEnv() + defer tt.cleanupEnv() + + // Set up git repository + tt.setupGit(t, tempDir) + + // Run the binary in the temp directory + cmd := exec.Command(binaryPath) + cmd.Dir = tempDir + output, err := cmd.CombinedOutput() + + if tt.expectedError { + if err == nil { + t.Error("Expected error but command succeeded") + } + if tt.expectStderr && err == nil { + t.Error("Expected stderr output but got none") + } + return + } + + if err != nil { + t.Errorf("Unexpected error: %v\nOutput: %s", err, output) + return + } + + outputStr := string(output) + if !contains(outputStr, tt.expectedOutput) { + t.Errorf("Expected output to contain %q, got: %s", tt.expectedOutput, outputStr) + } + }) + } +} + +// Test that the action can successfully process version calculation without actually creating tags/releases +func TestAcceptanceTagAndCreateSemverReleaseVersionCalculation(t *testing.T) { + // Create temporary directory for building + tempBuildDir, err := os.MkdirTemp("", "build-test-*") + if err != nil { + t.Fatalf("Failed to create temp build dir: %v", err) + } + defer os.RemoveAll(tempBuildDir) + + // Build the binary in temp directory + binaryPath := filepath.Join(tempBuildDir, "tag-and-create-semver-release") + + buildCmd := exec.Command("go", "build", "-o", binaryPath, "main.go") + if err := buildCmd.Run(); err != nil { + t.Fatalf("Failed to build binary: %v", err) + } + + tests := []struct { + name string + setupEnv func() + cleanupEnv func() + setupGit func(t *testing.T, tempDir string) + expectedOutput string + stopBeforeGit bool // Stop execution before git operations to test version calculation + }{ + { + name: "version calculation with no existing tags", + setupEnv: func() { + os.Setenv("INPUT_PREFIX", "v") + os.Setenv("INPUT_DEFAULT_VERSION", "v0.1.0") + os.Setenv("INPUT_GITHUB_TOKEN", "fake-token") + // We'll let this fail at git operations, but it should calculate version first + }, + cleanupEnv: func() { + os.Unsetenv("INPUT_PREFIX") + os.Unsetenv("INPUT_DEFAULT_VERSION") + os.Unsetenv("INPUT_GITHUB_TOKEN") + }, + setupGit: func(t *testing.T, tempDir string) { + // Initialize git repo with no tags + cmd := exec.Command("git", "init") + cmd.Dir = tempDir + if err := cmd.Run(); err != nil { + t.Fatalf("Failed to initialize git: %v", err) + } + + // Configure git user + cmd = exec.Command("git", "config", "user.email", "test@example.com") + cmd.Dir = tempDir + cmd.Run() + cmd = exec.Command("git", "config", "user.name", "Test User") + cmd.Dir = tempDir + cmd.Run() + + // Create an initial commit (required for tags) + readmeFile := filepath.Join(tempDir, "README.md") + if err := os.WriteFile(readmeFile, []byte("# Test Repo"), 0644); err != nil { + t.Fatalf("Failed to create README.md: %v", err) + } + + cmd = exec.Command("git", "add", "README.md") + cmd.Dir = tempDir + if err := cmd.Run(); err != nil { + t.Fatalf("Failed to add README.md: %v", err) + } + + cmd = exec.Command("git", "commit", "-m", "initial commit") + cmd.Dir = tempDir + if err := cmd.Run(); err != nil { + t.Fatalf("Failed to create initial commit: %v", err) + } + }, + expectedOutput: "Next version: v0.1.1 (patch increment)", + }, + { + name: "version calculation with existing tags", + setupEnv: func() { + os.Setenv("INPUT_PREFIX", "v") + os.Setenv("INPUT_DEFAULT_VERSION", "v0.1.0") + os.Setenv("INPUT_INCREMENT_MINOR", "true") + os.Setenv("INPUT_GITHUB_TOKEN", "fake-token") + }, + cleanupEnv: func() { + os.Unsetenv("INPUT_PREFIX") + os.Unsetenv("INPUT_DEFAULT_VERSION") + os.Unsetenv("INPUT_INCREMENT_MINOR") + os.Unsetenv("INPUT_GITHUB_TOKEN") + }, + setupGit: func(t *testing.T, tempDir string) { + // Initialize git repo with existing tags + cmd := exec.Command("git", "init") + cmd.Dir = tempDir + if err := cmd.Run(); err != nil { + t.Fatalf("Failed to initialize git: %v", err) + } + + // Configure git user + cmd = exec.Command("git", "config", "user.email", "test@example.com") + cmd.Dir = tempDir + cmd.Run() + cmd = exec.Command("git", "config", "user.name", "Test User") + cmd.Dir = tempDir + cmd.Run() + + // Create an initial commit + readmeFile := filepath.Join(tempDir, "README.md") + if err := os.WriteFile(readmeFile, []byte("# Test Repo"), 0644); err != nil { + t.Fatalf("Failed to create README.md: %v", err) + } + + cmd = exec.Command("git", "add", "README.md") + cmd.Dir = tempDir + if err := cmd.Run(); err != nil { + t.Fatalf("Failed to add README.md: %v", err) + } + + cmd = exec.Command("git", "commit", "-m", "initial commit") + cmd.Dir = tempDir + if err := cmd.Run(); err != nil { + t.Fatalf("Failed to create initial commit: %v", err) + } + + // Create existing tags + tags := []string{"v1.0.0", "v1.1.0", "v1.0.1"} + for _, tag := range tags { + cmd = exec.Command("git", "tag", tag) + cmd.Dir = tempDir + if err := cmd.Run(); err != nil { + t.Fatalf("Failed to create tag %s: %v", tag, err) + } + } + }, + expectedOutput: "Next version: v1.2.0 (minor increment)", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Create temporary directory for git repo + tempDir, err := os.MkdirTemp("", "git-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(tempDir) + + // Set up environment + tt.setupEnv() + defer tt.cleanupEnv() + + // Set up git repository + tt.setupGit(t, tempDir) + + // Run the binary in the temp directory + cmd := exec.Command(binaryPath) + cmd.Dir = tempDir + output, err := cmd.CombinedOutput() + + // We expect this to fail at git operations (tagging/release creation) + // but we want to verify it calculates the version correctly first + outputStr := string(output) + + if !contains(outputStr, tt.expectedOutput) { + t.Errorf("Expected output to contain %q, got: %s", tt.expectedOutput, outputStr) + } + + // The command should fail at git operations, but that's expected + // We're testing the version calculation logic + }) + } +} + +func contains(s, substr string) bool { + return strings.Contains(s, substr) +} \ No newline at end of file diff --git a/tag-and-create-semver-release/action.yml b/tag-and-create-semver-release/action.yml new file mode 100644 index 0000000..2d5e134 --- /dev/null +++ b/tag-and-create-semver-release/action.yml @@ -0,0 +1,75 @@ +name: 'Tag and Create Semver Release' +description: 'Tag a commit with a semver version and create a GitHub release with auto-generated notes' +author: 'Half-Ogre Games' + +inputs: + branch: + description: 'Branch to tag (defaults to repository default branch)' + required: false + default: '' + commit: + description: 'Commit SHA to tag (defaults to HEAD of branch)' + required: false + default: 'HEAD' + increment-major: + description: 'Increment major version' + required: false + default: 'false' + type: boolean + increment-minor: + description: 'Increment minor version' + required: false + default: 'false' + type: boolean + prefix: + description: 'Version prefix (e.g., "v" for "v1.2.3")' + required: false + default: 'v' + default-version: + description: 'Default version to use if no tags are found' + required: false + default: 'v0.1.0' + github-token: + description: 'GitHub token for creating releases' + required: true + +outputs: + previous-version: + description: 'The previous version tag (or none if this is the first release)' + value: ${{ steps.tag-and-release.outputs.previous-version }} + new-version: + description: 'The new version tag that was created' + value: ${{ steps.tag-and-release.outputs.new-version }} + increment-type: + description: 'The type of increment performed (major, minor, patch)' + value: ${{ steps.tag-and-release.outputs.increment-type }} + release-url: + description: 'URL of the created GitHub release' + value: ${{ steps.tag-and-release.outputs.release-url }} + target-commit: + description: 'The commit SHA that was tagged' + value: ${{ steps.tag-and-release.outputs.target-commit }} + +runs: + using: 'composite' + steps: + - name: Build and run tag-and-create-semver-release + id: tag-and-release + shell: bash + env: + INPUT_BRANCH: ${{ inputs.branch }} + INPUT_COMMIT: ${{ inputs.commit }} + INPUT_INCREMENT_MAJOR: ${{ inputs.increment-major }} + INPUT_INCREMENT_MINOR: ${{ inputs.increment-minor }} + INPUT_PREFIX: ${{ inputs.prefix }} + INPUT_DEFAULT_VERSION: ${{ inputs.default-version }} + INPUT_GITHUB_TOKEN: ${{ inputs.github-token }} + GITHUB_TOKEN: ${{ inputs.github-token }} + run: | + cd ${{ github.action_path }} + go build -o tag-and-create-semver-release main.go + ./tag-and-create-semver-release + +branding: + icon: 'tag' + color: 'purple' \ No newline at end of file diff --git a/tag-and-create-semver-release/go.mod b/tag-and-create-semver-release/go.mod new file mode 100644 index 0000000..54b88f6 --- /dev/null +++ b/tag-and-create-semver-release/go.mod @@ -0,0 +1,3 @@ +module github.com/half-ogre-games/hog-actions/tag-and-create-semver-release + +go 1.24 \ No newline at end of file diff --git a/tag-and-create-semver-release/main.go b/tag-and-create-semver-release/main.go new file mode 100644 index 0000000..6c580bf --- /dev/null +++ b/tag-and-create-semver-release/main.go @@ -0,0 +1,339 @@ +package main + +import ( + "fmt" + "os" + "os/exec" + "strings" + + "github.com/half-ogre/go-kit/actionskit" + "github.com/half-ogre-games/hog-actions/internal/semveractions" +) + +// Config holds the configuration for the tag-and-create-semver-release action +type Config struct { + Branch string + Commit string + IncrementMajor bool + IncrementMinor bool + Prefix string + DefaultVersion string + GitHubToken string + DefaultBranch string // Will be populated from GitHub context +} + +// Result holds the result of the tag-and-create-semver-release action +type Result struct { + PreviousVersion string + NewVersion string + IncrementType string + ReleaseURL string + TargetCommit string + Success bool + Error error +} + +func main() { + config, err := getConfigFromEnvironment() + if err != nil { + actionskit.Error(err.Error()) + os.Exit(1) + } + + result := run(config) + if result.Error != nil { + actionskit.Error(result.Error.Error()) + os.Exit(1) + } + + // Output results + actionskit.Info(fmt.Sprintf("✅ Created tag %s for commit %s", result.NewVersion, result.TargetCommit)) + if result.ReleaseURL != "" { + actionskit.Info(fmt.Sprintf("✅ Created release: %s", result.ReleaseURL)) + } + + // Set outputs for GitHub Actions + if err := setOutputs(result); err != nil { + actionskit.Error(fmt.Sprintf("Failed to set outputs: %v", err)) + os.Exit(1) + } +} + +// getConfigFromEnvironment reads configuration from environment variables and GitHub Actions inputs +func getConfigFromEnvironment() (*Config, error) { + branch := actionskit.GetInput("branch") + if branch == "" { + // Get default branch from GitHub context + branch = os.Getenv("GITHUB_REF_NAME") + if branch == "" { + branch = "main" // fallback + } + } + + commit := actionskit.GetInput("commit") + if commit == "" { + commit = "HEAD" + } + + incrementMajorStr := actionskit.GetInput("increment-major") + incrementMajor := incrementMajorStr == "true" + + incrementMinorStr := actionskit.GetInput("increment-minor") + incrementMinor := incrementMinorStr == "true" + + // Validate increment flags + if err := semveractions.ValidateIncrementFlags(incrementMajor, incrementMinor); err != nil { + return nil, err + } + + prefix := actionskit.GetInput("prefix") + if prefix == "" { + prefix = "v" + } + + defaultVersion := actionskit.GetInput("default-version") + if defaultVersion == "" { + defaultVersion = "v0.1.0" + } + + githubToken, err := actionskit.GetInputRequired("github-token") + if err != nil { + return nil, err + } + + return &Config{ + Branch: branch, + Commit: commit, + IncrementMajor: incrementMajor, + IncrementMinor: incrementMinor, + Prefix: prefix, + DefaultVersion: defaultVersion, + GitHubToken: githubToken, + DefaultBranch: branch, + }, nil +} + +// run executes the tag-and-create-semver-release action with the given configuration +func run(config *Config) *Result { + result := &Result{Success: false} + + // Step 1: Get target commit SHA + targetCommit, err := getTargetCommitSHA(config.Commit) + if err != nil { + result.Error = fmt.Errorf("error getting target commit: %v", err) + return result + } + result.TargetCommit = targetCommit + + // Step 2: Get latest version tag + tags, err := semveractions.GetAllTags() + if err != nil { + result.Error = fmt.Errorf("error getting tags: %v", err) + return result + } + + latestTag, found, err := semveractions.FindLatestSemverTag(tags, config.Prefix) + if err != nil { + result.Error = fmt.Errorf("error finding latest tag: %v", err) + return result + } + + var currentVersion string + if !found { + currentVersion = config.DefaultVersion + result.PreviousVersion = "none" + actionskit.Info(fmt.Sprintf("No tags found, using default: %s", currentVersion)) + } else { + currentVersion = latestTag + result.PreviousVersion = latestTag + actionskit.Info(fmt.Sprintf("Found latest tag: %s", latestTag)) + } + + // Step 3: Calculate new version + semver, _, err := semveractions.ParseVersionWithPrefix(currentVersion, config.Prefix) + if err != nil { + result.Error = fmt.Errorf("error parsing current version: %v", err) + return result + } + + newSemver, incrementType, err := semveractions.IncrementVersion(semver, config.IncrementMajor, config.IncrementMinor) + if err != nil { + result.Error = fmt.Errorf("error incrementing version: %v", err) + return result + } + + newVersionTag := semveractions.FormatVersionWithPrefix(newSemver, config.Prefix) + result.NewVersion = newVersionTag + result.IncrementType = incrementType + + actionskit.Info(fmt.Sprintf("Next version: %s (%s increment)", newVersionTag, incrementType)) + + // Step 4: Check if tag already exists + if tagExists(newVersionTag) { + result.Error = fmt.Errorf("tag %s already exists", newVersionTag) + return result + } + + // Step 5: Create and push tags + if err := createAndPushTags(newVersionTag, targetCommit, int(newSemver.MajorVersion)); err != nil { + result.Error = fmt.Errorf("error creating tags: %v", err) + return result + } + + // Step 6: Create GitHub release + releaseURL, err := createGitHubRelease(config, result) + if err != nil { + result.Error = fmt.Errorf("error creating release: %v", err) + return result + } + + result.ReleaseURL = releaseURL + result.Success = true + return result +} + +// getTargetCommitSHA resolves the target commit SHA +func getTargetCommitSHA(commit string) (string, error) { + if commit == "HEAD" || commit == "" { + cmd := exec.Command("git", "rev-parse", "HEAD") + output, err := cmd.Output() + if err != nil { + return "", fmt.Errorf("failed to get HEAD commit: %v", err) + } + return strings.TrimSpace(string(output)), nil + } + + // Verify the commit exists + cmd := exec.Command("git", "cat-file", "-e", commit+"^{commit}") + if err := cmd.Run(); err != nil { + return "", fmt.Errorf("commit %s does not exist", commit) + } + + return commit, nil +} + +// tagExists checks if a git tag already exists +func tagExists(tag string) bool { + if tag == "" { + return false + } + cmd := exec.Command("git", "tag", "-l", tag) + output, err := cmd.Output() + if err != nil { + return false + } + return strings.TrimSpace(string(output)) == tag +} + +// createAndPushTags creates and pushes the semver tag and major version tag +func createAndPushTags(newVersionTag, targetCommit string, majorVersion int) error { + // Configure git user + if err := exec.Command("git", "config", "user.name", "github-actions[bot]").Run(); err != nil { + return fmt.Errorf("failed to configure git user name: %v", err) + } + if err := exec.Command("git", "config", "user.email", "github-actions[bot]@users.noreply.github.com").Run(); err != nil { + return fmt.Errorf("failed to configure git user email: %v", err) + } + + // Create and push the semver tag + cmd := exec.Command("git", "tag", "-a", newVersionTag, targetCommit, "-m", fmt.Sprintf("Release %s", newVersionTag)) + if err := cmd.Run(); err != nil { + return fmt.Errorf("failed to create tag %s: %v", newVersionTag, err) + } + + cmd = exec.Command("git", "push", "origin", newVersionTag) + if err := cmd.Run(); err != nil { + return fmt.Errorf("failed to push tag %s: %v", newVersionTag, err) + } + + actionskit.Info(fmt.Sprintf("✅ Created and pushed tag %s for commit %s", newVersionTag, targetCommit)) + + // Create major version tag if major version is 1 or more + if majorVersion >= 1 { + majorTag := fmt.Sprintf("v%d", majorVersion) + actionskit.Info(fmt.Sprintf("Creating major version tag: %s", majorTag)) + + // Delete existing major tag if it exists (force update) + cmd = exec.Command("git", "tag", "-d", majorTag) + cmd.Run() // Ignore error if tag doesn't exist locally + + cmd = exec.Command("git", "push", "origin", ":refs/tags/"+majorTag) + cmd.Run() // Ignore error if tag doesn't exist remotely + + // Create and push new major tag + cmd = exec.Command("git", "tag", "-a", majorTag, targetCommit, "-m", fmt.Sprintf("Major version %s (latest: %s)", majorTag, newVersionTag)) + if err := cmd.Run(); err != nil { + return fmt.Errorf("failed to create major tag %s: %v", majorTag, err) + } + + cmd = exec.Command("git", "push", "origin", majorTag) + if err := cmd.Run(); err != nil { + return fmt.Errorf("failed to push major tag %s: %v", majorTag, err) + } + + actionskit.Info(fmt.Sprintf("✅ Created and pushed major version tag %s", majorTag)) + } else { + actionskit.Info("Major version is 0, skipping major version tag creation") + } + + return nil +} + +// createGitHubRelease creates a GitHub release with auto-generated notes +func createGitHubRelease(config *Config, result *Result) (string, error) { + // Create release notes file with metadata + releaseNotes := fmt.Sprintf(`## What's Changed + +This release was created from commit %s on branch %s. + +### Version Details +- **Previous version**: %s +- **New version**: %s +- **Version type**: %s release +`, result.TargetCommit, config.Branch, result.PreviousVersion, result.NewVersion, result.IncrementType) + + // Write release notes to temporary file + releaseNotesFile := "release_notes.md" + if err := os.WriteFile(releaseNotesFile, []byte(releaseNotes), 0644); err != nil { + return "", fmt.Errorf("failed to write release notes file: %v", err) + } + defer os.Remove(releaseNotesFile) + + // Create release with auto-generated notes + cmd := exec.Command("gh", "release", "create", result.NewVersion, + "--title", result.NewVersion, + "--notes-file", releaseNotesFile, + "--generate-notes", + "--latest", + "--target", result.TargetCommit) + + output, err := cmd.CombinedOutput() + if err != nil { + return "", fmt.Errorf("failed to create release: %v\nOutput: %s", err, string(output)) + } + + // Extract release URL from output + releaseURL := strings.TrimSpace(string(output)) + actionskit.Info(fmt.Sprintf("✅ Created release %s with auto-generated notes", result.NewVersion)) + + return releaseURL, nil +} + +// setOutputs sets the GitHub Actions outputs +func setOutputs(result *Result) error { + outputs := map[string]string{ + "previous-version": result.PreviousVersion, + "new-version": result.NewVersion, + "increment-type": result.IncrementType, + "release-url": result.ReleaseURL, + "target-commit": result.TargetCommit, + } + + for name, value := range outputs { + if err := actionskit.SetOutput(name, value); err != nil { + return fmt.Errorf("failed to set %s output: %v", name, err) + } + } + + return nil +} \ No newline at end of file diff --git a/tag-and-create-semver-release/main_test.go b/tag-and-create-semver-release/main_test.go new file mode 100644 index 0000000..6ced80a --- /dev/null +++ b/tag-and-create-semver-release/main_test.go @@ -0,0 +1,288 @@ +package main + +import ( + "os" + "testing" +) + +func TestGetConfigFromEnvironment(t *testing.T) { + tests := []struct { + name string + setupEnv func() + cleanupEnv func() + expectError bool + errorMsg string + expected *Config + }{ + { + name: "valid configuration with defaults", + setupEnv: func() { + os.Setenv("INPUT_GITHUB_TOKEN", "test-token") + }, + cleanupEnv: func() { + os.Unsetenv("INPUT_GITHUB_TOKEN") + }, + expectError: false, + expected: &Config{ + Branch: "", // Will be set dynamically + Commit: "HEAD", + IncrementMajor: false, + IncrementMinor: false, + Prefix: "v", + DefaultVersion: "v0.1.0", + GitHubToken: "test-token", + DefaultBranch: "", // Will be set dynamically + }, + }, + { + name: "custom configuration", + setupEnv: func() { + os.Setenv("INPUT_BRANCH", "develop") + os.Setenv("INPUT_COMMIT", "abc123") + os.Setenv("INPUT_INCREMENT_MAJOR", "true") + os.Setenv("INPUT_PREFIX", "release-") + os.Setenv("INPUT_DEFAULT_VERSION", "release-1.0.0") + os.Setenv("INPUT_GITHUB_TOKEN", "custom-token") + }, + cleanupEnv: func() { + os.Unsetenv("INPUT_BRANCH") + os.Unsetenv("INPUT_COMMIT") + os.Unsetenv("INPUT_INCREMENT_MAJOR") + os.Unsetenv("INPUT_PREFIX") + os.Unsetenv("INPUT_DEFAULT_VERSION") + os.Unsetenv("INPUT_GITHUB_TOKEN") + }, + expectError: false, + expected: &Config{ + Branch: "develop", + Commit: "abc123", + IncrementMajor: true, + IncrementMinor: false, + Prefix: "release-", + DefaultVersion: "release-1.0.0", + GitHubToken: "custom-token", + DefaultBranch: "develop", + }, + }, + { + name: "minor increment", + setupEnv: func() { + os.Setenv("INPUT_INCREMENT_MINOR", "true") + os.Setenv("INPUT_GITHUB_TOKEN", "test-token") + }, + cleanupEnv: func() { + os.Unsetenv("INPUT_INCREMENT_MINOR") + os.Unsetenv("INPUT_GITHUB_TOKEN") + }, + expectError: false, + expected: &Config{ + Branch: "", // Will be set dynamically based on environment + Commit: "HEAD", + IncrementMajor: false, + IncrementMinor: true, + Prefix: "v", + DefaultVersion: "v0.1.0", + GitHubToken: "test-token", + DefaultBranch: "", // Will be set dynamically based on environment + }, + }, + { + name: "missing github token", + setupEnv: func() { + // Don't set github token + }, + cleanupEnv: func() {}, + expectError: true, + errorMsg: "input required and not supplied: github-token", + }, + { + name: "both major and minor increment - should error", + setupEnv: func() { + os.Setenv("INPUT_INCREMENT_MAJOR", "true") + os.Setenv("INPUT_INCREMENT_MINOR", "true") + os.Setenv("INPUT_GITHUB_TOKEN", "test-token") + }, + cleanupEnv: func() { + os.Unsetenv("INPUT_INCREMENT_MAJOR") + os.Unsetenv("INPUT_INCREMENT_MINOR") + os.Unsetenv("INPUT_GITHUB_TOKEN") + }, + expectError: true, + errorMsg: "cannot increment both major and minor versions simultaneously", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tt.setupEnv() + defer tt.cleanupEnv() + + config, err := getConfigFromEnvironment() + + if tt.expectError { + if err == nil { + t.Error("Expected error but got none") + } else if err.Error() != tt.errorMsg { + t.Errorf("Expected error message %q, got %q", tt.errorMsg, err.Error()) + } + return + } + + if err != nil { + t.Errorf("Unexpected error: %v", err) + return + } + + // For tests with dynamic branch names, validate against actual environment + expectedBranch := tt.expected.Branch + if expectedBranch == "" { + // Get expected branch from environment or default to "main" + expectedBranch = os.Getenv("GITHUB_REF_NAME") + if expectedBranch == "" { + expectedBranch = "main" + } + } + if config.Branch != expectedBranch { + t.Errorf("Branch = %q, want %q", config.Branch, expectedBranch) + } + if config.Commit != tt.expected.Commit { + t.Errorf("Commit = %q, want %q", config.Commit, tt.expected.Commit) + } + if config.IncrementMajor != tt.expected.IncrementMajor { + t.Errorf("IncrementMajor = %v, want %v", config.IncrementMajor, tt.expected.IncrementMajor) + } + if config.IncrementMinor != tt.expected.IncrementMinor { + t.Errorf("IncrementMinor = %v, want %v", config.IncrementMinor, tt.expected.IncrementMinor) + } + if config.Prefix != tt.expected.Prefix { + t.Errorf("Prefix = %q, want %q", config.Prefix, tt.expected.Prefix) + } + if config.DefaultVersion != tt.expected.DefaultVersion { + t.Errorf("DefaultVersion = %q, want %q", config.DefaultVersion, tt.expected.DefaultVersion) + } + if config.GitHubToken != tt.expected.GitHubToken { + t.Errorf("GitHubToken = %q, want %q", config.GitHubToken, tt.expected.GitHubToken) + } + }) + } +} + +func TestGetTargetCommitSHA(t *testing.T) { + tests := []struct { + name string + commit string + expectError bool + errorMsg string + }{ + { + name: "HEAD commit", + commit: "HEAD", + expectError: false, + }, + { + name: "empty commit defaults to HEAD", + commit: "", + expectError: false, + }, + { + name: "invalid commit SHA", + commit: "invalid-sha-that-does-not-exist", + expectError: true, + errorMsg: "commit invalid-sha-that-does-not-exist does not exist", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := getTargetCommitSHA(tt.commit) + + if tt.expectError { + if err == nil { + t.Error("Expected error but got none") + } else if err.Error() != tt.errorMsg { + t.Errorf("Expected error message %q, got %q", tt.errorMsg, err.Error()) + } + return + } + + if err != nil { + t.Errorf("Unexpected error: %v", err) + return + } + + // For HEAD or empty, result should be a valid SHA (40 characters) + if len(result) != 40 { + t.Errorf("Expected SHA to be 40 characters, got %d: %s", len(result), result) + } + }) + } +} + +func TestTagExists(t *testing.T) { + tests := []struct { + name string + tag string + expected bool + }{ + { + name: "non-existent tag", + tag: "v999.999.999", + expected: false, + }, + { + name: "empty tag", + tag: "", + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := tagExists(tt.tag) + if result != tt.expected { + t.Errorf("tagExists(%q) = %v, want %v", tt.tag, result, tt.expected) + } + }) + } +} + +func TestSetOutputs(t *testing.T) { + tests := []struct { + name string + result *Result + }{ + { + name: "complete result", + result: &Result{ + PreviousVersion: "v1.0.0", + NewVersion: "v1.1.0", + IncrementType: "minor", + ReleaseURL: "https://github.com/repo/releases/tag/v1.1.0", + TargetCommit: "abc123", + Success: true, + }, + }, + { + name: "first release", + result: &Result{ + PreviousVersion: "none", + NewVersion: "v0.1.0", + IncrementType: "patch", + ReleaseURL: "https://github.com/repo/releases/tag/v0.1.0", + TargetCommit: "def456", + Success: true, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // This test just ensures the function doesn't panic + // In a real environment, it would test GitHub Actions output setting + err := setOutputs(tt.result) + if err != nil { + t.Errorf("setOutputs() error = %v", err) + } + }) + } +} \ No newline at end of file