diff --git a/.github/dependabot.yml b/.github/dependabot.yml index e5704ab8..c716b17d 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -65,3 +65,17 @@ updates: update-types: - "minor" - "patch" + + # git-cliff CLI (used by 'mage changelog'), pinned via a Cargo manifest so the + # version is visible to Dependabot and security scanners. + - package-ecosystem: "cargo" + directory: "/tools/git-cliff" + schedule: + interval: "weekly" + cooldown: + default-days: 7 + groups: + dependabot-cargo-tools: + applies-to: version-updates + patterns: + - "*" diff --git a/.github/workflows/prepare-release.yml b/.github/workflows/prepare-release.yml new file mode 100644 index 00000000..9c679b38 --- /dev/null +++ b/.github/workflows/prepare-release.yml @@ -0,0 +1,102 @@ +name: "Prepare release" + +# Manually triggered. Generates the changelog draft on a release branch and +# pushes it. It does NOT open a PR (that needs auth we avoid) -- a maintainer +# opens the PR from the pushed branch, curates the draft, and merges it. Tagging +# happens afterwards in the "Release" workflow. See +# docs/developer/how-to/releasing.md. +on: + workflow_dispatch: + +# Don't run two release preparations at once. +concurrency: + group: ${{ github.workflow }} + cancel-in-progress: false + +permissions: {} + +jobs: + prepare-release: + name: "Prepare release branch" + runs-on: ubuntu-latest + permissions: + contents: write # Push the release branch. + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + # git-cliff needs full history and tags to compute the next version. + fetch-depth: 0 + persist-credentials: false + + - name: Get go tools + uses: ./.github/getgotools + + - name: Read pinned git-cliff version + id: gitcliff + run: | + set -euo pipefail + version="$(sed -n 's/^[[:space:]]*git-cliff = "=\([0-9.]*\)".*/\1/p' tools/git-cliff/Cargo.toml)" + if [ -z "$version" ]; then + echo "could not read git-cliff version from tools/git-cliff/Cargo.toml" >&2 + exit 1 + fi + echo "version=$version" >> "$GITHUB_OUTPUT" + + - name: Install git-cliff + uses: taiki-e/install-action@bffeee26d4db9be238a4ea78d8826604ebcb594d # v2.82.5 + with: + tool: git-cliff@${{ steps.gitcliff.outputs.version }} + + - name: Generate changelog draft + run: mage changelog + + - name: Push release branch + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + REPO: ${{ github.repository }} + SERVER_URL: ${{ github.server_url }} + run: | + set -euo pipefail + + # Nothing to release: `mage changelog` left CHANGELOG.md unchanged (no new entries since + # the last release). Report it in the job summary and stop -- this is not a failure. + if git diff --quiet -- CHANGELOG.md; then + { + echo "### Nothing to release" + echo "" + echo "No new changelog entries since the last release, so no release branch was created." + } >> "$GITHUB_STEP_SUMMARY" + echo "Nothing to release: CHANGELOG.md is unchanged." + exit 0 + fi + + version="$(sed -n 's/^## \[\([0-9.]*\)\].*/\1/p' CHANGELOG.md | head -1)" + if [ -z "$version" ]; then + echo "no '## [X.Y.Z]' heading found in CHANGELOG.md" >&2 + exit 1 + fi + branch="release/v${version}" + + # Refuse to clobber an existing release branch (e.g. a prior prepare-release run that + # has not been merged or cleaned up yet). + if git ls-remote --exit-code --heads origin "refs/heads/${branch}" >/dev/null 2>&1; then + echo "Release branch '${branch}' already exists on the remote; merge or delete it before preparing again." >&2 + exit 1 + fi + + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git switch -c "$branch" + git add CHANGELOG.md + git commit -m "chore: prepare release v${version}" + git push "https://x-access-token:${GH_TOKEN}@github.com/${REPO}.git" "$branch" + + # Build the create-PR URL from the current server/repo so it points at this repo (fork or + # upstream). GitHub's pull/new/ form targets the repo's default branch as the base. + pr_url="${SERVER_URL}/${REPO}/pull/new/${branch}" + { + echo "Pushed branch \`$branch\` with the v${version} changelog draft." + echo "" + echo "**[Open the pull request →](${pr_url})**, curate the changelog into user-facing notes, then merge." + } >> "$GITHUB_STEP_SUMMARY" + echo "Open a PR: ${pr_url}" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 00000000..7e48859b --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,83 @@ +name: "Release" + +# Runs on every push to main. When CHANGELOG.md carries a new version, tags it +# (via the idempotent `mage release`), pushes the tag, and publishes a GitHub +# Release with that version's changelog notes. Normal merges are a no-op because +# the changelog's top version is already tagged. See docs/developer/how-to/releasing.md. +on: + push: + branches: [main] + +# Don't run two releases at once. +concurrency: + group: ${{ github.workflow }} + cancel-in-progress: false + +permissions: {} + +jobs: + release: + name: "Tag release" + runs-on: ubuntu-latest + permissions: + contents: write # Push the release tag. + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + # Need full history and tags to detect existing releases and tag HEAD. + fetch-depth: 0 + persist-credentials: false + + - name: Get go tools + uses: ./.github/getgotools + + - name: Create release tag from changelog + run: | + # Annotated tags (git tag -a) need a committer identity, which the runner lacks. + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + mage release + + - name: Publish release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + REPO: ${{ github.repository }} + run: | + set -euo pipefail + version="$(sed -n 's/^## \[\([0-9.]*\)\].*/\1/p' CHANGELOG.md | head -1)" + if [ -z "$version" ]; then + echo "no '## [X.Y.Z]' heading found in CHANGELOG.md" >&2 + exit 1 + fi + tag="v${version}" + + # Push the tag if the remote lacks it. Tag push is kept separate from publishing the + # release so a rerun can finish a half-done release that pushed the tag but never + # published (e.g. a prior run that failed between the two steps). + if git ls-remote --exit-code --tags origin "refs/tags/${tag}" >/dev/null 2>&1; then + echo "Tag \`${tag}\` already on remote." >> "$GITHUB_STEP_SUMMARY" + else + git push "https://x-access-token:${GH_TOKEN}@github.com/${REPO}.git" "refs/tags/${tag}" + echo "Pushed tag \`${tag}\`." >> "$GITHUB_STEP_SUMMARY" + fi + + # If the GitHub Release already exists, there's nothing left to publish or warm. + if gh release view "${tag}" --repo "${REPO}" >/dev/null 2>&1; then + echo "Release \`${tag}\` already exists; nothing to publish." >> "$GITHUB_STEP_SUMMARY" + exit 0 + fi + + # Release notes = this version's changelog section (its body, without the heading line). + awk '/^## \[/{n++; next} n==1' CHANGELOG.md > "${RUNNER_TEMP}/release-notes.md" + gh release create "${tag}" --repo "${REPO}" --title "${tag}" --notes-file "${RUNNER_TEMP}/release-notes.md" + echo "Published release \`${tag}\` with notes from CHANGELOG.md." >> "$GITHUB_STEP_SUMMARY" + + # Warm the module proxy and pkg.go.dev so the new version is discoverable promptly. + # Upstream only: a fork declares the upstream module path in go.mod, so the proxy would + # reject a request under the fork's path -- warming there just errors. Best-effort. + if [ "${REPO}" = "microsoft/azure-linux-dev-tools" ]; then + module="$(awk '/^module /{print $2; exit}' go.mod)" + GOPROXY=https://proxy.golang.org go list -m "${module}@${tag}" \ + || echo "proxy warm deferred; it will index on first use" + curl -fsS -o /dev/null "https://pkg.go.dev/${module}@${tag}" || true + fi diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 00000000..be9497af --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,50 @@ +# Changelog + + + +All notable changes to `azldev` are documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [0.1.0] - 2026-03-18 + +First tagged preview release of `azldev`, the developer CLI for the +[Azure Linux](https://github.com/microsoft/azurelinux) distro. + +### Added + +- **Project and metadata management.** Scaffold a project with `azldev project + init` or `project new`, then parse, resolve, and query the TOML metadata + (`azldev.toml`) that defines Azure Linux. Configuration merges built-in + defaults with project and user-level (XDG) files, is fully validated, and is + published as a JSON Schema via `azldev config generate-schema`. +- **Component inspection and locking.** List and inspect components with `azldev + component list` and `component query`, and import new ones with `component + add`. Deterministic component fingerprints and per-component lock files keep + builds reproducible; `component update` refreshes them with `--check-only`, + `--bump`, freshness-based skipping, a progress bar, and upstream-staleness + detection. `component changed` and `component diff-sources` report what moved. +- **Source preparation and spec rendering.** `component prepare-sources` and + `component render` produce build-ready sources and specs through a + `mock`-based batch pipeline, synthesizing the git history that `rpmautospec` + needs and constructing dist-git from lock-file history. A rich overlay system + (spec search/replace, prepend/append lines, remove section or subpackage, file + and source replacement, per-file overlay files, and inline metadata) + customizes specs, with explicit release-calculation modes (`autorelease`, + `static`, and automatic). Source archives are fetched from lookaside caches. +- **Local package and image builds.** Build individual packages with `mock` + using `component build`, emitting RPMs and SRPMs into structured, + publish-channel-aware output directories. `azldev image` builds, customizes, + injects files into, boots, and runs LISA tests against Azure Linux images on a + local QEMU VM. +- **Package and repository queries.** Inspect binary package configuration with + `azldev package list` (including `--rpm-file`, debug-package synthesis, and + separate package/component group columns), and inspect or manage RPM + repositories with `azldev repo query`, backed by repo resources and repo-set + templates. +- **Command-line experience.** Shell completions for bash, zsh, fish, and + PowerShell; actionable hints on errors; global `--quiet`, `--verbose`, and + `--dry-run` flags with `table`, `json`, `csv`, and `markdown` output formats; + an embedded MCP server (`azldev advanced mcp`); and auto-generated CLI + reference documentation. diff --git a/README.md b/README.md index 588f4ed8..9227dc4d 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,8 @@ # Azure Linux Dev Tools +[![Go Reference](https://pkg.go.dev/badge/github.com/microsoft/azure-linux-dev-tools.svg)](https://pkg.go.dev/github.com/microsoft/azure-linux-dev-tools) +[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](./LICENSE) + Azure Linux Dev Tools is a collection of utilities useful for development of the Azure Linux distro. @@ -28,9 +31,12 @@ It supports: 1. Install `azldev`: ```console - go install github.com/microsoft/azure-linux-dev-tools/cmd/azldev@main + go install github.com/microsoft/azure-linux-dev-tools/cmd/azldev@latest ``` + To pin a specific release instead of tracking the latest, replace `@latest` + with a version tag, e.g. `@v0.1.0`. + 1. To ensure you can build using `mock` you must be a member of the `mock` group, e.g.: ```console @@ -62,6 +68,10 @@ Please see our [Contribution Guidelines](./CONTRIBUTING.md) for our project. For development setup and workflow, please consult our [Developer Guide](./docs/developer). +## Changelog + +Notable changes for each release are recorded in the [changelog](./CHANGELOG.md). + ## Getting Help Have questions, found a bug, or need a new feature? Open an issue in our [GitHub repository](https://github.com/microsoft/azure-linux-dev-tools/issues/new/choose). For guidance on how to file an issue, see [how to report issues](https://aka.ms/azurelinux-reportissues). diff --git a/cliff.toml b/cliff.toml new file mode 100644 index 00000000..e0cb1092 --- /dev/null +++ b/cliff.toml @@ -0,0 +1,81 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. +# +# git-cliff configuration for generating azldev's CHANGELOG.md from Conventional +# Commits. Run it via `mage changelog`; see docs/developer/how-to/releasing.md. +# Reference: https://git-cliff.org +# +# The generated entries are a *draft*: a human (or Copilot) prunes and rewords +# them into user-facing notes before a release. Internal commit types (docs, +# test, chore, build, ci, style, refactor, and dependency bumps) are skipped so +# the draft starts close to user-facing. + +[changelog] +# The markdownlint-disable-file comment scopes two relaxations to CHANGELOG.md +# only (no repo-wide markdownlint config): MD013 because auto-generated entries +# are one line per commit and can exceed the line length, and MD024 because the +# Keep a Changelog format repeats '### Added'/'### Fixed' under every version. +header = """ +# Changelog + + + +All notable changes to `azldev` are documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +""" +# Per-version section in Keep a Changelog style. `version` is unset when +# rendering unreleased commits, which yields an `## [Unreleased]` heading. The +# leading blank line keeps a blank line above each version heading (markdownlint +# MD022) when the section is prepended below the file header or another version. +body = """ + +{% if version %}\ +## [{{ version | trim_start_matches(pat="v") }}] - {{ timestamp | date(format="%Y-%m-%d") }} +{% else %}\ +## [Unreleased] +{% endif %}\ +{% for group, commits in commits | group_by(attribute="group") %} +### {{ group | striptags | trim | upper_first }} +{% for commit in commits %} +- {{ commit.message | split(pat="\n") | first | upper_first | trim }}\ +{% endfor %} +{% endfor %}\ +""" +# Collapse any run of 3+ newlines left by the template into a single blank line +# so the rendered changelog has no double blank lines (markdownlint MD012). +postprocessors = [{ pattern = '\n{3,}', replace = "\n\n" }] +trim = true +footer = "" + +[git] +conventional_commits = true +# Keep non-conventional commits (e.g. early "README.md committed" bootstrap +# commits) instead of treating them as parse errors. They fall through to the +# catch-all `.*` skip parser below, so they're dropped quietly rather than +# emitting a "commit(s) skipped due to parse error(s)" warning during the +# full-history version bump. +filter_unconventional = false +# Drop commits that don't match a kept parser below (cuts internal noise). +filter_commits = true +# ...but never drop a breaking change, even if its type is skipped above. +protect_breaking_commits = true +# Only consider release tags of the form `vX.Y.Z`. +tag_pattern = '^v[0-9]+\.[0-9]+\.[0-9]+$' +sort_commits = "oldest" +commit_parsers = [ + { message = "^feat", group = "Added" }, + { message = "^fix", group = "Fixed" }, + { message = "^perf", group = "Changed" }, + { message = "^revert", group = "Removed" }, + { message = "^refactor", skip = true }, + { message = "^docs", skip = true }, + { message = "^test", skip = true }, + { message = "^chore", skip = true }, + { message = "^build", skip = true }, + { message = "^ci", skip = true }, + { message = "^style", skip = true }, + # Skip anything that didn't match a kept type above. + { message = ".*", skip = true }, +] diff --git a/cmd/azldev/azldev.go b/cmd/azldev/azldev.go index 171c0819..11aaf2e3 100644 --- a/cmd/azldev/azldev.go +++ b/cmd/azldev/azldev.go @@ -1,6 +1,18 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +// Command azldev is a developer tool for working on the Azure Linux distro. +// +// It parses, resolves, and queries the TOML-based metadata that defines Azure +// Linux, prepares component sources for building with mock, fetches source +// archives from lookaside caches, and offers convenience utilities for +// locally building individual packages and images. +// +// Install the latest release with: +// +// go install github.com/microsoft/azure-linux-dev-tools/cmd/azldev@latest +// +// Run "azldev --help" for usage information, or see the user guide under docs/user. package main import ( diff --git a/docs/developer/README.md b/docs/developer/README.md index 1cb73514..a628ce09 100644 --- a/docs/developer/README.md +++ b/docs/developer/README.md @@ -10,6 +10,7 @@ To get started developing `azldev`, review the [Getting Started guide](./how-to/ * [Submitting Pull Requests](./how-to/pull-requests.md) * [Configuration management](./how-to/config-management.md) * [Add go tools](./how-to/add-go-tool.md) +* [Cut a release](./how-to/releasing.md) ## Reference diff --git a/docs/developer/how-to/releasing.md b/docs/developer/how-to/releasing.md new file mode 100644 index 00000000..0f761cf1 --- /dev/null +++ b/docs/developer/how-to/releasing.md @@ -0,0 +1,184 @@ +# How to: cut a release + +This guide covers releasing the `azldev` Go module so that +`go install ...@version` and the [pkg.go.dev][pkgsite] reference page work. + +## TL;DR + +Releases run through CI — the recommended path, with nothing to install +locally: + +1. Trigger the [**Prepare release** workflow][prepare-release-run] + (**Run workflow** → `main`). It drafts the next changelog section and pushes + a `release/vX.Y.Z` branch. +2. Wait ~30 seconds, the output summary of the workflow will generate a link to create the PR. +3. On merge, the [**release** workflow][release-run] tags `vX.Y.Z`, publishes a + GitHub Release from the changelog, and warms the proxy + pkg.go.dev — no + further action needed. + +See [Automated releases (CI)](#automated-releases-ci) for what each workflow +does. + +Local steps are: + +```console +# One-time: install the changelog generator (git-cliff) +cargo binstall git-cliff # or: cargo install git-cliff --locked, or: brew install git-cliff + +# 1. Draft the changelog, curate it into user-facing notes, then PR + merge to main +mage changelog # prepends a draft ## [X.Y.Z] section to CHANGELOG.md + +# 2. Tag the release from the changelog version, then publish by pushing the tag +mage release # creates annotated tag vX.Y.Z on HEAD (does not push) + +# Undo a local tag created by mistake (before pushing) +git tag -d vX.Y.Z + +git push origin vX.Y.Z # pushing the tag is what publishes the release +``` + +Each manual step is explained in full under [Cut a release](#cut-a-release) +below. + +## Versioning policy + +We follow [Semantic Versioning][semver] with a `v` prefix on tags +(`vMAJOR.MINOR.PATCH`). + +* **`v0.x.y` (current).** The pre-1.0 phase: the CLI and any exported Go API may + change in breaking ways on a *minor* bump. Use this while the surface is still + settling. +* **`v1.0.0` and later.** Commits to SemVer stability: breaking changes require a + major bump. +* **Major versions `>= 2`** require a `/vN` suffix on the module path + (e.g. `github.com/microsoft/azure-linux-dev-tools/v2`). Staying on `v0`/`v1` + avoids that. Don't cut a `v2.0.0` tag without first updating the module path. + +## How publishing actually works + +There is no separate "upload" step. The public [Go module proxy][proxy] and +pkg.go.dev fetch directly from this repository's Git tags: + +* `go install .../cmd/azldev@vX.Y.Z` resolves the tag through the proxy. +* `azldev version` reports the right version automatically for proxy installs — + Go embeds the module version into the binary's build info. +* pkg.go.dev renders the package doc comments plus this repo's `README.md`. Our + `LICENSE` (MIT) marks the module redistributable so docs are shown. Only the + public `cmd/` and `pkg/` packages appear; `internal/` is hidden by design. + +## Cut a release + +1. Make sure `main` is green and up to date locally. + +2. Generate and curate the changelog. Run `mage changelog` to prepend a draft + section for the next version to [`CHANGELOG.md`](../../../CHANGELOG.md), then + edit it down into user-facing notes. See [Changelog](#changelog) below. + +3. Tag the release. Once the changelog change is on `main`, run `mage release`: + it reads the version from the top `## [X.Y.Z]` heading in + [`CHANGELOG.md`](../../../CHANGELOG.md) and creates a matching annotated tag + (`vX.Y.Z`), so the tag and the changelog can't disagree. Then push the tag: + + ```console + mage release + git push origin vX.Y.Z + ``` + + `mage release` creates the tag locally but never pushes — pushing the tag is + what publishes the release. The version lives only in the git tag (there is no + version file); `azldev version` reads it from the build. To sign the tag, + create it manually with `git tag -s` instead. + + `mage release` is idempotent: if that version is already tagged it does + nothing, so the same command is safe to automate on every merge to `main`. + +4. Warm the proxy and pkg.go.dev so the new version is discoverable promptly. + The CI release workflow does this automatically (upstream only); the commands + below are for a manual release. It is harmless — it only triggers indexing of + an already-public tag: + + ```console + GOPROXY=https://proxy.golang.org go list \ + -m github.com/microsoft/azure-linux-dev-tools@vX.Y.Z + ``` + + Then visit + `https://pkg.go.dev/github.com/microsoft/azure-linux-dev-tools@vX.Y.Z` once to + prompt the docs build. + +5. (Optional) Create a GitHub Release for the tag with that version's + `CHANGELOG.md` section as the notes. The CI release workflow does this + automatically; for a manual release, use `gh release create`. + +## Changelog + +[`CHANGELOG.md`](../../../CHANGELOG.md) at the repo root follows the +[Keep a Changelog](https://keepachangelog.com/en/1.1.0/) format. New sections are +drafted from the Conventional Commit history with +[git-cliff](https://git-cliff.org) and then curated by hand. Generate a draft +with: + +```console +mage changelog +``` + +This prepends a `## [X.Y.Z]` section (the version is inferred from the commits) +above the previous release, skipping internal commit types — docs, test, chore, +build, ci, style, refactor, and dependency bumps — per +[`cliff.toml`](../../../cliff.toml). The result is a **draft**: git-cliff emits +commit subjects, not release prose, so prune and reword the entries into +user-facing notes before committing. + +`mage changelog` is the single changelog flow — it runs identically locally and +in CI (CI just installs git-cliff first), so there is nothing to keep in sync +between the two. It needs git-cliff on your `PATH`; the version is pinned in +[`tools/git-cliff/Cargo.toml`](../../../tools/git-cliff/Cargo.toml) so Dependabot +and security scanners track it. Install it once: + +```console +cargo binstall git-cliff # or: cargo install git-cliff --locked, or: brew install git-cliff +``` + +> Tip: the generated draft is a natural place to let Copilot help rewrite commit +> subjects into concise, user-facing notes before you commit. + +## Automated releases (CI) + +Two workflows automate the manual steps above, reusing the same mage targets so +there is no second code path: + +* [`prepare-release.yml`](../../../.github/workflows/prepare-release.yml) + (manual **Run workflow**): checks out `main`, installs the pinned git-cliff, + runs `mage changelog`, and pushes a `release/vX.Y.Z` branch. It does **not** + open the PR — open it yourself from that branch, curate the draft, and merge. +* [`release.yml`](../../../.github/workflows/release.yml) (on push to `main`): + runs `mage release`, pushes the tag, publishes a GitHub Release whose notes are + that version's `CHANGELOG.md` section, and (on the upstream repo) warms the + module proxy and pkg.go.dev. It is a no-op on ordinary merges because the + changelog's top version is already tagged; it only releases when a release PR + bumps the changelog to a new version. + +Both push with the default `GITHUB_TOKEN` (`contents: write`) — no PAT needed. +A tag pushed by `GITHUB_TOKEN` does not itself trigger further workflows, which +only matters if a tag-triggered build is added later. + +## Fixing a bad release + +Proxy versions are immutable — you cannot delete or move a published version. +To withdraw one, [retract][retract] it: add a `retract` directive to `go.mod` +describing the bad version(s) and release a new patch. `go get` will then skip +the retracted versions. + +```go +// in go.mod +retract ( + v0.1.1 // contains a build-breaking bug; use v0.1.2. +) +``` + +[pkgsite]: https://pkg.go.dev/github.com/microsoft/azure-linux-dev-tools +[semver]: https://semver.org/ +[proxy]: https://proxy.golang.org/ +[retract]: https://go.dev/ref/mod#go-mod-file-retract +[prepare-release-run]: https://github.com/microsoft/azure-linux-dev-tools/actions/workflows/prepare-release.yml +[release-run]: https://github.com/microsoft/azure-linux-dev-tools/actions/workflows/release.yml diff --git a/magefiles/magefile.go b/magefiles/magefile.go index da8330ef..486eef79 100644 --- a/magefiles/magefile.go +++ b/magefiles/magefile.go @@ -16,6 +16,8 @@ import ( //mage:import "github.com/microsoft/azure-linux-dev-tools/magefiles/magecheckfix" //mage:import + _ "github.com/microsoft/azure-linux-dev-tools/magefiles/magerelease" + //mage:import _ "github.com/microsoft/azure-linux-dev-tools/magefiles/magemutation" //mage:import "github.com/microsoft/azure-linux-dev-tools/magefiles/magescenario" diff --git a/magefiles/magerelease/magerelease.go b/magefiles/magerelease/magerelease.go new file mode 100644 index 00000000..bfa0c8d6 --- /dev/null +++ b/magefiles/magerelease/magerelease.go @@ -0,0 +1,190 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +package magerelease + +import ( + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "regexp" + "strings" + + "github.com/magefile/mage/sh" + "github.com/microsoft/azure-linux-dev-tools/magefiles/mageutil" +) + +var ( + ErrChangelog = errors.New("changelog generation failed") + ErrRelease = errors.New("release tagging failed") +) + +// Changelog generates a draft changelog section for the next release from the Conventional Commit +// history and prepends it to 'CHANGELOG.md', using git-cliff and the repo's 'cliff.toml'. +// +// The output is a *draft*: review and curate it into user-facing notes before releasing. See +// docs/developer/how-to/releasing.md. +// +// git-cliff must be installed and on PATH; this target does not install it. The same target runs +// locally and in CI (CI just installs git-cliff first), so there is a single changelog flow. +func Changelog() error { + mageutil.MagePrintln(mageutil.MsgStart, "Generating changelog draft with git-cliff...") + + // git-cliff is an external (non-Go) tool installed separately, so invoke it by name and let + // PATH resolution happen at execution time (the repo forbids exec.LookPath for tool checks). + // Probe it once up front so a missing binary surfaces an actionable install hint. + const cliff = "git-cliff" + if _, err := sh.Output(cliff, "--version"); err != nil { + if errors.Is(err, exec.ErrNotFound) { + return mageutil.PrintAndReturnError(gitCliffInstallHint(), ErrChangelog, err) + } + + return mageutil.PrintAndReturnError("git-cliff is installed but failed to run.", ErrChangelog, err) + } + + projectDir := mageutil.AzldevProjectDir() + configPath := filepath.Join(projectDir, "cliff.toml") + changelogPath := filepath.Join(projectDir, "CHANGELOG.md") + + // Guard against double-prepending: if CHANGELOG.md already starts with the version git-cliff + // would bump to, the draft is already present. Re-running would add a duplicate section. + bumped := bumpedVersion(cliff, configPath) + + top, topErr := latestChangelogVersion() + if bumped != "" && topErr == nil && top == bumped { + mageutil.MagePrintf(mageutil.MsgInfo, + "CHANGELOG.md already has a draft for v%s; nothing to do "+ + "(run 'git restore CHANGELOG.md' to regenerate).\n", bumped) + + return nil + } + + // --bump computes the next version from the commits; --unreleased limits to commits since the + // last release tag; --prepend inserts the new section at the top of the existing changelog. + err := sh.Run(cliff, "--config", configPath, "--bump", "--unreleased", "--prepend", changelogPath) + if err != nil { + return mageutil.PrintAndReturnError("git-cliff failed to generate the changelog.", ErrChangelog, err) + } + + mageutil.MagePrintf(mageutil.MsgSuccess, + "Updated %#q. Review and curate the new section before releasing.\n", changelogPath) + + return nil +} + +// Release creates the release tag from CHANGELOG.md. It reads the version from the top +// `## [X.Y.Z]` heading and creates a matching annotated git tag (vX.Y.Z) on HEAD, so the tag and +// the changelog can never disagree. It does not push: pushing the tag is the point of no return +// for a release, so that stays an explicit step (manual locally, or a dedicated CI step). +// +// It is idempotent: if the changelog version is already tagged it does nothing, so it is safe to +// trigger on every merge to 'main' and only tags when the changelog carries a new version. Run it +// after the changelog PR has merged (see docs/developer/how-to/releasing.md). +func Release() error { + mageutil.MagePrintln(mageutil.MsgStart, "Preparing release tag from CHANGELOG.md...") + + version, err := latestChangelogVersion() + if err != nil { + return mageutil.PrintAndReturnError("Could not read the release version from CHANGELOG.md.", ErrRelease, err) + } + + tag := "v" + version + + exists, err := tagExists(tag) + if err != nil { + return mageutil.PrintAndReturnError("Could not check existing tags.", ErrRelease, err) + } + + if exists { + mageutil.MagePrintf(mageutil.MsgInfo, "Tag %#q already exists; nothing to release.\n", tag) + + return nil + } + + err = sh.Run("git", "tag", "-a", tag, "-m", tag) + if err != nil { + return mageutil.PrintAndReturnError(fmt.Sprintf("Failed to create tag %#q.", tag), ErrRelease, err) + } + + mageutil.MagePrintf(mageutil.MsgSuccess, "Created annotated tag %#q (matching CHANGELOG.md).\n", tag) + mageutil.MagePrintf(mageutil.MsgInfo, "Push it to publish the release: git push origin %s\n", tag) + mageutil.MagePrintf(mageutil.MsgInfo, "The tag is local only; to remove it before pushing: git tag -d %s\n", tag) + + return nil +} + +// latestChangelogVersion returns the version from the first `## [X.Y.Z]` heading in CHANGELOG.md, +// which is the release being prepared. +func latestChangelogVersion() (string, error) { + changelogPath := filepath.Join(mageutil.AzldevProjectDir(), "CHANGELOG.md") + + content, err := os.ReadFile(changelogPath) + if err != nil { + return "", fmt.Errorf("failed to read %#q:\n%w", changelogPath, err) + } + + // Match a Keep a Changelog version heading, e.g. "## [0.2.0] - 2026-06-25". + headingRe := regexp.MustCompile(`(?m)^##\s+\[(\d+\.\d+\.\d+)\]`) + + match := headingRe.FindStringSubmatch(string(content)) + if match == nil { + return "", fmt.Errorf("no '## [X.Y.Z]' version heading found in %#q", changelogPath) + } + + return match[1], nil +} + +// tagExists reports whether the given git tag already exists locally. +func tagExists(tag string) (bool, error) { + out, err := sh.Output("git", "tag", "--list", tag) + if err != nil { + return false, fmt.Errorf("failed to list git tags:\n%w", err) + } + + return strings.TrimSpace(out) != "", nil +} + +// gitCliffInstallHint builds the "git-cliff is missing" message, naming the version pinned in +// tools/git-cliff/Cargo.toml when it can be read. +func gitCliffInstallHint() string { + const base = "git-cliff not found on PATH. The pin lives in tools/git-cliff/Cargo.toml." + + if version := pinnedGitCliffVersion(); version != "" { + return fmt.Sprintf("%s Install it (e.g. 'cargo binstall git-cliff@%s' or 'brew install git-cliff'), "+ + "then re-run.", base, version) + } + + return base + " Install it (e.g. 'cargo binstall git-cliff' or 'brew install git-cliff'), then re-run." +} + +// bumpedVersion asks git-cliff for the version it would bump to next (without the leading "v"), +// or "" if it can't be determined (e.g. no eligible commits, or git-cliff fails). +func bumpedVersion(cliff, configPath string) string { + out, err := sh.Output(cliff, "--config", configPath, "--bumped-version") + if err != nil { + return "" + } + + return strings.TrimPrefix(strings.TrimSpace(out), "v") +} + +// pinnedGitCliffVersion returns the git-cliff version pinned in tools/git-cliff/Cargo.toml, or "" +// if it can't be determined. The pin lives there (not in Go) so Dependabot and security scanners +// can track and update it. +func pinnedGitCliffVersion() string { + cargoPath := filepath.Join(mageutil.AzldevProjectDir(), "tools", "git-cliff", "Cargo.toml") + + content, err := os.ReadFile(cargoPath) + if err != nil { + return "" + } + + match := regexp.MustCompile(`(?m)^\s*git-cliff\s*=\s*"=?(\d+\.\d+\.\d+)"`).FindStringSubmatch(string(content)) + if match == nil { + return "" + } + + return match[1] +} diff --git a/pkg/app/azldev_cli/azldev.go b/pkg/app/azldev_cli/azldev.go index 54872aa9..1f8782de 100644 --- a/pkg/app/azldev_cli/azldev.go +++ b/pkg/app/azldev_cli/azldev.go @@ -1,6 +1,11 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +// Package azldev_cli wires together and runs the azldev command-line application. +// +// It is the entry point used by the azldev command (see +// github.com/microsoft/azure-linux-dev-tools/cmd/azldev); end users should +// install and run that command rather than importing this package directly. package azldev_cli import ( @@ -18,6 +23,8 @@ import ( "github.com/microsoft/azure-linux-dev-tools/internal/app/azldev/cmds/version" ) +// Main constructs the azldev CLI application, runs it with the process +// arguments, and exits the process with the resulting status code. func Main() { // Instantiate the main CLI app instance. app := InstantiateApp() @@ -28,7 +35,8 @@ func Main() { os.Exit(ret) } -// Constructs a new instance of the main CLI application, with all subcommands registered. +// InstantiateApp constructs a new instance of the azldev CLI application with +// all subcommands registered. func InstantiateApp() *azldev.App { // Instantiate the main CLI application. app := azldev.NewApp(azldev.DefaultFileSystemFactory(), azldev.DefaultOSEnvFactory()) diff --git a/tools/README.md b/tools/README.md index b7a3fb17..b588f633 100644 --- a/tools/README.md +++ b/tools/README.md @@ -4,6 +4,8 @@ This directory contains a subdirectory for each golang-based build tool that thi We use separate modules (one per tool) to manage and isolate tools' dependencies. +> **Note:** Not every tool here is Go. [`git-cliff`](./git-cliff/) (used by `mage changelog`) is a Rust CLI, pinned via a `Cargo.toml` instead of a `go.mod` so Dependabot (cargo ecosystem) and security scanners can track and bump it. It is not installed via `go tool`; install it with `cargo`/`brew` (or in CI). + ## MCP Server (magemcp) The `magemcp` subdirectory contains an MCP (Model Context Protocol) server that exposes Mage build targets as tools for AI coding assistants. This allows AI agents to build, test, and check code quality in this repository. diff --git a/tools/git-cliff/Cargo.toml b/tools/git-cliff/Cargo.toml new file mode 100644 index 00000000..cb8a2b10 --- /dev/null +++ b/tools/git-cliff/Cargo.toml @@ -0,0 +1,14 @@ +# Pin for the git-cliff CLI used by `mage changelog` (and installed by CI). +# +# This crate is never compiled into azldev. It exists only so the git-cliff +# version is pinned in a manifest that Dependabot (cargo ecosystem) and security +# scanners can read and bump -- unlike a version hardcoded in Go. The `mage +# changelog` target reads this version for its install hint, and CI installs it. +[package] +name = "azldev-git-cliff-pin" +version = "0.0.0" +edition = "2021" +publish = false + +[dependencies] +git-cliff = "=2.13.1" diff --git a/tools/git-cliff/src/lib.rs b/tools/git-cliff/src/lib.rs new file mode 100644 index 00000000..8ad62a25 --- /dev/null +++ b/tools/git-cliff/src/lib.rs @@ -0,0 +1,3 @@ +//! Intentionally empty. This crate exists only to pin the git-cliff CLI version +//! in Cargo.toml so Dependabot and security scanners can track and bump it (see +//! Cargo.toml). Nothing here is built into azldev.