aav is a Go-based CLI that keeps Azure DevOps (ADO) repositories on semantic-versioning rails. It reads bump intent from branch names and pull-request labels, propagates that intent through merge validation, and creates annotated SemVer release or RC tags directly in ADO Git.
- Automatic branch → bump mapping with conflict-safe PR labeling
- PR merge inference that survives squash merges and defaults safely when strict mode is off
- Release and RC tag planning using validated SemVer math (powered by
github.com/blang/semver/v4) - Annotated tag creation through the official Azure DevOps SDK with tagger metadata and commit targeting
- Env > flag > default configuration precedence with structured logging (Zap)
- Pure business-logic layer with exhaustive unit tests and Azure-friendly integration tests
# Build the CLI
GOOS=$(go env GOOS) GOARCH=$(go env GOARCH) go build -o ./bin/aav ./cmd/aav
# Verify embedded build metadata
./bin/aav version
# Add semver labels to a PR during validation
AAV_ORG_URL=... AAV_PROJECT=... AAV_REPO=... AAV_TOKEN=... \
AAV_PR_ID=1234 AAV_SOURCE_BRANCH=feature/awesome-fix \
./bin/aav pr-label
# Infer the bump after a squash merge (strict mode errors when no PR is found)
AAV_COMMIT_SHA=$(git rev-parse HEAD) AAV_STRICT=true ./bin/aav infer-bump
# Create a release tag for the merge commit (bump provided via infer-bump output)
AAV_BUMP=minor AAV_TAG_MODE=release AAV_COMMIT_SHA=$(git rev-parse HEAD) \
AAV_TAGGER_NAME="Build Bot" AAV_TAGGER_EMAIL=ci@example.com ./bin/aav create-tag- PR validation: call
aav pr-labelafter checkout and before policy evaluation. - Main pipeline: run
aav infer-bump --commit-sha $(Build.SourceVersion)to capture intent, export its stdout, then invokeaav create-tag(release or RC mode) with that bump.
# azure-pipelines.yml
trigger:
branches:
include:
- main
pr:
branches:
include:
- '*'
variables:
AAV_ORG_URL: $(System.TeamFoundationCollectionUri)
AAV_PROJECT: $(System.TeamProject)
AAV_REPO: $(Build.Repository.Name)
AAV_LABEL_PREFIX: semver-
stages:
- stage: PRValidation
condition: eq(variables['Build.Reason'], 'PullRequest')
jobs:
- job: LabelPR
steps:
- checkout: self
- script: |
go run ./cmd/aav pr-label \
--pr-id $(System.PullRequest.PullRequestId) \
--source-branch $(System.PullRequest.SourceBranch)
displayName: Apply semver label
- stage: MainRelease
condition: and(succeeded(), eq(variables['Build.SourceBranch'], 'refs/heads/main'))
dependsOn: PRValidation
jobs:
- job: TagRelease
steps:
- checkout: self
- script: |
BUMP=$(go run ./cmd/aav infer-bump \
--commit-sha $(Build.SourceVersion))
echo "##vso[task.setvariable variable=AAV_BUMP]$BUMP"
displayName: Infer bump
- script: |
go run ./cmd/aav create-tag \
--commit-sha $(Build.SourceVersion) \
--tag-mode release \
--bump $(AAV_BUMP)
displayName: Create release tag| Purpose | Environment Variable | Flag | Default | Notes |
|---|---|---|---|---|
| Org URL | AAV_ORG_URL |
--org-url |
required | https://dev.azure.com/{org} |
| Project | AAV_PROJECT |
--project |
required | ADO project name |
| Repository | AAV_REPO |
--repo |
required | Git repo name |
| Token | AAV_TOKEN |
--token |
required | PAT or System.AccessToken |
| Log level | AAV_LOG_LEVEL |
--log-level |
terse |
verbose prints config + trace |
| Label prefix | AAV_LABEL_PREFIX |
--label-prefix |
semver- |
Empty string allowed |
| Major label | AAV_LABEL_MAJOR |
--label-major |
derived | Overrides prefix value |
| Minor label | AAV_LABEL_MINOR |
--label-minor |
derived | Overrides prefix value |
| Patch label | AAV_LABEL_PATCH |
--label-patch |
derived | Overrides prefix value |
| Major branch prefixes | AAV_BRANCH_MAJOR_PREFIXES |
--branch-major-prefix |
breaking/,major/ |
Repeatable flag; env uses comma-separated list (e.g. breaking/,major/) |
| Minor branch prefixes | AAV_BRANCH_MINOR_PREFIXES |
--branch-minor-prefix |
feature/,minor/ |
Repeatable flag; env uses comma-separated list (e.g. feature/,minor/) |
| Patch branch prefixes | AAV_BRANCH_PATCH_PREFIXES |
--branch-patch-prefix |
bugfix/,fix/,hotfix/,chore/,patch/ |
Repeatable flag; env uses comma-separated list (e.g. bugfix/,fix/) |
| PR ID | AAV_PR_ID |
--pr-id |
required by pr-label | Integer > 0 |
| Source branch | AAV_SOURCE_BRANCH |
--source-branch |
required by pr-label | Branch that triggered PR |
| Commit SHA | AAV_COMMIT_SHA |
--commit-sha |
required by infer-bump/create-tag | 40-char SHA |
| Strict mode | AAV_STRICT |
--strict |
false |
Only applies to infer-bump |
| Tag mode | AAV_TAG_MODE |
--tag-mode |
required by create-tag | release or rc |
| Bump intent | AAV_BUMP |
--bump |
required by create-tag | major, minor, patch |
| Base version | AAV_BASE_VERSION |
--base-version |
none | Used when no stable tags exist |
| Tag message | AAV_TAG_MESSAGE |
--tag-message |
empty | Stored in annotated tag |
| Tagger name | AAV_TAGGER_NAME |
--tagger-name |
aav |
Recorded in annotated tag |
| Tagger email | AAV_TAGGER_EMAIL |
--tagger-email |
aav@example.com |
Recorded in annotated tag |
| Tag prefix | AAV_TAG_PREFIX |
--tag-prefix |
empty | Prepended to computed tag names (set to v for legacy repos) |
| Floating tags | AAV_USE_FLOATING_TAGS |
--use-floating-tags |
false |
Maintain short v<major> refs that track the latest release major; detected automatically when such refs already exist |
Precedence: environment variables always win over explicit flags; conflicts are logged in both terse and verbose modes.
Branch prefix env format: When using the environment variables above, provide comma-separated prefixes with no quotes (e.g.
AAV_BRANCH_MINOR_PREFIXES=feature/,minor/). Use the repeatable CLI flags when you prefer to specify each prefix individually.
| Command | When to use | Behavior |
|---|---|---|
pr-label |
Pull-request validation | Resolves bump intent from the source branch, ensures the expected semver label exists, loudly warns on conflicts, and never removes user labels. |
infer-bump |
Main-branch CI after squash merge | Locates the PR by merge commit, rehydrates bump intent from labels, defaults to patch unless --strict is set. Prints major, minor, or patch to stdout for scripting. |
create-tag |
Release/RC tagging stages | Discovers existing tags, computes the next SemVer (release or RC), and creates an annotated tag on the desired commit with full trace logging. |
version |
Introspection | Prints the embedded semantic version and build date for the running binary. |
aav create-tag --tag-mode release can also maintain floating v<major> refs that always point at the most recent patch of the newest release line:
- Opt in via
--use-floating-tags/AAV_USE_FLOATING_TAGS, or let the tool detect an existing floating tag that already tracks a valid SemVer release. - Floating refs are only created or updated in release mode, and only for the highest major version (e.g., when
2.xis current, onlyv2moves; creating3.0.0also createsv3). - Updates are performed by deleting the previous ref (when present) and recreating it as an annotated tag using the exact same metadata (tagger, message, commit) as the freshly minted SemVer tag. This movement is automatic for virtual floating refs; SemVer release and RC tags are never moved.
- Detection requires that the floating ref’s commit matches a non-RC SemVer tag so repositories that already use floating tags automatically stay on rails even if the flag is not set explicitly. The CLI logs when auto-detection overrides the flag state.
- Every build stamps two ldflags into
internal/version: the semantic version (Version) and UTC build date (BuildDate). make buildderives defaults fromgit describe --tags(falling back todev) and the current UTC time. Override them by settingAAV_VERSIONand/orAAV_BUILD_DATEbefore invoking the target.- GoReleaser already injects release metadata via the same variables, so published artifacts report the tag they were built from when you run
aav version. - The
versionsubcommand prints both values so pipelines (or end users) can confirm which artifact they are running.
- Business logic: pure Go packages for branch mapping, label resolution, SemVer planning, and configuration. These packages do not perform I/O and are fully unit tested.
- ADO client: a thin wrapper over
github.com/microsoft/azure-devops-go-api/azuredevops/v7that exposes the handful of Git operations we need (PR labels, ref listing, annotated tags). Substitutable via interfaces for tests. - CLI layer: Cobra commands, env/flag resolution, and Zap logging that wire user intent into the business layer.
| Scope | Command | Notes |
|---|---|---|
| Unit tests | go test ./... |
Covers business logic and service layers. Executed in CI. |
| Integration tests | go test -tags=integration ./integration -count=1 |
Hits live ADO resources using the AAV_ environment variables described below. Not run by default or in CI. |
Set these variables to point at a safe ADO test repository before running with the integration build tag:
| Variable | Purpose |
|---|---|
AAV_ORG_URL |
Azure DevOps organization URL that hosts the repo under test |
AAV_PROJECT |
Project name containing that repository |
AAV_REPO |
Repository name used for temporary branches/tags |
AAV_TOKEN |
PAT or System.AccessToken with PR + tag permissions |
AAV_EXPECTED_BUMP (optional) |
Override the bump asserted by the workflow (major, minor, or patch); defaults to minor by using a feature/ branch |
AAV_TARGET_BRANCH (optional) |
Base branch to branch from; defaults to main |
AAV_MANUAL_MERGE (optional) |
Set to true to pause so you can merge the PR manually |
AAV_GIT_AUTHOR_NAME / AAV_GIT_AUTHOR_EMAIL (optional) |
Commit identity for the temporary feature branch |
AAV_TAGGER_NAME / AAV_TAGGER_EMAIL (optional) |
Annotated tag identity overrides |
AAV_TAG_PREFIX (optional) |
Prepends text to created tags (set to v when matching legacy repos) |
AAV_BAD_COMMIT_SHA (optional) |
Commit that should not map to a PR; defaults to all zeros |
AAV_BAD_PR_ID (optional) |
Nonexistent PR ID for negative testing |
AAV_BRANCH_MAJOR_PREFIXES / AAV_BRANCH_MINOR_PREFIXES / AAV_BRANCH_PATCH_PREFIXES (optional) |
Override the branch-to-bump mapping the tests and CLI use; each env expects a comma-separated list (mirrors the CLI defaults) |
The tests call go run ./cmd/aav ... so they verify the built binary end-to-end. When the required AAV_ variables are absent the tests automatically skip.
- Go 1.26.2+ is required (for standard library security patches). Run
make depsto install dependencies and Go tools, thenmake ci-localto mirror CI locally. - Keep Go modules tidy and run
go test ./...before submitting changes. - Follow the layered architecture: keep business logic separate from Azure SDK calls and CLI plumbing.
- Add unit tests alongside any new exported behavior. Mock the ADO client for service tests.
- For release engineering, see
RELEASE_GUIDE.mdandRELEASE_*Make targets.
Apache License 2.0. See LICENSE for details.
.tool-versions- mise versions for golang and pre-committools/tools.go- Development tool dependencies (golangci-lint, gosec, govulncheck).goreleaser.yml- GoReleaser configuration for builds and releases.github/workflows/ci.yml- CI pipeline (tests, linting, security).github/workflows/release.yml- Release pipeline.golangci.yml- Linter configuration.github/dependabot.yml- Dependency update configurationDockerfile- Multi-stage Docker build configurationdocker-compose.yml- Local development environment.pre-commit-config.yaml- Pre-commit hooks configuration.vscode/settings.json- VS Code Go development settings.vscode/extensions.json- Recommended VS Code extensions.env.example- Environment variables template
We use mise for top-level ecosystems only (golang, pre-commit via .tool-versions). Go development tools (golangci-lint, gosec, govulncheck) are installed via the Makefile:
mise install # Install golang and pre-commit from .tool-versions
make deps # Download modules, verify, and install Go toolsTools are tracked in tools/tools.go using the standard Go pattern: blank imports with //go:build tools so they are excluded from the application build. Versions are pinned in go.mod; go install (without @version) uses the module graph and any replace directives.
Module cache issues: If go mod verify fails with "dir has been modified", run make deps-clean to clear the cache, then make deps again. The deps target will also auto-retry after cleaning on verify failure.
| Concern | Impact |
|---|---|
| Application binary | None. The tools package is excluded from builds (//go:build tools). Your shipped binary does not include golangci-lint, gosec, or govulncheck. |
| Runtime attack surface | None. The tools are separate executables used only in development and CI, not in production. |
| go.sum / module cache | Larger. Tool dependencies are downloaded and checksummed. This affects local dev and CI, not the released artifact. |
| govulncheck output | May report vulnerabilities in tool dependencies. Those affect the tool binaries only, not your application at runtime. |
You are not inheriting the tools' security issues into your code—you are tracking their versions for reproducible development and CI.
This project includes several CI checks:
- Tests: Unit tests with race detection and coverage
- Linting: golangci-lint with multiple linters enabled
- Security: Gosec security scanner and govulncheck
- Builds: Cross-platform build verification
- Dependencies: Go mod tidy verification
All checks must pass before merging to main.
launch-ado-automatic-versioner/
├── cmd/aav/ # CLI entry point
├── internal/ # Private application code
│ ├── ado/ # Azure DevOps client
│ ├── cli/ # Cobra commands and flags
│ ├── config/ # Configuration resolution
│ ├── domain/ # Business logic (branchmap, bump, labels, tagplan)
│ ├── logging/ # Structured logging
│ ├── services/ # Service layer (inferbump, prlabel, tagging)
│ └── version/ # Build metadata
├── tools/ # Development tool dependencies (tools.go)
├── integration/ # Integration tests
└── scripts/ # Build and setup scripts
# Build and run with Docker
make docker-build
make docker-run
# Or use Docker Compose for local development
make docker-compose-up
make docker-compose-downThis project includes pre-commit hooks for code quality:
# Install pre-commit hooks
pre-commit install
# Run manually
pre-commit run --all-files