Compare commits

...

20 Commits

Author SHA1 Message Date
lukaszraczylo 49a46a74c1 Add signing of the builds. 2025-12-15 00:43:42 +00:00
lukaszraczylo 3a48a67c75 Improve calculation logic, add ability to strip prefixes. 2025-12-10 14:37:38 +00:00
lukaszraczylo 18b9b474e0 fix: remove unnecessary verified parameter from homebrew cask
The verified parameter is only needed when the URL domain differs
from the homepage domain. Since both point to github.com, Homebrew
audit flags this as unnecessary.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-08 02:06:43 +00:00
lukaszraczylo f5a118fd1a Use shared PR workflow. 2025-12-08 02:06:43 +00:00
lukaszraczylo a1c4133e94 Update go.mod and go.sum (#57) 2025-12-08 01:22:53 +00:00
lukaszraczylo 2a51752663 Trigger autoupdate. 2025-12-08 01:14:40 +00:00
lukaszraczylo 0bc848f6f4 fixup! fixup! Update binary confusion. 2025-12-07 16:29:21 +00:00
lukaszraczylo 8590963822 fixup! Update binary confusion. 2025-12-07 16:27:57 +00:00
lukaszraczylo f4285403f7 Update binary confusion. 2025-12-07 16:27:04 +00:00
lukaszraczylo b3d104f0a8 fixup! fixup! fixup! fixup! fixup! fixup! Move updater to REST api. 2025-12-07 16:23:19 +00:00
lukaszraczylo 7a70ed6614 fixup! fixup! fixup! fixup! fixup! Move updater to REST api. 2025-12-07 16:09:36 +00:00
lukaszraczylo f9a18995d0 fixup! fixup! fixup! fixup! Move updater to REST api. 2025-12-07 15:59:21 +00:00
lukaszraczylo 66756b6772 fixup! fixup! fixup! Move updater to REST api. 2025-12-07 15:42:15 +00:00
lukaszraczylo a0f1ab6930 fixup! fixup! Move updater to REST api. 2025-12-07 15:36:03 +00:00
lukaszraczylo b80929ff52 fixup! Move updater to REST api. 2025-12-07 15:31:03 +00:00
lukaszraczylo be189187ba Move updater to REST api. 2025-12-07 15:28:37 +00:00
lukaszraczylo 336d8cc163 Move to shared github actions and goreleaser 2025-12-07 15:11:16 +00:00
lukaszraczylo b8fdc4bfb5 Update go.mod and go.sum
Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-12-07 03:29:23 +00:00
lukaszraczylo 25f6bf5c83 Update go.mod and go.sum
Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-12-04 03:27:19 +00:00
lukaszraczylo f5b6bd5375 Update go.mod and go.sum
Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-12-03 03:24:42 +00:00
24 changed files with 1763 additions and 622 deletions
+11 -65
View File
@@ -5,70 +5,16 @@ on:
schedule:
- cron: "0 3 * * *"
env:
GO_VERSION: ">=1.21"
permissions:
contents: write
actions: write
pull-requests: write
jobs:
# This job is responsible for preparation of the build
# environment variables.
prepare:
name: Preparing build context
runs-on: ubuntu-latest
steps:
- name: Checkout repo
uses: actions/checkout@v4
- name: Install Go
uses: actions/setup-go@v5
id: cache
with:
go-version: ${{env.GO_VERSION}}
cache-dependency-path: "**/*.sum"
- name: Go get dependencies
if: steps.cache.outputs.cache-hit != 'true'
run: |
go get ./...
# This job is responsible for running tests and linting the codebase
test:
name: "Unit testing"
runs-on: ubuntu-latest
container: golang:1
needs: [prepare]
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0 # Ensure full history is checked out
token: ${{ secrets.GHCR_TOKEN }}
- name: Install Go
uses: actions/setup-go@v5
with:
go-version: ${{env.GO_VERSION}}
cache-dependency-path: "**/*.sum"
- name: Install dependencies
run: |
apt-get update
apt-get install ca-certificates make -y
update-ca-certificates
go mod tidy
go get -u -v ./...
go mod tidy -v
- name: Run unit tests
run: |
export GITHUB_TOKEN=${{ secrets.GHCR_TOKEN }}
CI_RUN=${CI} make test
git config --global --add safe.directory /__w/semver-generator/semver-generator
- name: Commit changes
uses: stefanzweifel/git-auto-commit-action@v5
with:
commit_message: "Update go.mod and go.sum"
commit_options: "--no-verify --signoff"
file_pattern: "go.mod go.sum"
autoupdate:
uses: lukaszraczylo/shared-actions/.github/workflows/go-autoupdate.yaml@main
with:
go-version: "1.24"
release-workflow: "release.yaml"
secrets:
pat-token: ${{ secrets.HOMEBREW_TAP_TOKEN }}
+16
View File
@@ -0,0 +1,16 @@
name: Pull Request
on:
pull_request:
branches:
- main
push:
branches:
- "**"
- "!main"
jobs:
pr-checks:
uses: lukaszraczylo/shared-actions/.github/workflows/go-pr.yaml@main
with:
go-version: "1.24"
+17 -213
View File
@@ -1,222 +1,26 @@
name: Test, scan, build, release
name: Test, build, release
on:
workflow_dispatch:
push:
paths-ignore:
- '**.md'
- '**/release.yaml'
- 'action.yml'
- '**.md'
- '**/release.yaml'
- 'action.yml'
branches:
- "master"
- "main"
- main
env:
ENABLE_CODE_LINT: false
ENABLE_CODE_SCANS: false
DEPLOY: false
GO_VERSION: 1.21
permissions:
id-token: write
contents: write
packages: write
jobs:
prepare:
name: Preparing build context
runs-on: ubuntu-latest
outputs:
SANITISED_REPOSITORY_NAME: ${{ steps.get_env.outputs.SANITISED_REPOSITORY_NAME }}
DOCKER_IMAGE: ${{ steps.get_env.outputs.DOCKER_IMAGE }}
GITHUB_COMMIT_NUMBER: ${{ steps.get_env.outputs.GITHUB_COMMIT_NUMBER }}
GITHUB_SHA: ${{ steps.get_env.outputs.GITHUB_SHA }}
GITHUB_RUN_ID: ${{ steps.get_env.outputs.GITHUB_RUN_ID }}
RELEASE_VERSION: ${{ steps.get_env.outputs.RELEASE_VERSION }}
steps:
- name: Checkout repo
uses: actions/checkout@v3
with:
fetch-depth: '0'
- name: Setting environment variables
id: get_env
run: |
DOWNLOAD_URL=$(curl -s https://api.github.com/repos/lukaszraczylo/semver-generator/releases/latest | grep -E ".*browser_download_url.*linux-" | grep -vE "(arm64|md5)" \
| cut -d '"' -f 4)
curl -s -L -o semver-gen "$DOWNLOAD_URL" && chmod +x semver-gen
TMP_SANITISED_REPOSITORY_NAME=$(echo ${{ github.event.repository.name }} | sed -e 's|\.|-|g')
TMP_GITHUB_COMMITS_COUNT=$(git rev-list --count HEAD)
TMP_GITHUB_COUNT_NUMBER=$(echo ${GITHUB_RUN_NUMBER})
TMP_RELEASE_VERSION=$(./semver-gen generate -l -c config-release.yaml | sed -e 's|SEMVER ||g')
echo ">> Release version: $TMP_RELEASE_VERSION <<"
# Setting outputs
echo "SANITISED_REPOSITORY_NAME=$TMP_SANITISED_REPOSITORY_NAME" > $GITHUB_OUTPUT
echo "DOCKER_IMAGE=ghcr.io/${{ github.repository_owner }}/$TMP_SANITISED_REPOSITORY_NAME" >> $GITHUB_OUTPUT
echo "GITHUB_COMMIT_NUMBER=$TMP_GITHUB_COMMITS_COUNT" >> $GITHUB_OUTPUT
echo "GITHUB_SHA=$(echo ${GITHUB_SHA::8})" >> $GITHUB_OUTPUT
echo "GITHUB_RUN_ID=$TMP_GITHUB_COUNT_NUMBER" >> $GITHUB_OUTPUT
echo "RELEASE_VERSION=$TMP_RELEASE_VERSION" >> $GITHUB_OUTPUT
test:
needs: [ prepare ]
name: Code checks pipeline
runs-on: ubuntu-latest
container: github/super-linter:v3.15.5
env:
CI: true
steps:
- name: Checkout repo
uses: actions/checkout@v3
- name: Install Go
uses: actions/setup-go@v3
with:
go-version: ${{ env.GO_VERSION }}
- name: Lint Code Base
if: env.ENABLE_CODE_LINT == true
env:
VALIDATE_ALL_CODEBASE: true
VALIDATE_DOCKERFILE: false # this leaves us with hadolint only
VALIDATE_GO: false # disable bulk validation of go files, run the linter manually
DEFAULT_BRANCH: main
GITHUB_TOKEN: ${{ secrets.GHCR_TOKEN }}
LOG_LEVEL: WARN
run: |
golangci-lint run --exclude-use-default ./...
/action/lib/linter.sh
- name: Run unit tests
env:
GITHUB_TOKEN: ${{ secrets.GHCR_TOKEN }}
run: |
make test CI_RUN=${CI}
- name: Upload codecov result
uses: codecov/codecov-action@v3
continue-on-error: true
with:
token: ${{ secrets.CODECOV_TOKEN }} # not required for public repos
files: coverage.out
fail_ci_if_error: false
code_scans:
needs: [ prepare ]
name: Code scans pipeline
runs-on: ubuntu-latest
steps:
- name: Checkout repo
uses: actions/checkout@v3
- name: Install Go
uses: actions/setup-go@v3
with:
go-version: ${{ env.GO_VERSION }}
- name: Configure git for private modules
run: |
make update
- name: WriteGoList
run: go list -json -m all > go.list
- name: Running nancy
if: env.ENABLE_CODE_SCANS == true
uses: sonatype-nexus-community/nancy-github-action@main
- name: Running gosec
if: env.ENABLE_CODE_SCANS == true
uses: securego/gosec@master
with:
args: ./...
build-binary:
needs: [ prepare, test, code_scans ]
name: Binary compilation and release
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v3
- name: Install Go
uses: actions/setup-go@v3
with:
go-version: ${{ env.GO_VERSION }}
- name: Build binaries
run: |
LOCAL_VERSION=${{ needs.prepare.outputs.RELEASE_VERSION }} make dist-release
- name: Get list of the commits since last release
run: |
echo "$(git log $(git describe --tags --abbrev=0)..HEAD --pretty=format:"%h %s")" > .release_notes
- name: Create release [semver]
uses: ncipollo/release-action@v1
with:
bodyFile: ./.release_notes
name: version ${{ needs.prepare.outputs.RELEASE_VERSION }}
token: ${{ secrets.GHCR_TOKEN }}
tag: ${{ needs.prepare.outputs.RELEASE_VERSION }}
prerelease: ${{ github.ref != 'refs/heads/master' && github.ref != 'refs/heads/main' }}
artifacts: "dist/*"
allowUpdates: true
- name: Delete existing v1 tag and release
run: |
gh release delete v1 --cleanup-tag -y
env:
GH_TOKEN: ${{ secrets.GHCR_TOKEN }}
- name: Create release [v1]
uses: ncipollo/release-action@v1
with:
bodyFile: ./.release_notes
name: v1 - ${{ needs.prepare.outputs.RELEASE_VERSION }}
token: ${{ secrets.GHCR_TOKEN }}
tag: v1
prerelease: ${{ github.ref != 'refs/heads/master' && github.ref != 'refs/heads/main' }}
artifacts: "dist/*"
allowUpdates: true
makeLatest: false
build-docker:
needs: [ prepare, test, code_scans, build-binary ]
name: Docker image build
runs-on: ubuntu-latest
steps:
- name: Checkout repo
uses: actions/checkout@v3
- name: Set up QEMU
uses: docker/setup-qemu-action@v2
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
- name: Login to GHCR
if: github.event_name != 'pull_request'
uses: docker/login-action@v2
with:
registry: ghcr.io
username: ${{ github.ACTOR }}
password: ${{ secrets.GHCR_TOKEN }}
- name: Prepare for push
id: prep
run: |
if [ -z "${{ needs.prepare.outputs.RELEASE_VERSION }}" ]; then
TAGS="${{ needs.prepare.outputs.DOCKER_IMAGE }}:${{ needs.prepare.outputs.GITHUB_SHA }},${{ needs.prepare.outputs.DOCKER_IMAGE }}:latest,${{ needs.prepare.outputs.DOCKER_IMAGE }}:v1"
else
TAGS="${{ needs.prepare.outputs.DOCKER_IMAGE }}:${{ needs.prepare.outputs.GITHUB_SHA }},${{ needs.prepare.outputs.DOCKER_IMAGE }}:${{ needs.prepare.outputs.RELEASE_VERSION }},${{ needs.prepare.outputs.DOCKER_IMAGE }}:latest,${{ needs.prepare.outputs.DOCKER_IMAGE }}:v1"
fi
echo "TAGS=$TAGS" >> $GITHUB_OUTPUT
BRANCH=$(echo ${GITHUB_REF##*/} | tr '[A-Z]' '[a-z]')
LABELS="org.opencontainers.image.revision=${{ needs.prepare.outputs.GITHUB_SHA }}"
LABELS="$LABELS,org.opencontainers.image.created=$(date -u +'%Y-%m-%dT%H:%M:%SZ')"
LABELS="$LABELS,org.opencontainers.image.version=$VERSION"
LABELS="$LABELS,com.github.repo.branch=$BRANCH"
LABELS="$LABELS,com.github.repo.dockerfile=Dockerfile"
echo "LABELS=$LABELS" >> $GITHUB_OUTPUT
BUILD_ARGS="BRANCH=$BRANCH"
echo "args=$BUILD_ARGS" >> $GITHUB_OUTPUT
- name: Build image
id: docker_build
uses: docker/build-push-action@v4
with:
builder: ${{ steps.buildx.outputs.name }}
platforms: linux/arm64,linux/amd64
push: ${{ github.ref == 'refs/heads/master' || github.ref == 'refs/heads/main' }}
tags: ${{ steps.prep.outputs.tags }}
build-args: |
GITHUB_AUTH_TOKEN=${{ secrets.GHCR_TOKEN }}
MICROSERVICE_NAME=${{ github.event.repository.name }}
GITHUB_COMMIT_NUMBER=${{ needs.prepare.outputs.GITHUB_COMMIT_NUMBER }}
GITHUB_SHA=${{ needs.prepare.outputs.GITHUB_SHA }}
${{ steps.prep.outputs.args }}
labels: ${{ steps.prep.outputs.labels }}
no-cache: false
release:
uses: lukaszraczylo/shared-actions/.github/workflows/go-release.yaml@main
with:
go-version: "1.24"
docker-enabled: true
rolling-release-tag: "v1"
semver-config: "config-release.yaml"
secrets: inherit
+45
View File
@@ -0,0 +1,45 @@
# Simple workflow for deploying static content to GitHub Pages
name: Deploy static content to Pages
on:
# Runs on pushes targeting the default branch
push:
branches: ["main"]
paths:
- 'docs/**'
# Allows you to run this workflow manually from the Actions tab
workflow_dispatch:
# Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages
permissions:
contents: read
pages: write
id-token: write
# Allow only one concurrent deployment, skipping runs queued between the run in-progress and latest queued.
# However, do NOT cancel in-progress runs as we want to allow these production deployments to complete.
concurrency:
group: "pages"
cancel-in-progress: false
jobs:
# Single deploy job since we're just deploying
deploy:
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Pages
uses: actions/configure-pages@v5
- name: Upload artifact
uses: actions/upload-pages-artifact@v3
with:
# Upload entire repository
path: 'docs/'
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v4
+109
View File
@@ -0,0 +1,109 @@
version: 2
before:
hooks:
- go mod tidy
builds:
- id: semver-gen
main: .
binary: semver-generator
env:
- CGO_ENABLED=0
goos:
- linux
- darwin
- windows
goarch:
- amd64
- arm64
ldflags:
- -s -w
- -X main.PKG_VERSION={{.Version}}
archives:
- id: semver-gen
formats: [tar.gz]
name_template: "semver-generator-{{ .Os }}-{{ .Arch }}"
format_overrides:
- goos: windows
formats: [zip]
files:
- LICENSE
- README.md
- config.yaml
checksum:
name_template: "semver-generator-checksums.txt"
algorithm: sha256
changelog:
sort: asc
filters:
exclude:
- '^docs:'
- '^test:'
- '^Merge'
- '^WIP'
- '^Update go.mod'
release:
github:
owner: lukaszraczylo
name: semver-generator
name_template: "version {{.Version}}"
draft: false
prerelease: auto
dockers_v2:
- images:
- "ghcr.io/lukaszraczylo/semver-generator"
tags:
- "{{ .Version }}"
- "latest"
- "v1"
platforms:
- linux/amd64
- linux/arm64
dockerfile: Dockerfile.goreleaser
extra_files:
- config-release.yaml
- entrypoint.sh
homebrew_casks:
- name: semver-generator
repository:
owner: lukaszraczylo
name: homebrew-taps
token: "{{ .Env.HOMEBREW_TAP_TOKEN }}"
directory: Casks
homepage: https://github.com/lukaszraczylo/semver-generator
description: "Automatic semantic version generator based on git commit messages"
license: MIT
hooks:
post:
install: |
if OS.mac?
system_command "/usr/bin/xattr",
args: ["-dr", "com.apple.quarantine", "#{staged_path}/semver-generator"]
end
signs:
- cmd: cosign
signature: "${artifact}.sigstore.json"
args:
- sign-blob
- "--bundle=${signature}"
- "${artifact}"
- "--yes"
artifacts: checksum
output: true
docker_signs:
- cmd: cosign
artifacts: manifests
output: true
args:
- sign
- "${artifact}@${digest}"
- "--yes"
+7
View File
@@ -0,0 +1,7 @@
FROM ubuntu:jammy
ARG TARGETPLATFORM
COPY ${TARGETPLATFORM}/semver-generator /go/src/app/semver-generator
COPY config-release.yaml /go/src/app/config.yaml
COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
ENTRYPOINT ["/entrypoint.sh"]
+81 -7
View File
@@ -16,6 +16,7 @@ Project created overnight, to prove that management of semantic versioning is NO
- [Calculations example \[standard\]](#calculations-example-standard)
- [Calculations example \[strict matching\]](#calculations-example-strict-matching)
- [Release candidates](#release-candidates)
- [Tag prefix stripping](#tag-prefix-stripping)
- [Example configuration](#example-configuration)
- [Good to know](#good-to-know)
@@ -29,6 +30,7 @@ Project created overnight, to prove that management of semantic versioning is NO
* With flag `-e` or config `force.existing: true` the existing tags in versioning will be respected, helping you to avoid the version conflicts.
* With config `force.commit: deadbeef` where `deadbeef` is the commit hash - calculations will start from the specified commit.
* Tag prefix stripping: The `v` prefix is automatically stripped from tags (e.g., `v1.2.3``1.2.3`). Additional prefixes can be configured via `tag_prefixes` for monorepo setups (e.g., `app-1.2.3`, `infra-1.2.3`).
### Important changes
@@ -49,15 +51,23 @@ export GITHUB_TOKEN=yourPersonalApiToken
#### As a binary
##### Homebrew (macOS)
```bash
brew install --cask lukaszraczylo/taps/semver-generator
```
##### Manual Download
You can download latest versions of the binaries from the [release page](https://github.com/lukaszraczylo/semver-generator/releases/latest).
**Supported OS and architectures:**
Darwin ARM64/AMD64, Linux ARM64/AMD64, Windows AMD64
```bash
bash$ ./semver-gen generate -r https://github.com/nextapps-de/winbox
bash$ semver-generator generate -r https://github.com/nextapps-de/winbox
SEMVER 9.0.10
bash$ ./semver-gen generate -l
bash$ semver-generator generate -l
SEMVER 5.1.1
```
@@ -65,8 +75,8 @@ SEMVER 5.1.1
```yaml
Usage:
semver-gen generate [flags]
semver-gen [command]
semver-generator generate [flags]
semver-generator [command]
Available Commands:
generate Generates semantic version
@@ -76,15 +86,25 @@ Flags:
-c, --config string Path to config file (default "semver.yaml")
-d, --debug Enable debug mode
-e, --existing Respect existing tags
-h, --help help for semver-gen
-h, --help help for semver-generator
-l, --local Use local repository
-r, --repository string Remote repository URL. (default "https://github.com/lukaszraczylo/simple-gql-client")
-b, --branch string Remote repository URL Branch. (default "main")
-s, --strict Strict matching
-u, --update Update binary with latest
-u, --update Update binary with latest (no authentication required)
-v, --version Display version
```
##### Self-Update
The binary can update itself to the latest version:
```bash
semver-generator -u
```
This downloads the latest release for your platform directly from GitHub releases. No authentication is required.
#### As a github action
```yaml
@@ -126,6 +146,25 @@ jobs:
docker pull ghcr.io/lukaszraczylo/semver-generator:latest
```
#### Verifying Release Signatures
All release checksums and Docker images are signed with [cosign](https://github.com/sigstore/cosign) using keyless signing. To verify:
```bash
# Verify checksum signature
cosign verify-blob \
--certificate-identity-regexp "https://github.com/lukaszraczylo/semver-generator/.*" \
--certificate-oidc-issuer "https://token.actions.githubusercontent.com" \
--bundle "<checksums-file>.sigstore.json" \
<checksums-file>
# Verify Docker image
cosign verify \
--certificate-identity-regexp "https://github.com/lukaszraczylo/semver-generator/.*" \
--certificate-oidc-issuer "https://token.actions.githubusercontent.com" \
ghcr.io/lukaszraczylo/semver-generator:latest
```
**Docker supported architectures:**
Linux/arm64, Linux/amd64
@@ -164,6 +203,36 @@ to generate the appropriate release in format `1.3.37-rc.1` and counting up unti
- add-rc
```
#### Tag prefix stripping
When using the `-e` (existing tags) flag, the semver-generator needs to parse existing git tags to determine the current version. Tags often include prefixes that need to be stripped before version parsing.
**Automatic `v` prefix stripping:**
The `v` prefix is always stripped automatically from tags. For example:
- `v1.2.3` → parsed as `1.2.3`
- `v0.5.0` → parsed as `0.5.0`
**Custom prefixes for monorepos:**
In monorepo setups where different components have their own versioned tags, you can configure additional prefixes to strip:
```yaml
tag_prefixes:
- "app-"
- "infra-"
- "api-"
- "frontend-"
```
With this configuration:
- `app-1.2.3` → parsed as `1.2.3`
- `infra-0.5.0` → parsed as `0.5.0`
- `api-2.0.0-rc.1` → parsed as `2.0.0-rc.1` (release candidate)
This is particularly useful when:
- You have multiple services/components in a single repository
- Your CI/CD creates tags with component prefixes
- You want to track versions separately for different parts of your codebase
#### Example configuration
```yaml
@@ -178,6 +247,10 @@ blacklist:
- "Merge pull request"
- "feature/"
- "feature:"
tag_prefixes:
- "app-"
- "infra-"
- "service-"
wording:
patch:
- update
@@ -197,9 +270,10 @@ wording:
* `force`: sets the "starting" version, you don't need to specify this section as the default is always `0`
* `force.commit`: allows you to set commit hash from which the calculations should start
* `blacklist`: terms to ignore when processing commits. Any commit containing these terms will be skipped in version calculations. Useful for ignoring merge commits, feature branch names, and other unwanted triggers.
* `tag_prefixes`: prefixes to strip from existing tags before parsing version numbers. Useful for monorepos where tags are prefixed with component names (e.g., `app-1.2.3`, `infra-0.5.0`). The `v` prefix is always stripped automatically.
* `wording`: words the program should look for in the git commits to increment (patch|minor|major)
### Good to know
### Good to knows
* Word matching uses fuzzy search AND is case INSENSITIVE
* I do not recommend using common words ( like "the" from the example configuration )
+18 -14
View File
@@ -26,27 +26,26 @@ import (
)
var (
err error
repo *Setup
PKG_VERSION string
)
// Setup represents the application setup
type Setup struct {
RepositoryName string
RepositoryBranch string
LocalConfigFile string
Generate bool
UseLocal bool
GitRepo utils.GitRepository
Config *utils.Config
Semver utils.SemVer
RepositoryName string
RepositoryBranch string
LocalConfigFile string
Generate bool
UseLocal bool
GitRepo utils.GitRepository
Config *utils.Config
Semver utils.SemVer
}
// Initialize the fuzzy search function in the utils package
func init() {
utils.InitLogger(false) // Will be updated in main based on debug flag
// Set the fuzzy search function
utils.FuzzyFind = fuzzy.FindNormalizedFold
}
@@ -72,12 +71,12 @@ func main() {
if PKG_VERSION != latestRelease && latestReleaseOk {
outdatedMsg = fmt.Sprintf("(Latest available: %s)", latestRelease)
}
utils.Info("semver-gen", map[string]interface{}{
"version": PKG_VERSION,
"version": PKG_VERSION,
"outdated": outdatedMsg,
})
if outdatedMsg != "" {
utils.Info("semver-gen", map[string]interface{}{
"message": "You can update automatically with: semver-gen -u",
@@ -122,7 +121,11 @@ func main() {
}
// List commits
utils.ListCommits(&repo.GitRepo)
if _, err := utils.ListCommits(&repo.GitRepo); err != nil {
utils.Error("Unable to list commits", map[string]interface{}{
"error": err.Error(),
})
}
// List existing tags if needed
if params.varExisting || repo.Config.Force.Existing {
@@ -141,6 +144,7 @@ func main() {
repo.Semver,
params.varExisting || repo.Config.Force.Existing,
params.varStrict || repo.Config.Force.Strict,
repo.Config.TagPrefixes,
)
// Print semantic version
+39 -18
View File
@@ -267,7 +267,7 @@ func (suite *Tests) Test_checkMatches() {
if tt.name == "No match" {
return nil
}
// For other test cases, match if the needle is in the haystack
for _, h := range haystack {
if strings.Contains(h, needle) || strings.Contains(needle, h) {
@@ -276,7 +276,7 @@ func (suite *Tests) Test_checkMatches() {
}
return nil
}
got := utils.CheckMatches(tt.args.content, tt.args.targets, tt.blacklist)
assertObj.Equal(tt.want, got, "Unexpected result in "+tt.name)
})
@@ -285,7 +285,8 @@ func (suite *Tests) Test_checkMatches() {
func (suite *Tests) Test_parseExistingSemver() {
type args struct {
tagName string
tagName string
prefixes []string
}
tests := []struct {
name string
@@ -296,7 +297,8 @@ func (suite *Tests) Test_parseExistingSemver() {
{
name: "Test parsing existing semver",
args: args{
tagName: "1.2.3",
tagName: "1.2.3",
prefixes: []string{},
},
currentSemver: utils.SemVer{Major: 1, Minor: 1, Patch: 1},
wantSemanticVersion: utils.SemVer{
@@ -308,7 +310,8 @@ func (suite *Tests) Test_parseExistingSemver() {
{
name: "Test parsing existing semver with v",
args: args{
tagName: "v1.2.3",
tagName: "v1.2.3",
prefixes: []string{"v"},
},
currentSemver: utils.SemVer{Major: 1, Minor: 1, Patch: 1},
wantSemanticVersion: utils.SemVer{
@@ -320,7 +323,8 @@ func (suite *Tests) Test_parseExistingSemver() {
{
name: "Test parsing existing semver with rc",
args: args{
tagName: "1.2.5-rc.7",
tagName: "1.2.5-rc.7",
prefixes: []string{},
},
currentSemver: utils.SemVer{Major: 1, Minor: 1, Patch: 1},
wantSemanticVersion: utils.SemVer{
@@ -331,10 +335,25 @@ func (suite *Tests) Test_parseExistingSemver() {
EnableReleaseCandidate: true,
},
},
{
name: "Test parsing prefixed tag without rc",
args: args{
tagName: "app-0.0.16",
prefixes: []string{"app-", "infra-"},
},
currentSemver: utils.SemVer{Major: 1, Minor: 1, Patch: 1},
wantSemanticVersion: utils.SemVer{
Major: 0,
Minor: 0,
Patch: 16,
EnableReleaseCandidate: false,
},
},
{
name: "Test invalid semver format",
args: args{
tagName: "invalid",
tagName: "invalid",
prefixes: []string{},
},
currentSemver: utils.SemVer{Major: 2, Minor: 3, Patch: 4},
wantSemanticVersion: utils.SemVer{
@@ -346,7 +365,8 @@ func (suite *Tests) Test_parseExistingSemver() {
{
name: "Test partial semver",
args: args{
tagName: "1.2",
tagName: "1.2",
prefixes: []string{},
},
currentSemver: utils.SemVer{Major: 2, Minor: 3, Patch: 4},
wantSemanticVersion: utils.SemVer{
@@ -358,7 +378,8 @@ func (suite *Tests) Test_parseExistingSemver() {
{
name: "Test empty tag",
args: args{
tagName: "",
tagName: "",
prefixes: []string{},
},
currentSemver: utils.SemVer{Major: 2, Minor: 3, Patch: 4},
wantSemanticVersion: utils.SemVer{
@@ -370,7 +391,7 @@ func (suite *Tests) Test_parseExistingSemver() {
}
for _, tt := range tests {
suite.T().Run(tt.name, func(t *testing.T) {
got := utils.ParseExistingSemver(tt.args.tagName, tt.currentSemver)
got := utils.ParseExistingSemver(tt.args.tagName, tt.currentSemver, tt.args.prefixes)
assertObj.Equal(tt.wantSemanticVersion.Major, got.Major, "Unexpected MAJOR semver result in "+tt.name)
assertObj.Equal(tt.wantSemanticVersion.Minor, got.Minor, "Unexpected MINOR semver result in "+tt.name)
assertObj.Equal(tt.wantSemanticVersion.Patch, got.Patch, "Unexpected PATCH semver result in "+tt.name)
@@ -382,10 +403,10 @@ func (suite *Tests) Test_parseExistingSemver() {
func (suite *Tests) TestSetup_ListCommits() {
type fields struct {
RepositoryName string
RepositoryBranch string
LocalConfigFile string
GitRepo utils.GitRepository
RepositoryName string
RepositoryBranch string
LocalConfigFile string
GitRepo utils.GitRepository
}
tests := []struct {
@@ -441,23 +462,23 @@ func (suite *Tests) TestSetup_ListCommits() {
if tt.name == "List commits from existing repository" {
t.Skip("Skipping test that requires repository access")
}
s := &Setup{
RepositoryName: tt.fields.RepositoryName,
RepositoryBranch: tt.fields.RepositoryBranch,
GitRepo: tt.fields.GitRepo,
}
config, _ := utils.ReadConfig(tt.fields.LocalConfigFile)
s.Config = config
err := utils.PrepareRepository(&s.GitRepo)
if err != nil && !tt.wantErr {
if tt.name != "List commits starting with certain hash" {
t.Fatalf("Failed to prepare repository: %v", err)
}
}
if err == nil {
listOfCommits, err := utils.ListCommits(&s.GitRepo)
if !tt.wantErr {
+22 -12
View File
@@ -26,26 +26,36 @@ type Force struct {
// Config represents the application configuration
type Config struct {
Wording Wording
Force Force
Blacklist []string
Wording Wording
Force Force
Blacklist []string
TagPrefixes []string // Prefixes to strip from tags before parsing (e.g., "app-", "infra-", "v")
}
// ReadConfig reads the configuration from a file
func ReadConfig(file string) (*Config, error) {
config := &Config{}
viper.SetConfigFile(file)
err := viper.ReadInConfig()
if err != nil {
err = fmt.Errorf("fatal error config file: %s", err)
return config, err
}
viper.UnmarshalKey("wording", &config.Wording)
viper.UnmarshalKey("force", &config.Force)
viper.UnmarshalKey("blacklist", &config.Blacklist)
if err := viper.UnmarshalKey("wording", &config.Wording); err != nil {
return config, fmt.Errorf("error parsing wording config: %w", err)
}
if err := viper.UnmarshalKey("force", &config.Force); err != nil {
return config, fmt.Errorf("error parsing force config: %w", err)
}
if err := viper.UnmarshalKey("blacklist", &config.Blacklist); err != nil {
return config, fmt.Errorf("error parsing blacklist config: %w", err)
}
if err := viper.UnmarshalKey("tag_prefixes", &config.TagPrefixes); err != nil {
return config, fmt.Errorf("error parsing tag_prefixes config: %w", err)
}
return config, nil
}
@@ -55,14 +65,14 @@ func ApplyForcedVersioning(force Force, semver *SemVer) {
Debug("Forced versioning (MAJOR)", map[string]interface{}{"major": force.Major})
semver.Major = force.Major
}
if force.Minor > 0 {
Debug("Forced versioning (MINOR)", map[string]interface{}{"minor": force.Minor})
semver.Minor = force.Minor
}
if force.Patch > 0 {
Debug("Forced versioning (PATCH)", map[string]interface{}{"patch": force.Patch})
semver.Patch = force.Patch
}
}
}
+40 -32
View File
@@ -29,14 +29,14 @@ type TagDetails struct {
// GitRepository represents a git repository
type GitRepository struct {
Handler *git.Repository
Name string
Branch string
LocalPath string
UseLocal bool
Commits []CommitDetails
Tags []TagDetails
StartCommit string
Handler *git.Repository
Name string
Branch string
LocalPath string
UseLocal bool
Commits []CommitDetails
Tags []TagDetails
StartCommit string
}
// PrepareRepository prepares the git repository for use
@@ -47,15 +47,15 @@ func PrepareRepository(repo *GitRepository) error {
u, err := url.Parse(repo.Name)
if err != nil {
Error("Unable to parse repository URL", map[string]interface{}{
"error": err.Error(),
"url": repo.Name,
"error": err.Error(),
"url": repo.Name,
})
return err
}
repo.LocalPath = fmt.Sprintf("/tmp/semver/%s/%s", u.Path, repo.Branch)
os.RemoveAll(repo.LocalPath)
_ = os.RemoveAll(repo.LocalPath) // Ignore error - directory may not exist
repo.Handler, err = git.PlainClone(repo.LocalPath, false, &git.CloneOptions{
URL: repo.Name,
ReferenceName: plumbing.NewBranchReferenceName(repo.Branch),
@@ -66,11 +66,11 @@ func PrepareRepository(repo *GitRepository) error {
},
Tags: git.AllTags,
})
if err != nil {
Error("Unable to clone repository", map[string]interface{}{
"error": err.Error(),
"url": repo.Name,
"error": err.Error(),
"url": repo.Name,
})
return err
}
@@ -79,14 +79,20 @@ func PrepareRepository(repo *GitRepository) error {
repo.Handler, err = git.PlainOpen(repo.LocalPath)
if err != nil {
Error("Unable to open local repository", map[string]interface{}{
"error": err.Error(),
"path": repo.LocalPath,
"error": err.Error(),
"path": repo.LocalPath,
})
return err
}
}
os.Chdir(repo.LocalPath)
if err := os.Chdir(repo.LocalPath); err != nil {
Error("Unable to change directory", map[string]interface{}{
"error": err.Error(),
"path": repo.LocalPath,
})
return err
}
return nil
}
@@ -105,14 +111,14 @@ func ListCommits(repo *GitRepository) ([]CommitDetails, error) {
if err != nil {
return []CommitDetails{}, err
}
commitsList, err := repo.Handler.Log(&git.LogOptions{From: ref.Hash()})
if err != nil {
return []CommitDetails{}, err
}
var tmpResults []CommitDetails
commitsList.ForEach(func(c *object.Commit) error {
if err := commitsList.ForEach(func(c *object.Commit) error {
tmpResults = append(tmpResults, CommitDetails{
Hash: c.Hash.String(),
Author: c.Author.String(),
@@ -123,17 +129,19 @@ func ListCommits(repo *GitRepository) ([]CommitDetails, error) {
return tmpResults[i].Timestamp.Unix() < tmpResults[j].Timestamp.Unix()
})
return nil
})
}); err != nil {
return []CommitDetails{}, err
}
Debug("Listing commits", map[string]interface{}{"commits": tmpResults})
// Filter commits starting from the specified commit if provided
if repo.StartCommit != "" {
for commitId, cmt := range tmpResults {
if cmt.Hash == repo.StartCommit {
Debug("Found commit match", map[string]interface{}{
"commit": cmt.Hash,
"index": commitId,
"index": commitId,
})
repo.Commits = tmpResults[commitId:]
break
@@ -150,32 +158,32 @@ func ListCommits(repo *GitRepository) ([]CommitDetails, error) {
// ListExistingTags lists all tags in the repository
func ListExistingTags(repo *GitRepository) {
Debug("Listing existing tags", nil)
// Check if Handler is nil to avoid panic
if repo.Handler == nil {
Debug("Repository handler is nil, skipping tag listing", nil)
return
}
refs, err := repo.Handler.Tags()
if err != nil {
Error("Unable to list tags", map[string]interface{}{"error": err.Error()})
return
}
if err := refs.ForEach(func(ref *plumbing.Reference) error {
repo.Tags = append(repo.Tags, TagDetails{
Name: ref.Name().Short(),
Hash: ref.Hash().String(),
})
Debug("Found tag", map[string]interface{}{
"tag": ref.Name().Short(),
"tag": ref.Name().Short(),
"hash": ref.Hash().String(),
})
return nil
}); err != nil {
Error("Error iterating tags", map[string]interface{}{"error": err.Error()})
}
}
}
+409 -120
View File
@@ -1,148 +1,437 @@
package utils
import (
"flag"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"os/exec"
"runtime"
"github.com/lukaszraczylo/ask"
graphql "github.com/lukaszraczylo/go-simple-graphql"
"github.com/melbahja/got"
"strings"
"time"
)
// UpdatePackage updates the binary with the latest version
func UpdatePackage() bool {
ghToken, ghTokenSet := os.LookupEnv("GITHUB_TOKEN")
if !ghTokenSet {
Error("GITHUB_TOKEN not set", nil)
return false
}
const (
// GitHub API endpoint for latest release
githubReleasesURL = "https://api.github.com/repos/lukaszraczylo/semver-generator/releases/latest"
// Request timeout for HTTP requests
requestTimeout = 10 * time.Second
)
binaryName := fmt.Sprintf("semver-gen-%s-%s", runtime.GOOS, runtime.GOARCH)
Info("Checking for updates", map[string]interface{}{"binaryName": binaryName})
gql := graphql.NewConnection()
gql.SetEndpoint("https://api.github.com/graphql")
gql.SetOutput("mapstring")
// ReleaseInfo contains information about a GitHub release
type ReleaseInfo struct {
TagName string `json:"tag_name"`
HTMLURL string `json:"html_url"`
Name string `json:"name"`
Assets []ReleaseAsset `json:"assets"`
}
headers := map[string]interface{}{
"Authorization": fmt.Sprintf("Bearer %s", ghToken),
}
variables := map[string]interface{}{
"binaryName": binaryName,
}
var query = `query ($binaryName: String) {
repository(name: "semver-generator", owner: "lukaszraczylo") {
latestRelease {
releaseAssets(first: 10, name: $binaryName) {
edges {
node {
name
downloadUrl
}
}
}
}
}
}`
result, err := gql.Query(query, variables, headers)
// ReleaseAsset contains information about a release asset
type ReleaseAsset struct {
Name string `json:"name"`
BrowserDownloadURL string `json:"browser_download_url"`
}
// UpdateInfo contains information about an available update
type UpdateInfo struct {
CurrentVersion string
LatestVersion string
ReleaseURL string
DownloadURL string
}
// httpClient is the HTTP client used for requests (allows mocking in tests)
var httpClient = &http.Client{
Timeout: requestTimeout,
}
// CheckLatestRelease checks for the latest release version using REST API
// Returns the latest version tag and true if successful, empty string and false otherwise
func CheckLatestRelease() (string, bool) {
release, err := fetchLatestRelease(context.Background())
if err != nil {
Error("Unable to query GitHub API", map[string]interface{}{"error": err.Error()})
Debug("Unable to check latest release", map[string]interface{}{"error": err.Error()})
return "", false
}
version := normalizeVersion(release.TagName)
return version, true
}
// CheckForUpdate checks if a newer version is available
// Returns UpdateInfo if an update is available, nil otherwise
func CheckForUpdate(currentVersion string) *UpdateInfo {
release, err := fetchLatestRelease(context.Background())
if err != nil {
return nil
}
latestVersion := normalizeVersion(release.TagName)
current := normalizeVersion(currentVersion)
if isNewerVersion(latestVersion, current) {
downloadURL := findBinaryAsset(release.Assets)
return &UpdateInfo{
CurrentVersion: current,
LatestVersion: latestVersion,
ReleaseURL: release.HTMLURL,
DownloadURL: downloadURL,
}
}
return nil
}
// UpdatePackage downloads and installs the latest version
func UpdatePackage() bool {
Info("Checking for updates", nil)
release, err := fetchLatestRelease(context.Background())
if err != nil {
Error("Unable to fetch latest release", map[string]interface{}{"error": err.Error()})
return false
}
output, ok := ask.For(result, "repository.latestRelease.releaseAssets.edges[0].node.downloadUrl").String("")
if !ok {
Error("Unable to obtain download url for the binary", map[string]interface{}{
"binary": binaryName,
"output": output,
downloadURL := findBinaryAsset(release.Assets)
if downloadURL == "" {
Error("Unable to find binary for current platform", map[string]interface{}{
"os": runtime.GOOS,
"arch": runtime.GOARCH,
})
return false
}
// Skip actual download in test mode
if flag.Lookup("test.v") == nil && os.Getenv("CI") == "" {
downloadedBinaryPath := fmt.Sprintf("/tmp/%s", binaryName)
g := got.New()
err = g.Download(output, downloadedBinaryPath)
if err != nil {
Error("Unable to download binary", map[string]interface{}{
"error": err.Error(),
"binaryPath": downloadedBinaryPath,
})
return false
}
currentBinary, err := os.Executable()
if err != nil {
Error("Unable to obtain current binary path", map[string]interface{}{
"error": err.Error(),
})
return false
}
err = os.Rename(downloadedBinaryPath, currentBinary)
if err != nil {
Error("Unable to overwrite current binary", map[string]interface{}{
"error": err.Error(),
})
return false
}
err = os.Chmod(currentBinary, 0777)
if err != nil {
Error("Unable to make binary executable", map[string]interface{}{
"error": err.Error(),
})
return false
}
Info("Downloading update", map[string]interface{}{
"version": release.TagName,
"url": downloadURL,
})
// Download to temp file
tempFile, err := downloadBinary(downloadURL)
if err != nil {
Error("Unable to download binary", map[string]interface{}{"error": err.Error()})
return false
}
defer os.Remove(tempFile) // Clean up temp file on failure
// Get current binary path
currentBinary, err := os.Executable()
if err != nil {
Error("Unable to get current binary path", map[string]interface{}{"error": err.Error()})
return false
}
// Replace current binary
if err := replaceBinary(tempFile, currentBinary); err != nil {
Error("Unable to replace binary", map[string]interface{}{"error": err.Error()})
return false
}
Info("Update successful", map[string]interface{}{
"version": release.TagName,
})
return true
}
// CheckLatestRelease checks for the latest release version
func CheckLatestRelease() (string, bool) {
ghToken, ghTokenSet := os.LookupEnv("GITHUB_TOKEN")
if !ghTokenSet {
return "[no GITHUB_TOKEN set]", false
// fetchLatestRelease fetches the latest release info from GitHub REST API
func fetchLatestRelease(ctx context.Context) (*ReleaseInfo, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, githubReleasesURL, nil)
if err != nil {
return nil, err
}
gql := graphql.NewConnection()
gql.SetEndpoint("https://api.github.com/graphql")
headers := map[string]interface{}{
"Authorization": fmt.Sprintf("bearer %s", ghToken),
req.Header.Set("Accept", "application/vnd.github.v3+json")
req.Header.Set("User-Agent", "semver-generator")
resp, err := httpClient.Do(req)
if err != nil {
return nil, err
}
variables := map[string]interface{}{}
var query = `query {
repository(name: "semver-generator", owner: "lukaszraczylo", followRenames: true) {
releases(last: 2) {
nodes {
tag {
name
}
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("GitHub API returned status %d", resp.StatusCode)
}
var release ReleaseInfo
if err := json.NewDecoder(resp.Body).Decode(&release); err != nil {
return nil, err
}
return &release, nil
}
// findBinaryAsset finds the download URL for the current platform
func findBinaryAsset(assets []ReleaseAsset) string {
// Build expected binary name pattern
// Format: semver-gen-{version}-{os}-{arch}.tar.gz or just semver-gen-{os}-{arch}
osName := runtime.GOOS
archName := runtime.GOARCH
for _, asset := range assets {
name := strings.ToLower(asset.Name)
// Match patterns like "semver-gen-1.0.0-darwin-arm64.tar.gz" or "semver-gen-darwin-arm64"
if strings.Contains(name, osName) && strings.Contains(name, archName) {
// Prefer tar.gz archives
if strings.HasSuffix(name, ".tar.gz") {
return asset.BrowserDownloadURL
}
}
}`
result, err := gql.Query(query, variables, headers)
}
// Fallback: try to find any matching binary without tar.gz
for _, asset := range assets {
name := strings.ToLower(asset.Name)
if strings.Contains(name, osName) && strings.Contains(name, archName) {
// Skip checksums
if strings.Contains(name, "checksum") || strings.HasSuffix(name, ".sha256") || strings.HasSuffix(name, ".md5") {
continue
}
return asset.BrowserDownloadURL
}
}
return ""
}
// downloadBinary downloads the binary to a temp file and returns the path
func downloadBinary(url string) (string, error) {
resp, err := httpClient.Get(url)
if err != nil {
Error("Unable to query GitHub API", map[string]interface{}{"error": err.Error()})
return "", false
return "", err
}
output, _ := ask.For(result, "repository.releases.nodes[0].tag.name").String("")
if output == "v1" {
output, _ = ask.For(result, "repository.releases.nodes[1].tag.name").String("")
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("download failed with status %d", resp.StatusCode)
}
return output, true
}
// Create temp file
tempFile, err := os.CreateTemp("", "semver-generator-update-*")
if err != nil {
return "", err
}
tempPath := tempFile.Name()
// Check if it's a tar.gz archive
if strings.HasSuffix(url, ".tar.gz") {
// For tar.gz, we need to extract the binary
if err := extractTarGz(resp.Body, tempFile); err != nil {
_ = tempFile.Close()
_ = os.Remove(tempPath)
return "", err
}
} else {
// Direct binary download
if _, err := io.Copy(tempFile, resp.Body); err != nil {
_ = tempFile.Close()
_ = os.Remove(tempPath)
return "", err
}
}
if err := tempFile.Close(); err != nil {
_ = os.Remove(tempPath)
return "", err
}
return tempPath, nil
}
// extractTarGz extracts the semver-generator binary from a tar.gz archive
func extractTarGz(r io.Reader, destFile *os.File) error {
// For simplicity, we'll download the whole archive to a temp file first,
// then use tar command to extract. This avoids adding archive/tar dependency.
// Create temp archive file
archiveFile, err := os.CreateTemp("", "semver-generator-archive-*.tar.gz")
if err != nil {
return err
}
archivePath := archiveFile.Name()
defer os.Remove(archivePath)
if _, err := io.Copy(archiveFile, r); err != nil {
_ = archiveFile.Close()
return err
}
if err := archiveFile.Close(); err != nil {
return err
}
// Extract using tar command
extractDir, err := os.MkdirTemp("", "semver-generator-extract-*")
if err != nil {
return err
}
defer os.RemoveAll(extractDir)
// Use tar to extract
cmd := fmt.Sprintf("tar -xzf %s -C %s", archivePath, extractDir)
if err := runCommand(cmd); err != nil {
return fmt.Errorf("failed to extract archive: %w", err)
}
// Find the binary in the extracted files
// Support both new name (semver-generator) and old name (semver-gen) for backwards compatibility
binaryPath := ""
entries, err := os.ReadDir(extractDir)
if err != nil {
return err
}
// First try to find semver-generator (new name)
for _, entry := range entries {
if entry.Name() == "semver-generator" {
binaryPath = fmt.Sprintf("%s/%s", extractDir, entry.Name())
break
}
}
// Fallback to semver-gen (old name) for older releases
if binaryPath == "" {
for _, entry := range entries {
if entry.Name() == "semver-gen" {
binaryPath = fmt.Sprintf("%s/%s", extractDir, entry.Name())
break
}
}
}
if binaryPath == "" {
return fmt.Errorf("binary not found in archive (looked for semver-generator and semver-gen)")
}
// Copy the binary to the destination
srcFile, err := os.Open(binaryPath)
if err != nil {
return err
}
defer srcFile.Close()
// Seek to beginning of dest file and truncate
if _, err := destFile.Seek(0, 0); err != nil {
return err
}
if err := destFile.Truncate(0); err != nil {
return err
}
if _, err := io.Copy(destFile, srcFile); err != nil {
return err
}
return nil
}
// runCommand runs a shell command
func runCommand(cmdStr string) error {
return runCommandFunc(cmdStr)
}
// runCommandFunc is the function used to run commands (allows mocking in tests)
var runCommandFunc = func(cmdStr string) error {
cmd := exec.Command("sh", "-c", cmdStr)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
return cmd.Run()
}
// replaceBinary replaces the current binary with the new one
func replaceBinary(newBinary, currentBinary string) error {
// Make the new binary executable
// #nosec G302 -- 0755 is required for executable binaries
if err := os.Chmod(newBinary, 0755); err != nil {
return err
}
// Rename (atomic on most systems)
if err := os.Rename(newBinary, currentBinary); err != nil {
// If rename fails (e.g., cross-device), try copy
return copyFile(newBinary, currentBinary)
}
return nil
}
// copyFile copies a file from src to dst
// Note: This function is only called internally with controlled paths from
// os.CreateTemp and os.Executable, not with user-supplied paths.
func copyFile(src, dst string) error {
// #nosec G304 -- src is from os.CreateTemp, not user input
srcFile, err := os.Open(src)
if err != nil {
return err
}
defer srcFile.Close()
// #nosec G304 -- dst is from os.Executable, not user input
dstFile, err := os.Create(dst)
if err != nil {
return err
}
defer dstFile.Close()
if _, err := io.Copy(dstFile, srcFile); err != nil {
return err
}
// Make executable
// #nosec G302 -- 0755 is required for executable binaries
return os.Chmod(dst, 0755)
}
// normalizeVersion removes 'v' or 'V' prefix and trims whitespace
func normalizeVersion(v string) string {
v = strings.TrimSpace(v)
v = strings.TrimPrefix(v, "v")
v = strings.TrimPrefix(v, "V")
return v
}
// isNewerVersion compares two semver-like versions
// Returns true if latest is newer than current
func isNewerVersion(latest, current string) bool {
latestParts := parseVersionParts(latest)
currentParts := parseVersionParts(current)
for i := 0; i < len(latestParts) && i < len(currentParts); i++ {
if latestParts[i] > currentParts[i] {
return true
}
if latestParts[i] < currentParts[i] {
return false
}
}
return len(latestParts) > len(currentParts)
}
// parseVersionParts splits a version string into numeric parts
func parseVersionParts(v string) []int {
// Remove any suffix like -beta, -rc1, etc.
if idx := strings.IndexAny(v, "-+"); idx != -1 {
v = v[:idx]
}
parts := strings.Split(v, ".")
result := make([]int, 0, len(parts))
for _, p := range parts {
var num int
if _, err := fmt.Sscanf(p, "%d", &num); err != nil {
// If parsing fails, use 0 for this part
num = 0
}
result = append(result, num)
}
return result
}
// FormatUpdateMessage formats a user-friendly update notification
func (u *UpdateInfo) FormatUpdateMessage() string {
return fmt.Sprintf("New version available: %s (current: %s) - %s",
u.LatestVersion, u.CurrentVersion, u.ReleaseURL)
}
+227 -53
View File
@@ -1,66 +1,240 @@
package utils
import (
"flag"
"net/http"
"net/http/httptest"
"os"
"testing"
"github.com/stretchr/testify/assert"
)
func TestCheckLatestRelease(t *testing.T) {
// Initialize logger
InitLogger(true)
// Save original environment variables
originalToken := os.Getenv("GITHUB_TOKEN")
defer os.Setenv("GITHUB_TOKEN", originalToken)
// Test with no token
os.Unsetenv("GITHUB_TOKEN")
release, ok := CheckLatestRelease()
assert.Equal(t, "[no GITHUB_TOKEN set]", release, "Should return no token message")
assert.False(t, ok, "Should return false when no token is set")
// Test with token but simulating API error
// Set a dummy token that won't work with the GitHub API
os.Setenv("GITHUB_TOKEN", "dummy-token")
release, ok = CheckLatestRelease()
assert.Equal(t, "", release, "Should return empty string on API error")
assert.False(t, ok, "Should return false on API error")
// We can't reliably test the successful API call in unit tests
// as it would require a valid GitHub token and network access
}
func TestUpdatePackage(t *testing.T) {
// Initialize logger
InitLogger(true)
// Save original environment variables
originalToken := os.Getenv("GITHUB_TOKEN")
defer os.Setenv("GITHUB_TOKEN", originalToken)
// Test with no token
os.Unsetenv("GITHUB_TOKEN")
result := UpdatePackage()
assert.False(t, result, "Should return false when no token is set")
// Test with token but simulating API error
os.Setenv("GITHUB_TOKEN", "dummy-token")
result = UpdatePackage()
assert.False(t, result, "Should return false on API error")
// Create a test flag to simulate test mode
if flag.Lookup("test.v") == nil {
// This is a hack to simulate the test flag being set
// which is used in the UpdatePackage function to skip actual download
flag.Bool("test.v", true, "")
func TestNormalizeVersion(t *testing.T) {
tests := []struct {
input string
expected string
}{
{"v1.0.0", "1.0.0"},
{"1.0.0", "1.0.0"},
{" v2.1.3 ", "2.1.3"},
{"V1.0.0", "1.0.0"},
{"v", ""},
{"", ""},
}
// We can't fully test the update functionality as it would modify the binary
// but we've tested the token check logic and API error handling
for _, tt := range tests {
t.Run(tt.input, func(t *testing.T) {
result := normalizeVersion(tt.input)
assert.Equal(t, tt.expected, result)
})
}
}
// Note: We're not using mock transports for these tests to avoid
// adding complexity. The tests focus on the token presence logic and error handling.
func TestParseVersionParts(t *testing.T) {
tests := []struct {
input string
expected []int
}{
{"1.0.0", []int{1, 0, 0}},
{"2.1.3", []int{2, 1, 3}},
{"1.0", []int{1, 0}},
{"10.20.30", []int{10, 20, 30}},
{"1.0.0-beta", []int{1, 0, 0}},
{"1.0.0-rc1", []int{1, 0, 0}},
{"1.0.0+build123", []int{1, 0, 0}},
}
for _, tt := range tests {
t.Run(tt.input, func(t *testing.T) {
result := parseVersionParts(tt.input)
assert.Equal(t, tt.expected, result)
})
}
}
func TestIsNewerVersion(t *testing.T) {
tests := []struct {
name string
latest string
current string
expected bool
}{
{"major version bump", "2.0.0", "1.0.0", true},
{"minor version bump", "1.1.0", "1.0.0", true},
{"patch version bump", "1.0.1", "1.0.0", true},
{"same version", "1.0.0", "1.0.0", false},
{"current is newer major", "1.0.0", "2.0.0", false},
{"current is newer minor", "1.0.0", "1.1.0", false},
{"current is newer patch", "1.0.0", "1.0.1", false},
{"multi-digit versions", "1.10.0", "1.9.0", true},
{"longer version is newer", "1.0.1", "1.0", true},
{"shorter version is older", "1.0", "1.0.1", false},
{"complex comparison", "2.1.3", "2.1.2", true},
{"real world example", "0.2.0", "0.1.0", true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := isNewerVersion(tt.latest, tt.current)
assert.Equal(t, tt.expected, result)
})
}
}
func TestFindBinaryAsset(t *testing.T) {
assets := []ReleaseAsset{
{Name: "semver-gen-1.0.0-linux-amd64.tar.gz", BrowserDownloadURL: "https://example.com/linux-amd64.tar.gz"},
{Name: "semver-gen-1.0.0-darwin-arm64.tar.gz", BrowserDownloadURL: "https://example.com/darwin-arm64.tar.gz"},
{Name: "semver-gen-1.0.0-darwin-amd64.tar.gz", BrowserDownloadURL: "https://example.com/darwin-amd64.tar.gz"},
{Name: "semver-gen-1.0.0-windows-amd64.zip", BrowserDownloadURL: "https://example.com/windows-amd64.zip"},
{Name: "semver-gen-1.0.0-checksums.txt", BrowserDownloadURL: "https://example.com/checksums.txt"},
}
// Test finding the correct asset for the current platform
url := findBinaryAsset(assets)
assert.NotEmpty(t, url, "Should find a binary for the current platform")
assert.NotContains(t, url, "checksum", "Should not return checksum file")
}
func TestFindBinaryAssetEmpty(t *testing.T) {
assets := []ReleaseAsset{}
url := findBinaryAsset(assets)
assert.Empty(t, url, "Should return empty string when no assets")
}
func TestUpdateInfo_FormatUpdateMessage(t *testing.T) {
info := &UpdateInfo{
CurrentVersion: "1.0.0",
LatestVersion: "2.0.0",
ReleaseURL: "https://github.com/lukaszraczylo/semver-generator/releases/tag/v2.0.0",
}
msg := info.FormatUpdateMessage()
assert.Contains(t, msg, "2.0.0")
assert.Contains(t, msg, "1.0.0")
assert.Contains(t, msg, "https://github.com/lukaszraczylo/semver-generator/releases/tag/v2.0.0")
}
func TestCheckLatestRelease(t *testing.T) {
// Initialize logger
InitLogger(false)
// Create a mock server
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{
"tag_name": "v1.2.3",
"html_url": "https://github.com/lukaszraczylo/semver-generator/releases/tag/v1.2.3",
"name": "Release 1.2.3",
"assets": []
}`))
}))
defer server.Close()
// Note: In a real test, we'd need to mock the HTTP client or the URL
// For now, we just test the network error case
release, ok := CheckLatestRelease()
// This will either succeed (if network is available) or fail gracefully
if ok {
assert.NotEmpty(t, release)
}
}
func TestCheckForUpdate(t *testing.T) {
InitLogger(false)
// Test with a very old version - should show update available if network works
info := CheckForUpdate("0.0.1")
// This will either return update info or nil depending on network
if info != nil {
assert.NotEmpty(t, info.LatestVersion)
assert.Equal(t, "0.0.1", info.CurrentVersion)
}
// Test with a very new version - should not show update
info = CheckForUpdate("999.999.999")
assert.Nil(t, info, "Should not show update for future version")
}
func TestFetchLatestReleaseError(t *testing.T) {
InitLogger(false)
// Save original client
originalClient := httpClient
defer func() { httpClient = originalClient }()
// Create a mock server that returns an error
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
}))
defer server.Close()
// We can't easily test this without modifying the URL constant
// but we can test the error handling by checking that it fails gracefully
release, ok := CheckLatestRelease()
// The result depends on whether the real GitHub API is accessible
_ = release
_ = ok
}
func TestCopyFile(t *testing.T) {
// Create a temp source file
srcContent := []byte("test content")
srcFile, err := os.CreateTemp("", "test-*")
assert.NoError(t, err)
defer os.Remove(srcFile.Name())
_, err = srcFile.Write(srcContent)
assert.NoError(t, err)
srcFile.Close()
// Create destination path
dstPath := srcFile.Name() + ".copy"
defer os.Remove(dstPath)
// Copy the file
err = copyFile(srcFile.Name(), dstPath)
assert.NoError(t, err)
// Verify the content
content, err := os.ReadFile(dstPath)
assert.NoError(t, err)
assert.Equal(t, srcContent, content)
}
func TestReplaceBinary(t *testing.T) {
// Create a temp "new" binary
newContent := []byte("new binary content")
newFile, err := os.CreateTemp("", "new-binary-*")
assert.NoError(t, err)
defer os.Remove(newFile.Name())
_, err = newFile.Write(newContent)
assert.NoError(t, err)
newFile.Close()
// Create a temp "current" binary
currentFile, err := os.CreateTemp("", "current-binary-*")
assert.NoError(t, err)
currentPath := currentFile.Name()
defer os.Remove(currentPath)
currentFile.Close()
// Replace the binary
err = replaceBinary(newFile.Name(), currentPath)
assert.NoError(t, err)
// Verify the content was replaced
content, err := os.ReadFile(currentPath)
assert.NoError(t, err)
assert.Equal(t, newContent, content)
}
func TestUpdatePackageNoBinary(t *testing.T) {
InitLogger(false)
// This test verifies UpdatePackage handles the case where no binary is found
// by testing with a mock that returns empty assets
// Note: This would need proper mocking of httpClient to test fully
}
+13 -12
View File
@@ -13,6 +13,7 @@ func CalculateSemver(
initialSemver SemVer,
respectExisting bool,
strictMode bool,
tagPrefixes []string,
) SemVer {
semver := initialSemver
@@ -22,10 +23,10 @@ func CalculateSemver(
for _, tagHash := range tags {
if commit.Hash == tagHash.Hash {
Debug("Found existing tag", map[string]interface{}{
"tag": tagHash.Name,
"tag": tagHash.Name,
"commit": strings.TrimSuffix(commit.Message, "\n"),
})
semver = ParseExistingSemver(tagHash.Name, semver)
semver = ParseExistingSemver(tagHash.Name, semver, tagPrefixes)
continue
}
}
@@ -35,7 +36,7 @@ func CalculateSemver(
if !strictMode {
semver.Patch++
Debug("Incrementing patch (DEFAULT)", map[string]interface{}{
"commit": strings.TrimSuffix(commit.Message, "\n"),
"commit": strings.TrimSuffix(commit.Message, "\n"),
"semver": FormatSemver(semver),
})
}
@@ -55,44 +56,44 @@ func CalculateSemver(
semver.EnableReleaseCandidate = false
semver.Release = 0
Debug("Incrementing major (WORDING)", map[string]interface{}{
"commit": strings.TrimSuffix(commit.Message, "\n"),
"commit": strings.TrimSuffix(commit.Message, "\n"),
"semver": FormatSemver(semver),
})
continue
}
if matchMinor {
semver.Minor++
semver.Patch = 1
semver.EnableReleaseCandidate = false
semver.Release = 0
Debug("Incrementing minor (WORDING)", map[string]interface{}{
"commit": strings.TrimSuffix(commit.Message, "\n"),
"commit": strings.TrimSuffix(commit.Message, "\n"),
"semver": FormatSemver(semver),
})
continue
}
if matchReleaseCandidate {
semver.Release++
semver.Patch = 1
semver.EnableReleaseCandidate = true
Debug("Incrementing release candidate (WORDING)", map[string]interface{}{
"commit": strings.TrimSuffix(commit.Message, "\n"),
"commit": strings.TrimSuffix(commit.Message, "\n"),
"semver": FormatSemver(semver),
})
continue
}
if matchPatch {
semver.Patch++
Debug("Incrementing patch (WORDING)", map[string]interface{}{
"commit": strings.TrimSuffix(commit.Message, "\n"),
"commit": strings.TrimSuffix(commit.Message, "\n"),
"semver": FormatSemver(semver),
})
continue
}
}
return semver
}
}
+52 -11
View File
@@ -19,7 +19,7 @@ func TestCalculateSemver(t *testing.T) {
// More sophisticated mock implementation for testing
for _, h := range haystack {
// Check for substring match to better simulate fuzzy search
if h == needle || (len(h) >= 3 && len(needle) >= 3 &&
if h == needle || (len(h) >= 3 && len(needle) >= 3 &&
(h[:3] == needle[:3] || h[len(h)-3:] == needle[len(needle)-3:])) {
return []string{h}
}
@@ -29,7 +29,7 @@ func TestCalculateSemver(t *testing.T) {
// Test data
now := time.Now()
// Common wording and blacklist for all tests
wording := Wording{
Patch: []string{"update", "fix", "initial"},
@@ -49,6 +49,7 @@ func TestCalculateSemver(t *testing.T) {
initialSemver SemVer
respectExisting bool
strictMode bool
tagPrefixes []string
want SemVer
}{
{
@@ -76,11 +77,12 @@ func TestCalculateSemver(t *testing.T) {
initialSemver: SemVer{},
respectExisting: true,
strictMode: false,
tagPrefixes: []string{},
want: SemVer{
Major: 2,
Minor: 0,
Patch: 1, // Initial tag 2.0.0 + one patch increment
Release: 1,
Major: 2,
Minor: 0,
Patch: 1, // Initial tag 2.0.0 + one patch increment
Release: 1,
EnableReleaseCandidate: true,
},
},
@@ -109,11 +111,12 @@ func TestCalculateSemver(t *testing.T) {
initialSemver: SemVer{},
respectExisting: true,
strictMode: true,
tagPrefixes: []string{},
want: SemVer{
Major: 2,
Minor: 0,
Patch: 1, // Initial tag 2.0.0 + patch from "update" keyword
Release: 1,
Major: 2,
Minor: 0,
Patch: 1, // Initial tag 2.0.0 + patch from "update" keyword
Release: 1,
EnableReleaseCandidate: true,
},
},
@@ -142,6 +145,7 @@ func TestCalculateSemver(t *testing.T) {
initialSemver: SemVer{},
respectExisting: false,
strictMode: false,
tagPrefixes: []string{},
want: SemVer{
Major: 0,
Minor: 1,
@@ -173,6 +177,7 @@ func TestCalculateSemver(t *testing.T) {
initialSemver: SemVer{Major: 1},
respectExisting: false,
strictMode: true,
tagPrefixes: []string{},
want: SemVer{
Major: 1,
Minor: 1,
@@ -199,6 +204,7 @@ func TestCalculateSemver(t *testing.T) {
initialSemver: SemVer{},
respectExisting: false,
strictMode: false,
tagPrefixes: []string{},
want: SemVer{
Major: 0,
Minor: 0,
@@ -225,6 +231,7 @@ func TestCalculateSemver(t *testing.T) {
initialSemver: SemVer{},
respectExisting: false,
strictMode: false,
tagPrefixes: []string{},
want: SemVer{
Major: 0,
Minor: 0,
@@ -233,6 +240,39 @@ func TestCalculateSemver(t *testing.T) {
EnableReleaseCandidate: true,
},
},
{
name: "With prefixed tags should not be RC",
commits: []CommitDetails{
{
Hash: "commit1",
Message: "tagged commit",
Timestamp: now.Add(-3 * time.Hour),
},
{
Hash: "commit2",
Message: "another commit",
Timestamp: now.Add(-2 * time.Hour),
},
},
tags: []TagDetails{
{
Name: "app-0.0.16",
Hash: "commit1",
},
},
wording: wording,
blacklist: blacklist,
initialSemver: SemVer{},
respectExisting: true,
strictMode: true, // Use strict mode for predictable results
tagPrefixes: []string{"app-", "infra-"},
want: SemVer{
Major: 0,
Minor: 0,
Patch: 16, // From tag, no additional increments in strict mode
EnableReleaseCandidate: false,
},
},
}
for _, tt := range tests {
@@ -245,6 +285,7 @@ func TestCalculateSemver(t *testing.T) {
tt.initialSemver,
tt.respectExisting,
tt.strictMode,
tt.tagPrefixes,
)
assert.Equal(t, tt.want.Major, got.Major, "Major version mismatch")
@@ -254,4 +295,4 @@ func TestCalculateSemver(t *testing.T) {
assert.Equal(t, tt.want.EnableReleaseCandidate, got.EnableReleaseCandidate, "EnableReleaseCandidate mismatch")
})
}
}
}
+72 -22
View File
@@ -51,52 +51,102 @@ func FormatSemver(semver SemVer) string {
var extractNumber = regexp.MustCompile("[0-9]+")
// StripTagPrefix removes configured prefixes from a tag name
// The "v" prefix is always stripped by default (e.g., v1.2.3 -> 1.2.3)
func StripTagPrefix(tagName string, prefixes []string) string {
result := tagName
// Always strip "v" prefix by default
if strings.HasPrefix(result, "v") && len(result) > 1 {
// Only strip if followed by a digit (to avoid stripping "version-1.0.0")
if result[1] >= '0' && result[1] <= '9' {
result = result[1:]
Debug("Stripped default 'v' prefix from tag", map[string]interface{}{
"original": tagName,
"result": result,
})
}
}
// Then strip any user-configured prefixes
for _, prefix := range prefixes {
if strings.HasPrefix(result, prefix) {
result = strings.TrimPrefix(result, prefix)
Debug("Stripped prefix from tag", map[string]interface{}{
"original": tagName,
"prefix": prefix,
"result": result,
})
break // Only strip one prefix
}
}
return result
}
// ParseExistingSemver parses a semantic version from a tag name
func ParseExistingSemver(tagName string, currentSemver SemVer) SemVer {
func ParseExistingSemver(tagName string, currentSemver SemVer, prefixes []string) SemVer {
Debug("Parsing existing semver", map[string]interface{}{"tag": tagName})
tagNameParts := strings.Split(tagName, ".")
// Strip configured prefixes before parsing
cleanTagName := StripTagPrefix(tagName, prefixes)
// Check for release candidate pattern (-rc.X) before splitting
isReleaseCandidate := false
rcVersion := 0
if idx := strings.Index(cleanTagName, "-rc."); idx != -1 {
isReleaseCandidate = true
rcPart := cleanTagName[idx+4:] // Get everything after "-rc."
rcMatches := extractNumber.FindAllString(rcPart, 1)
if len(rcMatches) > 0 {
rcVersion, _ = strconv.Atoi(rcMatches[0])
}
// Remove the RC suffix for version parsing
cleanTagName = cleanTagName[:idx]
Debug("Detected release candidate", map[string]interface{}{
"rc_version": rcVersion,
"clean_tag_name": cleanTagName,
})
}
tagNameParts := strings.Split(cleanTagName, ".")
if len(tagNameParts) < 3 {
Debug("Unable to parse incompatible semver (non x.y.z)", map[string]interface{}{"tag": tagName})
return currentSemver
}
semanticVersion := SemVer{}
// Extract major version
majorMatches := extractNumber.FindAllString(tagNameParts[0], -1)
if len(majorMatches) > 0 {
semanticVersion.Major, _ = strconv.Atoi(majorMatches[0])
}
// Extract minor version
minorMatches := extractNumber.FindAllString(tagNameParts[1], -1)
if len(minorMatches) > 0 {
semanticVersion.Minor, _ = strconv.Atoi(minorMatches[0])
}
// Extract patch version
patchMatches := extractNumber.FindAllString(tagNameParts[2], -1)
if len(patchMatches) > 0 {
semanticVersion.Patch, _ = strconv.Atoi(patchMatches[0])
}
// Extract release candidate version if present
if len(tagNameParts) > 3 {
releaseMatches := extractNumber.FindAllString(tagNameParts[3], -1)
if len(releaseMatches) > 0 {
semanticVersion.Release, _ = strconv.Atoi(releaseMatches[0])
semanticVersion.EnableReleaseCandidate = true
}
// Set release candidate if detected
if isReleaseCandidate {
semanticVersion.Release = rcVersion
semanticVersion.EnableReleaseCandidate = true
}
return semanticVersion
}
// CheckMatches checks if any of the targets match the content
func CheckMatches(content []string, targets []string, blacklist []string) bool {
contentStr := strings.Join(content, " ")
// First check if any target matches
hasMatch := false
for _, tgt := range targets {
@@ -104,8 +154,8 @@ func CheckMatches(content []string, targets []string, blacklist []string) bool {
if len(matches) > 0 {
hasMatch = true
Debug("Found match", map[string]interface{}{
"target": tgt,
"match": strings.Join(matches, ","),
"target": tgt,
"match": strings.Join(matches, ","),
"content": contentStr,
})
break
@@ -117,14 +167,14 @@ func CheckMatches(content []string, targets []string, blacklist []string) bool {
for _, blacklistTerm := range blacklist {
if strings.Contains(strings.ToLower(contentStr), strings.ToLower(blacklistTerm)) {
Debug("Blacklisted term detected, ignoring commit", map[string]interface{}{
"content": contentStr,
"content": contentStr,
"blacklist_term": blacklistTerm,
})
return false
}
}
}
return hasMatch
}
@@ -132,4 +182,4 @@ func CheckMatches(content []string, targets []string, blacklist []string) bool {
var FuzzyFind = func(needle string, haystack []string) []string {
// This will be replaced with the actual implementation in main.go
return nil
}
}
+64 -11
View File
@@ -58,15 +58,17 @@ func TestParseExistingSemver(t *testing.T) {
InitLogger(false)
tests := []struct {
name string
tagName string
name string
tagName string
currentSemver SemVer
want SemVer
prefixes []string
want SemVer
}{
{
name: "Standard semver",
tagName: "1.2.3",
name: "Standard semver",
tagName: "1.2.3",
currentSemver: SemVer{},
prefixes: []string{},
want: SemVer{
Major: 1,
Minor: 2,
@@ -74,9 +76,10 @@ func TestParseExistingSemver(t *testing.T) {
},
},
{
name: "With v prefix",
tagName: "v2.3.4",
name: "With v prefix configured",
tagName: "v2.3.4",
currentSemver: SemVer{},
prefixes: []string{"v"},
want: SemVer{
Major: 2,
Minor: 3,
@@ -84,9 +87,32 @@ func TestParseExistingSemver(t *testing.T) {
},
},
{
name: "With release candidate",
tagName: "3.4.5-rc.2",
name: "With app- prefix configured",
tagName: "app-1.2.3",
currentSemver: SemVer{},
prefixes: []string{"app-", "infra-"},
want: SemVer{
Major: 1,
Minor: 2,
Patch: 3,
},
},
{
name: "With prefix but not in config - should still parse numbers",
tagName: "v2.3.4",
currentSemver: SemVer{},
prefixes: []string{}, // v not in prefixes
want: SemVer{
Major: 2,
Minor: 3,
Patch: 4,
},
},
{
name: "With release candidate",
tagName: "3.4.5-rc.2",
currentSemver: SemVer{},
prefixes: []string{},
want: SemVer{
Major: 3,
Minor: 4,
@@ -95,6 +121,31 @@ func TestParseExistingSemver(t *testing.T) {
EnableReleaseCandidate: true,
},
},
{
name: "With prefix and release candidate",
tagName: "app-1.0.0-rc.3",
currentSemver: SemVer{},
prefixes: []string{"app-"},
want: SemVer{
Major: 1,
Minor: 0,
Patch: 0,
Release: 3,
EnableReleaseCandidate: true,
},
},
{
name: "Prefixed tag without RC should NOT be RC",
tagName: "app-0.0.16",
currentSemver: SemVer{},
prefixes: []string{"app-"},
want: SemVer{
Major: 0,
Minor: 0,
Patch: 16,
EnableReleaseCandidate: false,
},
},
{
name: "Invalid format",
tagName: "not-a-semver",
@@ -103,6 +154,7 @@ func TestParseExistingSemver(t *testing.T) {
Minor: 1,
Patch: 1,
},
prefixes: []string{},
want: SemVer{
Major: 1,
Minor: 1,
@@ -117,6 +169,7 @@ func TestParseExistingSemver(t *testing.T) {
Minor: 5,
Patch: 5,
},
prefixes: []string{},
want: SemVer{
Major: 5,
Minor: 5,
@@ -127,7 +180,7 @@ func TestParseExistingSemver(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := ParseExistingSemver(tt.tagName, tt.currentSemver)
got := ParseExistingSemver(tt.tagName, tt.currentSemver, tt.prefixes)
assert.Equal(t, tt.want.Major, got.Major, "Major version mismatch")
assert.Equal(t, tt.want.Minor, got.Minor, "Minor version mismatch")
assert.Equal(t, tt.want.Patch, got.Patch, "Patch version mismatch")
@@ -196,4 +249,4 @@ func TestCheckMatches(t *testing.T) {
assert.Equal(t, tt.want, got)
})
}
}
}
+5
View File
@@ -5,6 +5,11 @@ force:
existing: true
strict: false
commit: 960207e4677476ad31a9f389f74eaf9f33d49613
# tag_prefixes: (optional) prefixes to strip from existing tags
# Note: "v" prefix is always stripped automatically
# tag_prefixes:
# - "app-"
# - "service-"
wording:
patch:
- update
+4
View File
@@ -8,6 +8,10 @@ blacklist:
- "Merge pull request"
- "feature/"
- "feature:"
tag_prefixes:
# Note: "v" prefix is stripped automatically, no need to include it here
- "app-"
- "infra-"
wording:
patch:
- update
+1
View File
@@ -0,0 +1 @@
semver-generator.raczylo.com
+500
View File
@@ -0,0 +1,500 @@
<!doctype html>
<html lang="en" class="scroll-smooth">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>semver-generator - Automatic Semantic Version Generator</title>
<meta
name="description"
content="Automatic semantic version generator based on git commit messages. Use as CLI, GitHub Action, or Docker container."
/>
<script src="https://cdn.tailwindcss.com"></script>
<script>
tailwind.config = {
darkMode: 'class'
}
</script>
<link
rel="stylesheet"
href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css"
/>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap"
rel="stylesheet"
/>
<style>
body { font-family: "Inter", sans-serif; }
code, pre { font-family: "JetBrains Mono", monospace; }
.theme-transition {
transition: background-color 0.3s ease, color 0.3s ease, border-color 0.3s ease;
}
@keyframes fadeInUp {
from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes float {
0%, 100% { transform: translateY(0px); }
50% { transform: translateY(-10px); }
}
.animate-fade-in-up { animation: fadeInUp 0.6s ease-out; }
.animate-float { animation: float 3s ease-in-out infinite; }
.glass {
background: rgba(255, 255, 255, 0.7);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
border: 1px solid rgba(255, 255, 255, 0.2);
}
.dark .glass {
background: rgba(17, 24, 39, 0.7);
border: 1px solid rgba(255, 255, 255, 0.1);
}
.gradient-text {
background: linear-gradient(135deg, #10b981 0%, #3b82f6 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.dark .gradient-text {
background: linear-gradient(135deg, #34d399 0%, #60a5fa 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.shadow-modern { box-shadow: 0 10px 40px -10px rgba(0, 0, 0, 0.1); }
.dark .shadow-modern { box-shadow: 0 10px 40px -10px rgba(0, 0, 0, 0.4); }
html { scroll-behavior: smooth; }
</style>
<script>
if (localStorage.theme === "dark" || (!("theme" in localStorage) && window.matchMedia("(prefers-color-scheme: dark)").matches)) {
document.documentElement.classList.add("dark");
} else {
document.documentElement.classList.remove("dark");
}
</script>
</head>
<body class="bg-white dark:bg-gray-900 text-gray-900 dark:text-gray-100 theme-transition">
<!-- Navigation -->
<nav class="fixed w-full glass shadow-modern z-50 theme-transition">
<div class="max-w-6xl mx-auto px-4 sm:px-6">
<div class="flex justify-between h-16 items-center">
<a href="#" class="flex items-center hover:opacity-80 transition-opacity duration-300 gap-2">
<i class="fas fa-code-branch text-2xl gradient-text"></i>
<span class="text-xl font-bold gradient-text">semver-generator</span>
</a>
<div class="hidden md:flex space-x-6">
<a href="#features" class="text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-gray-100 font-medium">Features</a>
<a href="#installation" class="text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-gray-100 font-medium">Installation</a>
<a href="#usage" class="text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-gray-100 font-medium">Usage</a>
<a href="#configuration" class="text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-gray-100 font-medium">Configuration</a>
</div>
<div class="flex items-center space-x-4">
<button id="theme-toggle" class="text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-gray-100 p-2 min-w-[44px] min-h-[44px] flex items-center justify-center" aria-label="Toggle theme">
<i class="fas fa-moon dark:hidden text-xl"></i>
<i class="fas fa-sun hidden dark:inline text-xl"></i>
</button>
<a href="https://github.com/lukaszraczylo/semver-generator" target="_blank" class="text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-gray-100 p-2 min-w-[44px] min-h-[44px] flex items-center justify-center" aria-label="View on GitHub">
<i class="fab fa-github text-xl"></i>
</a>
<button id="mobile-menu-toggle" class="md:hidden text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-gray-100 p-2 min-w-[44px] min-h-[44px] flex items-center justify-center" aria-label="Toggle menu">
<i class="fas fa-bars text-xl" id="menu-open-icon"></i>
<i class="fas fa-times text-xl hidden" id="menu-close-icon"></i>
</button>
</div>
</div>
</div>
<div id="mobile-menu" class="hidden md:hidden border-t border-gray-200 dark:border-gray-700">
<div class="px-4 py-3 space-y-1 bg-white dark:bg-gray-800">
<a href="#features" class="block px-3 py-3 text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-gray-100 hover:bg-gray-50 dark:hover:bg-gray-700 rounded font-medium">Features</a>
<a href="#installation" class="block px-3 py-3 text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-gray-100 hover:bg-gray-50 dark:hover:bg-gray-700 rounded font-medium">Installation</a>
<a href="#usage" class="block px-3 py-3 text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-gray-100 hover:bg-gray-50 dark:hover:bg-gray-700 rounded font-medium">Usage</a>
<a href="#configuration" class="block px-3 py-3 text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-gray-100 hover:bg-gray-50 dark:hover:bg-gray-700 rounded font-medium">Configuration</a>
</div>
</div>
</nav>
<!-- Hero Section -->
<section class="relative pt-24 sm:pt-32 pb-12 sm:pb-20 overflow-hidden">
<div class="absolute inset-0 bg-gradient-to-br from-emerald-50 via-cyan-50 to-blue-50 dark:from-gray-900 dark:via-emerald-900/20 dark:to-blue-900/20 theme-transition"></div>
<div class="absolute top-0 -left-4 w-72 h-72 bg-emerald-300 dark:bg-emerald-500 rounded-full mix-blend-multiply dark:mix-blend-soft-light filter blur-xl opacity-20 animate-float"></div>
<div class="absolute top-0 -right-4 w-72 h-72 bg-cyan-300 dark:bg-cyan-500 rounded-full mix-blend-multiply dark:mix-blend-soft-light filter blur-xl opacity-20 animate-float" style="animation-delay: 1s;"></div>
<div class="absolute -bottom-8 left-20 w-72 h-72 bg-blue-300 dark:bg-blue-500 rounded-full mix-blend-multiply dark:mix-blend-soft-light filter blur-xl opacity-20 animate-float" style="animation-delay: 2s;"></div>
<div class="relative max-w-6xl mx-auto px-4 sm:px-6">
<div class="text-center">
<div class="mb-8 sm:mb-10 flex justify-center animate-fade-in-up">
<div class="text-8xl sm:text-9xl animate-float">
<i class="fas fa-code-branch gradient-text"></i>
</div>
</div>
<h1 class="text-3xl sm:text-4xl md:text-5xl lg:text-6xl font-bold text-gray-900 dark:text-gray-100 mb-4 sm:mb-6 leading-tight animate-fade-in-up" style="animation-delay: 0.1s;">
Semantic Version<br /><span class="gradient-text">Generator</span>
</h1>
<p class="text-base sm:text-lg md:text-xl text-gray-600 dark:text-gray-300 mb-8 sm:mb-10 max-w-2xl mx-auto leading-relaxed px-4 animate-fade-in-up" style="animation-delay: 0.2s;">
Automatic semantic versioning based on git commit messages. Use as a CLI tool, GitHub Action, or Docker container.
</p>
<div class="flex flex-col sm:flex-row gap-3 sm:gap-4 justify-center mb-8 sm:mb-12 px-4 animate-fade-in-up" style="animation-delay: 0.3s;">
<a href="#installation" class="group relative bg-gradient-to-r from-emerald-500 to-blue-600 hover:from-emerald-600 hover:to-blue-700 text-white px-8 py-3 rounded-lg font-medium transition-all duration-300 min-h-[48px] flex items-center justify-center shadow-lg hover:shadow-xl hover:scale-105">
<span class="relative z-10">Get Started</span>
</a>
<a href="https://github.com/lukaszraczylo/semver-generator" class="group glass hover:shadow-lg text-gray-900 dark:text-gray-100 px-8 py-3 rounded-lg font-medium transition-all duration-300 min-h-[48px] flex items-center justify-center hover:scale-105">
<i class="fab fa-github mr-2"></i>View on GitHub
</a>
</div>
<div class="flex flex-wrap justify-center gap-2 sm:gap-4 text-sm px-4">
<img src="https://img.shields.io/github/v/release/lukaszraczylo/semver-generator" alt="Version" class="h-5" />
<img src="https://img.shields.io/github/license/lukaszraczylo/semver-generator" alt="License" class="h-5" />
<img src="https://goreportcard.com/badge/github.com/lukaszraczylo/semver-generator" alt="Go Report" class="h-5" />
</div>
<div class="mt-12 sm:mt-16 max-w-3xl mx-auto px-4 animate-fade-in-up" style="animation-delay: 0.4s;">
<div class="relative group">
<div class="absolute -inset-1 bg-gradient-to-r from-emerald-500 to-blue-600 rounded-xl blur opacity-25 group-hover:opacity-50 transition duration-500"></div>
<div class="relative bg-gray-900 rounded-xl p-6 text-left">
<div class="flex items-center gap-2 mb-4">
<div class="w-3 h-3 rounded-full bg-red-500"></div>
<div class="w-3 h-3 rounded-full bg-yellow-500"></div>
<div class="w-3 h-3 rounded-full bg-green-500"></div>
<span class="ml-2 text-gray-400 text-sm">terminal</span>
</div>
<pre class="text-gray-100 text-sm sm:text-base overflow-x-auto"><code><span class="text-gray-400">$</span> semver-generator generate -l
<span class="text-emerald-400">SEMVER</span> 1.5.2
<span class="text-gray-400">$</span> semver-generator generate -r https://github.com/user/repo
<span class="text-emerald-400">SEMVER</span> 2.3.0</code></pre>
</div>
</div>
</div>
</div>
</div>
</section>
<!-- Features Section -->
<section id="features" class="py-12 sm:py-16 md:py-20 bg-white dark:bg-gray-900 theme-transition">
<div class="max-w-6xl mx-auto px-4 sm:px-6">
<div class="text-center mb-8 sm:mb-12">
<h2 class="text-2xl sm:text-3xl md:text-4xl font-bold text-gray-900 dark:text-gray-100 mb-3 sm:mb-4">Features</h2>
<p class="text-base sm:text-lg text-gray-600 dark:text-gray-300 px-4">Automatic versioning that just works</p>
</div>
<div class="grid sm:grid-cols-2 lg:grid-cols-3 gap-4">
<div class="glass p-5 rounded-xl group hover:shadow-lg transition-all duration-300">
<div class="flex items-start gap-4">
<div class="w-12 h-12 rounded-xl bg-gradient-to-br from-emerald-500 to-emerald-600 flex items-center justify-center flex-shrink-0 group-hover:scale-110 transition-transform duration-300">
<i class="fas fa-magic text-white"></i>
</div>
<div>
<h3 class="font-semibold text-gray-900 dark:text-gray-100 mb-1">Automatic Versioning</h3>
<p class="text-sm text-gray-600 dark:text-gray-400">Calculates version based on commit message keywords</p>
</div>
</div>
</div>
<div class="glass p-5 rounded-xl group hover:shadow-lg transition-all duration-300">
<div class="flex items-start gap-4">
<div class="w-12 h-12 rounded-xl bg-gradient-to-br from-blue-500 to-blue-600 flex items-center justify-center flex-shrink-0 group-hover:scale-110 transition-transform duration-300">
<i class="fab fa-github text-white"></i>
</div>
<div>
<h3 class="font-semibold text-gray-900 dark:text-gray-100 mb-1">GitHub Action</h3>
<p class="text-sm text-gray-600 dark:text-gray-400">Use directly in your CI/CD workflows</p>
</div>
</div>
</div>
<div class="glass p-5 rounded-xl group hover:shadow-lg transition-all duration-300">
<div class="flex items-start gap-4">
<div class="w-12 h-12 rounded-xl bg-gradient-to-br from-cyan-500 to-cyan-600 flex items-center justify-center flex-shrink-0 group-hover:scale-110 transition-transform duration-300">
<i class="fab fa-docker text-white"></i>
</div>
<div>
<h3 class="font-semibold text-gray-900 dark:text-gray-100 mb-1">Docker Ready</h3>
<p class="text-sm text-gray-600 dark:text-gray-400">Multi-arch Docker images for any environment</p>
</div>
</div>
</div>
<div class="glass p-5 rounded-xl group hover:shadow-lg transition-all duration-300">
<div class="flex items-start gap-4">
<div class="w-12 h-12 rounded-xl bg-gradient-to-br from-purple-500 to-purple-600 flex items-center justify-center flex-shrink-0 group-hover:scale-110 transition-transform duration-300">
<i class="fas fa-cog text-white"></i>
</div>
<div>
<h3 class="font-semibold text-gray-900 dark:text-gray-100 mb-1">Configurable Keywords</h3>
<p class="text-sm text-gray-600 dark:text-gray-400">Define your own trigger words for version bumps</p>
</div>
</div>
</div>
<div class="glass p-5 rounded-xl group hover:shadow-lg transition-all duration-300">
<div class="flex items-start gap-4">
<div class="w-12 h-12 rounded-xl bg-gradient-to-br from-orange-500 to-orange-600 flex items-center justify-center flex-shrink-0 group-hover:scale-110 transition-transform duration-300">
<i class="fas fa-tag text-white"></i>
</div>
<div>
<h3 class="font-semibold text-gray-900 dark:text-gray-100 mb-1">Respects Tags</h3>
<p class="text-sm text-gray-600 dark:text-gray-400">Optional mode to respect existing git tags</p>
</div>
</div>
</div>
<div class="glass p-5 rounded-xl group hover:shadow-lg transition-all duration-300">
<div class="flex items-start gap-4">
<div class="w-12 h-12 rounded-xl bg-gradient-to-br from-pink-500 to-pink-600 flex items-center justify-center flex-shrink-0 group-hover:scale-110 transition-transform duration-300">
<i class="fas fa-flask text-white"></i>
</div>
<div>
<h3 class="font-semibold text-gray-900 dark:text-gray-100 mb-1">Release Candidates</h3>
<p class="text-sm text-gray-600 dark:text-gray-400">Support for RC versions like 1.3.37-rc.1</p>
</div>
</div>
</div>
</div>
</div>
</section>
<!-- Installation Section -->
<section id="installation" class="py-12 sm:py-16 md:py-20 bg-gray-50 dark:bg-gray-800 theme-transition">
<div class="max-w-6xl mx-auto px-4 sm:px-6">
<div class="text-center mb-8 sm:mb-12">
<h2 class="text-2xl sm:text-3xl md:text-4xl font-bold text-gray-900 dark:text-gray-100 mb-3 sm:mb-4">Installation</h2>
<p class="text-base sm:text-lg text-gray-600 dark:text-gray-300 px-4">Get started in seconds</p>
</div>
<div class="max-w-3xl mx-auto space-y-6">
<div class="glass p-6 rounded-xl">
<h3 class="font-semibold text-gray-900 dark:text-gray-100 mb-3 flex items-center">
<i class="fas fa-beer mr-2 text-amber-500"></i>
Homebrew (macOS)
</h3>
<pre class="bg-gray-900 text-gray-100 p-4 rounded-lg overflow-x-auto"><code>brew install --cask lukaszraczylo/taps/semver-generator</code></pre>
</div>
<div class="glass p-6 rounded-xl">
<h3 class="font-semibold text-gray-900 dark:text-gray-100 mb-3 flex items-center">
<i class="fas fa-download mr-2 text-emerald-500"></i>
Manual Download
</h3>
<p class="text-gray-600 dark:text-gray-400 mb-3">Download from the <a href="https://github.com/lukaszraczylo/semver-generator/releases/latest" class="text-emerald-600 dark:text-emerald-400 hover:underline">releases page</a>.</p>
<p class="text-sm text-gray-500 dark:text-gray-400">Supported: Darwin ARM64/AMD64, Linux ARM64/AMD64, Windows AMD64</p>
</div>
<div class="glass p-6 rounded-xl">
<h3 class="font-semibold text-gray-900 dark:text-gray-100 mb-3 flex items-center">
<i class="fab fa-docker mr-2 text-blue-500"></i>
Docker
</h3>
<pre class="bg-gray-900 text-gray-100 p-4 rounded-lg overflow-x-auto"><code>docker pull ghcr.io/lukaszraczylo/semver-generator:latest</code></pre>
</div>
</div>
</div>
</section>
<!-- Usage Section -->
<section id="usage" class="py-12 sm:py-16 md:py-20 bg-white dark:bg-gray-900 theme-transition">
<div class="max-w-6xl mx-auto px-4 sm:px-6">
<div class="text-center mb-8 sm:mb-12">
<h2 class="text-2xl sm:text-3xl md:text-4xl font-bold text-gray-900 dark:text-gray-100 mb-3 sm:mb-4">Usage</h2>
<p class="text-base sm:text-lg text-gray-600 dark:text-gray-300 px-4">Multiple ways to use semver-generator</p>
</div>
<div class="max-w-4xl mx-auto space-y-8">
<div class="glass p-6 rounded-xl">
<h3 class="font-semibold text-gray-900 dark:text-gray-100 mb-4 flex items-center text-xl">
<i class="fas fa-terminal mr-2 text-emerald-500"></i>
CLI Usage
</h3>
<pre class="bg-gray-900 text-gray-100 p-4 rounded-lg overflow-x-auto mb-4"><code><span class="text-gray-400"># Local repository</span>
semver-generator generate -l
<span class="text-gray-400"># Remote repository</span>
semver-generator generate -r https://github.com/user/repo
<span class="text-gray-400"># With custom config</span>
semver-generator generate -l -c semver.yaml
<span class="text-gray-400"># Strict mode (only exact matches)</span>
semver-generator generate -l -s
<span class="text-gray-400"># Respect existing tags</span>
semver-generator generate -l -e
<span class="text-gray-400"># Self-update to latest version (no auth required)</span>
semver-generator -u</code></pre>
<div class="grid sm:grid-cols-2 gap-4 text-sm">
<div>
<h4 class="font-medium text-gray-900 dark:text-gray-100 mb-2">Flags</h4>
<ul class="space-y-1 text-gray-600 dark:text-gray-400">
<li><code class="text-emerald-600 dark:text-emerald-400">-l, --local</code> Use local repository</li>
<li><code class="text-emerald-600 dark:text-emerald-400">-r, --repository</code> Remote repository URL</li>
<li><code class="text-emerald-600 dark:text-emerald-400">-c, --config</code> Path to config file</li>
<li><code class="text-emerald-600 dark:text-emerald-400">-s, --strict</code> Strict matching</li>
<li><code class="text-emerald-600 dark:text-emerald-400">-e, --existing</code> Respect existing tags</li>
<li><code class="text-emerald-600 dark:text-emerald-400">-d, --debug</code> Enable debug mode</li>
<li><code class="text-emerald-600 dark:text-emerald-400">-u, --update</code> Self-update to latest version</li>
<li><code class="text-emerald-600 dark:text-emerald-400">-v, --version</code> Display current version</li>
</ul>
</div>
</div>
</div>
<div class="glass p-6 rounded-xl">
<h3 class="font-semibold text-gray-900 dark:text-gray-100 mb-4 flex items-center text-xl">
<i class="fab fa-github mr-2 text-blue-500"></i>
GitHub Action
</h3>
<pre class="bg-gray-900 text-gray-100 p-4 rounded-lg overflow-x-auto text-sm"><code>jobs:
version:
runs-on: ubuntu-latest
outputs:
version: ${{ steps.semver.outputs.semantic_version }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: '0'
- name: Generate version
id: semver
uses: lukaszraczylo/semver-generator@v1
with:
config_file: semver.yaml
repository_local: true
- name: Use version
run: echo "Version: ${{ steps.semver.outputs.semantic_version }}"</code></pre>
</div>
</div>
</div>
</section>
<!-- How It Works Section -->
<section class="py-12 sm:py-16 md:py-20 bg-gray-50 dark:bg-gray-800 theme-transition">
<div class="max-w-6xl mx-auto px-4 sm:px-6">
<div class="text-center mb-8 sm:mb-12">
<h2 class="text-2xl sm:text-3xl md:text-4xl font-bold text-gray-900 dark:text-gray-100 mb-3 sm:mb-4">How It Works</h2>
<p class="text-base sm:text-lg text-gray-600 dark:text-gray-300 px-4">Version calculation based on commit messages</p>
</div>
<div class="max-w-3xl mx-auto">
<div class="glass p-6 rounded-xl">
<pre class="text-gray-700 dark:text-gray-300 text-sm overflow-x-auto"><code>0.0.1 - PATCH - starting commit
0.0.2 - PATCH - another commit
0.0.4 - PATCH - commit with 'Update' => DOUBLE increment PATCH
0.1.0 - MINOR - commit with 'Change' => increment MINOR, reset PATCH
0.1.1 - PATCH - additional commit
1.0.1 - MAJOR - commit with 'BREAKING' => INCREMENT MAJOR, reset MINOR
1.0.2 - PATCH - another commit</code></pre>
</div>
<div class="mt-6 grid sm:grid-cols-3 gap-4 text-center">
<div class="glass p-4 rounded-xl">
<div class="text-3xl font-bold text-red-500 mb-2">MAJOR</div>
<p class="text-sm text-gray-600 dark:text-gray-400">Breaking changes</p>
<p class="text-xs text-gray-500 dark:text-gray-500 mt-1">Keywords: "breaking"</p>
</div>
<div class="glass p-4 rounded-xl">
<div class="text-3xl font-bold text-yellow-500 mb-2">MINOR</div>
<p class="text-sm text-gray-600 dark:text-gray-400">New features</p>
<p class="text-xs text-gray-500 dark:text-gray-500 mt-1">Keywords: "change", "improve"</p>
</div>
<div class="glass p-4 rounded-xl">
<div class="text-3xl font-bold text-emerald-500 mb-2">PATCH</div>
<p class="text-sm text-gray-600 dark:text-gray-400">Bug fixes</p>
<p class="text-xs text-gray-500 dark:text-gray-500 mt-1">Keywords: "update", "initial"</p>
</div>
</div>
</div>
</div>
</section>
<!-- Configuration Section -->
<section id="configuration" class="py-12 sm:py-16 md:py-20 bg-white dark:bg-gray-900 theme-transition">
<div class="max-w-6xl mx-auto px-4 sm:px-6">
<div class="text-center mb-8 sm:mb-12">
<h2 class="text-2xl sm:text-3xl md:text-4xl font-bold text-gray-900 dark:text-gray-100 mb-3 sm:mb-4">Configuration</h2>
<p class="text-base sm:text-lg text-gray-600 dark:text-gray-300 px-4">Customize keywords and behavior</p>
</div>
<div class="max-w-3xl mx-auto">
<div class="glass p-6 rounded-xl">
<h3 class="font-semibold text-gray-900 dark:text-gray-100 mb-4">semver.yaml</h3>
<pre class="bg-gray-900 text-gray-100 p-4 rounded-lg overflow-x-auto text-sm"><code><span class="text-gray-400">version:</span> 1
<span class="text-gray-400"># Starting version (optional)</span>
<span class="text-emerald-400">force:</span>
<span class="text-blue-400">major:</span> 1
<span class="text-blue-400">minor:</span> 0
<span class="text-blue-400">patch:</span> 0
<span class="text-blue-400">commit:</span> 69fbe2df696f40281b9104ff073d26186cde1024
<span class="text-gray-400"># Commits to ignore</span>
<span class="text-emerald-400">blacklist:</span>
- "Merge branch"
- "Merge pull request"
- "feature/"
<span class="text-gray-400"># Keywords for version bumps</span>
<span class="text-emerald-400">wording:</span>
<span class="text-blue-400">patch:</span>
- update
- initial
- fix
<span class="text-blue-400">minor:</span>
- change
- improve
- add
<span class="text-blue-400">major:</span>
- breaking
<span class="text-blue-400">release:</span>
- release-candidate
- add-rc</code></pre>
</div>
</div>
</div>
</section>
<!-- Footer -->
<footer class="py-8 bg-gray-100 dark:bg-gray-800 theme-transition">
<div class="max-w-6xl mx-auto px-4 sm:px-6">
<div class="flex flex-col sm:flex-row justify-between items-center gap-4">
<div class="flex items-center gap-2">
<i class="fas fa-code-branch text-xl gradient-text"></i>
<span class="font-semibold gradient-text">semver-generator</span>
</div>
<div class="flex items-center gap-6">
<a href="https://github.com/lukaszraczylo/semver-generator" class="text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100">
<i class="fab fa-github text-xl"></i>
</a>
<a href="https://github.com/lukaszraczylo/semver-generator/issues" class="text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 text-sm">
Issues
</a>
<a href="https://github.com/lukaszraczylo/semver-generator/releases" class="text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 text-sm">
Releases
</a>
</div>
<p class="text-gray-500 dark:text-gray-400 text-sm">MIT License</p>
</div>
</div>
</footer>
<script>
// Theme toggle
document.getElementById('theme-toggle').addEventListener('click', function() {
if (document.documentElement.classList.contains('dark')) {
document.documentElement.classList.remove('dark');
localStorage.theme = 'light';
} else {
document.documentElement.classList.add('dark');
localStorage.theme = 'dark';
}
});
// Mobile menu toggle
document.getElementById('mobile-menu-toggle').addEventListener('click', function() {
const menu = document.getElementById('mobile-menu');
const openIcon = document.getElementById('menu-open-icon');
const closeIcon = document.getElementById('menu-close-icon');
menu.classList.toggle('hidden');
openIcon.classList.toggle('hidden');
closeIcon.classList.toggle('hidden');
});
// Close mobile menu when clicking a link
document.querySelectorAll('#mobile-menu a').forEach(link => {
link.addEventListener('click', () => {
document.getElementById('mobile-menu').classList.add('hidden');
document.getElementById('menu-open-icon').classList.remove('hidden');
document.getElementById('menu-close-icon').classList.add('hidden');
});
});
</script>
</body>
</html>
+2 -2
View File
@@ -60,11 +60,11 @@ if [[ ! -z "$INPUT_DEBUGMODE" ]]; then
echo "----"
echo "FLAGS: $FLAGS"
echo "----"
/go/src/app/semver-gen generate $FLAGS $*
/go/src/app/semver-generator generate $FLAGS $*
echo "----"
fi
OUT_SEMVER_GEN=$(/go/src/app/semver-gen generate $FLAGS $*)
OUT_SEMVER_GEN=$(/go/src/app/semver-generator generate $FLAGS $*)
[ $? -eq 0 ] || exit 1
CLEAN_SEMVER=$(echo $OUT_SEMVER_GEN | sed -e 's|SEMVER ||g')
echo "semantic_version=$CLEAN_SEMVER" >> $GITHUB_OUTPUT
+3 -10
View File
@@ -7,12 +7,9 @@ toolchain go1.24.6
require (
github.com/go-git/go-git/v5 v5.16.4
github.com/lithammer/fuzzysearch v1.1.8
github.com/lukaszraczylo/ask v0.0.0-20240916204100-6e9ef53a62d9
github.com/lukaszraczylo/go-simple-graphql v1.2.89
github.com/lukaszraczylo/graphql-monitoring-proxy v0.41.9
github.com/lukaszraczylo/graphql-monitoring-proxy v0.42.4
github.com/lukaszraczylo/pandati v0.0.29
github.com/melbahja/got v0.7.0
github.com/spf13/cobra v1.10.1
github.com/spf13/cobra v1.10.2
github.com/spf13/viper v1.21.0
github.com/stretchr/testify v1.11.1
)
@@ -21,18 +18,16 @@ require (
dario.cat/mergo v1.0.2 // indirect
github.com/Microsoft/go-winio v0.6.2 // indirect
github.com/ProtonMail/go-crypto v1.3.0 // indirect
github.com/avast/retry-go/v4 v4.7.0 // indirect
github.com/cloudflare/circl v1.6.1 // indirect
github.com/cyphar/filepath-securejoin v0.6.1 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/emirpasic/gods v1.18.1 // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect
github.com/go-git/go-billy/v5 v5.6.2 // indirect
github.com/go-git/go-billy/v5 v5.7.0 // indirect
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
github.com/goccy/go-json v0.10.5 // indirect
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
github.com/gookit/goutil v0.7.2 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect
github.com/kevinburke/ssh_config v1.4.0 // indirect
@@ -60,9 +55,7 @@ require (
golang.org/x/crypto v0.45.0 // indirect
golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 // indirect
golang.org/x/net v0.47.0 // indirect
golang.org/x/sync v0.18.0 // indirect
golang.org/x/sys v0.38.0 // indirect
golang.org/x/term v0.37.0 // indirect
golang.org/x/text v0.31.0 // indirect
gopkg.in/warnings.v0 v0.1.2 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
+6 -20
View File
@@ -9,8 +9,6 @@ github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFI
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4=
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio=
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs=
github.com/avast/retry-go/v4 v4.7.0 h1:yjDs35SlGvKwRNSykujfjdMxMhMQQM0TnIjJaHB+Zio=
github.com/avast/retry-go/v4 v4.7.0/go.mod h1:ZMPDa3sY2bKgpLtap9JRUgk2yTAba7cgiFhqxY2Sg6Q=
github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs=
github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0=
github.com/cloudflare/circl v1.6.1 h1:zqIqSPIndyBh1bjLVVDHMPpVKqp8Su/V+6MeDzzQBQ0=
@@ -35,8 +33,8 @@ github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c=
github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU=
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI=
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic=
github.com/go-git/go-billy/v5 v5.6.2 h1:6Q86EsPXMa7c3YZ3aLAQsMA0VlWmy43r6FHqa/UNbRM=
github.com/go-git/go-billy/v5 v5.6.2/go.mod h1:rcFC2rAsp/erv7CMz9GczHcuD0D32fWzH+MJAU+jaUU=
github.com/go-git/go-billy/v5 v5.7.0 h1:83lBUJhGWhYp0ngzCMSgllhUSuoHP1iEWYjsPl9nwqM=
github.com/go-git/go-billy/v5 v5.7.0/go.mod h1:/1IUejTKH8xipsAcdfcSAlUlo2J7lkYV8GTKxAT/L3E=
github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399 h1:eMje31YglSBqCdIqdhKBW8lokaMrL3uTkpGYlE2OOT4=
github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399/go.mod h1:1OCfN199q1Jm3HZlxleg+Dw/mwps2Wbk9frAWm+4FII=
github.com/go-git/go-git/v5 v5.16.4 h1:7ajIEZHZJULcyJebDLo99bGgS0jRrOxzZG4uCk2Yb2Y=
@@ -45,15 +43,11 @@ github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9L
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/goccy/go-reflect v1.2.0 h1:O0T8rZCuNmGXewnATuKYnkL0xm6o8UNOJZd/gOkb9ms=
github.com/goccy/go-reflect v1.2.0/go.mod h1:n0oYZn8VcV2CkWTxi8B9QjkCoq6GTtCEdfmR66YhFtE=
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ=
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/gookit/goutil v0.7.2 h1:NSiqWWY+BT0MwIlKDeSVPfQmr9xTkkAqwDjhplobdgo=
github.com/gookit/goutil v0.7.2/go.mod h1:vJS9HXctYTCLtCsZot5L5xF+O1oR17cDYO9R0HxBmnU=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A=
@@ -71,12 +65,8 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/lithammer/fuzzysearch v1.1.8 h1:/HIuJnjHuXS8bKaiTMeeDlW2/AyIWk2brx1V8LFgLN4=
github.com/lithammer/fuzzysearch v1.1.8/go.mod h1:IdqeyBClc3FFqSzYq/MXESsS4S0FsZ5ajtkr5xPLts4=
github.com/lukaszraczylo/ask v0.0.0-20240916204100-6e9ef53a62d9 h1:pL8B9mjv6RPUfKYYGm/uJ7QL6Ndf+z+OEl0qJE6KmEc=
github.com/lukaszraczylo/ask v0.0.0-20240916204100-6e9ef53a62d9/go.mod h1:M+UVdyqZs++xtEPrascaVmZdOMhCnxjZ2SgH+xHpR0c=
github.com/lukaszraczylo/go-simple-graphql v1.2.89 h1:Xbu1Ny+a0lT2Sr2SaSC8mcHmGQDwGD4TJKk4DDd+PwA=
github.com/lukaszraczylo/go-simple-graphql v1.2.89/go.mod h1:PxQYblQDZISmYYj8sNfazAWxAOh1rhAtU208y+uPV8s=
github.com/lukaszraczylo/graphql-monitoring-proxy v0.41.9 h1:5qGh0lgM/nXZSet8aA2L8N3v7XJD62qK7BW9pnNETWE=
github.com/lukaszraczylo/graphql-monitoring-proxy v0.41.9/go.mod h1:pCBFnL+NZ0S7QzTgROP7AIR8lP3uIgcL3lc+cbGTMHE=
github.com/lukaszraczylo/graphql-monitoring-proxy v0.42.4 h1:gq/bEq+JzPes8WR24HHys9VvVWREDEXOWFoVSOFXCCw=
github.com/lukaszraczylo/graphql-monitoring-proxy v0.42.4/go.mod h1:1FLcH7q+7cjUgQxyeVeF7ouBamGpcJZgqDF+j+cuFxI=
github.com/lukaszraczylo/pandati v0.0.29 h1:WUEWm1+hWjE5KJbIL8OctG00x2dk4XKGJSlrjhxZ55k=
github.com/lukaszraczylo/pandati v0.0.29/go.mod h1:+DyTWKFaXd+jIfe7GW5w2S5PyTko/RXxMyOa+Vl713A=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
@@ -86,8 +76,6 @@ github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/melbahja/got v0.7.0 h1:YHbiuNZVS8fIkyV0iXyThQQliwlKZb5h4k80zBVovxg=
github.com/melbahja/got v0.7.0/go.mod h1:27cUstWCEfj6HBESMTGzCFY24Qj+QNMWot3+KuxguQU=
github.com/onsi/gomega v1.34.1 h1:EUMJIKUjM8sKjYbtxQI9A4z2o+rruxnzNvpknOXie6k=
github.com/onsi/gomega v1.34.1/go.mod h1:kU1QgUvBDLXBJq618Xvm2LUX6rSAfRaFRTcdOeDLwwY=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
@@ -116,8 +104,8 @@ github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=
github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
github.com/spf13/cobra v1.10.1 h1:lJeBwCfmrnXthfAupyUTzJ/J4Nc1RsHC/mSRU2dll/s=
github.com/spf13/cobra v1.10.1/go.mod h1:7SmJGaTHFVBY0jW4NXGluQoLvhqFQM+6XSKD+P4XaB0=
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
@@ -166,8 +154,6 @@ golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=