diff --git a/.github/getgotools/action.yml b/.github/getgotools/action.yml index 1dca0461..3baabe2e 100644 --- a/.github/getgotools/action.yml +++ b/.github/getgotools/action.yml @@ -1,15 +1,17 @@ -name: 'Get go tools' -description: 'Install go and related go tools' +name: "Get go tools" +description: "Install go and related go tools" runs: - using: 'composite' + using: "composite" steps: - - name: Install go - uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6.2.0 - with: - go-version-file: 'go.mod' + - name: Install go + uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6.2.0 + with: + go-version-file: "go.mod" - - name: Build and install mage - shell: bash - run: | - echo "Building and installing mage..." - go install github.com/magefile/mage@latest + - name: Build and install mage + shell: bash + run: | + echo "Building and installing mage..." + # No @version: inside the module this installs the mage version pinned in + # go.mod, so Dependabot keeps it current from a single source of truth. + go install github.com/magefile/mage diff --git a/.github/workflows/prepare-release.yml b/.github/workflows/prepare-release.yml new file mode 100644 index 00000000..1eb67648 --- /dev/null +++ b/.github/workflows/prepare-release.yml @@ -0,0 +1,117 @@ +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: + # Always draft from the repo's default branch (main upstream), not the + # branch this workflow_dispatch happened to be launched from. + ref: ${{ github.event.repository.default_branch }} + # 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/^##[[:space:]]\+\[\([0-9]\+\.[0-9]\+\.[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}" + + # A release branch for this version already exists (e.g. a prior prepare-release run + # that hasn't been merged or cleaned up). Fail rather than clobber it -- but leave a + # summary with the PR link so recovery is a click away. ($GITHUB_STEP_SUMMARY still + # renders even though the step exits non-zero.) + if git ls-remote --exit-code --heads origin "refs/heads/${branch}" >/dev/null 2>&1; then + pr_url="${SERVER_URL}/${REPO}/pull/new/${branch}" + { + echo "### Release branch already exists" + echo "" + echo "Branch \`${branch}\` already exists, so no new draft was created." + echo "" + echo "**[Open its pull request →](${pr_url})** to finish the release, or delete the" + echo "branch and re-run this workflow to regenerate the draft from the latest \`main\`." + } >> "$GITHUB_STEP_SUMMARY" + echo "Release branch '${branch}' already exists; open its PR or delete it and re-run." >&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}" + gh auth setup-git + git push origin "$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..45b2702f --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,85 @@ +name: "Release" + +# Runs when a push to main changes CHANGELOG.md -- i.e. when a release PR merges. +# Tags the new version via the idempotent `mage release`, pushes the tag, and +# publishes a GitHub Release with that version's changelog notes. Still idempotent: +# if the top version is already tagged (e.g. a later CHANGELOG.md edit) it's a +# no-op. See docs/developer/how-to/releasing.md. +on: + push: + branches: [main] + paths: + - CHANGELOG.md + +# 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/^##[[:space:]]\+\[\([0-9]\+\.[0-9]\+\.[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 + gh auth setup-git + git push origin "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). + # Match only numeric version headings so this stays in sync with the version parsed + # above and can never capture a non-release heading. Tolerate any whitespace after '##' + # to match `mage release`. + notes="${RUNNER_TEMP}/release-notes.md" + awk '/^##[[:space:]]+\[[0-9]/{n++; next} n==1' CHANGELOG.md > "${notes}" + if [ ! -s "${notes}" ]; then + echo "no changelog notes found for ${tag}" >&2 + exit 1 + fi + gh release create "${tag}" --repo "${REPO}" --title "${tag}" --notes-file "${notes}" + echo "Published release \`${tag}\` with notes from CHANGELOG.md." >> "$GITHUB_STEP_SUMMARY" diff --git a/docs/developer/how-to/releasing.md b/docs/developer/how-to/releasing.md index 2a753763..b02d0d28 100644 --- a/docs/developer/how-to/releasing.md +++ b/docs/developer/how-to/releasing.md @@ -5,6 +5,22 @@ This guide covers releasing the `azldev` Go module so that ## 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. Edit the draft changelog into user-facing notes, then merge the PR to `main`. Consider using '@copilot Update the new changelog section into user-facing release notes' in GitHub to help rewrite the changelog. +4. On merge, the [**release** workflow][release-run] tags `vX.Y.Z` and publishes + a GitHub Release from the changelog — 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 @@ -18,10 +34,15 @@ mage release # creates annotated tag vX.Y.Z on HEAD (does no # 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 +git push origin vX.Y.Z # pushing the tag publishes the version to the Go proxy + +# 3. Create the GitHub Release with this version's changelog notes +gh release create vX.Y.Z --title vX.Y.Z \ + --notes-file <(awk '/^##[[:space:]]+\[[0-9]/{n++; next} n==1' CHANGELOG.md) ``` -Each step is explained in full under [Cut a release](#cut-a-release) below. +Each manual step is explained in full under [Cut a release](#cut-a-release) +below. ## Versioning policy @@ -75,21 +96,22 @@ pkg.go.dev fetch directly from this repository's Git tags: `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. - This is harmless and only triggers indexing of an already-public tag: +4. (Optional) The proxy and pkg.go.dev index a new version on the first request, + so nothing is required here. To make a release discoverable immediately, warm + them by hand — this only triggers indexing of an already-public tag: ```console GOPROXY=https://proxy.golang.org go list \ - -m github.com/microsoft/azure-linux-dev-tools@v0.1.1 + -m github.com/microsoft/azure-linux-dev-tools@vX.Y.Z ``` Then visit - `https://pkg.go.dev/github.com/microsoft/azure-linux-dev-tools@v0.1.1` once to + `https://pkg.go.dev/github.com/microsoft/azure-linux-dev-tools@vX.Y.Z` once to prompt the docs build. -5. (Optional, recommended) Create a GitHub Release for the tag and paste the new - `CHANGELOG.md` section as the release notes. A GitHub Release is separate from - the Git tag, so you can add notes even to a tag that already exists. +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 @@ -123,12 +145,47 @@ cargo binstall git-cliff # or: cargo install git-cliff --locked, or: brew inst > 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 the repo's default branch (`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 pushes to `main` + that change `CHANGELOG.md`): runs `mage release`, pushes the tag, and publishes + a GitHub Release whose notes are that version's `CHANGELOG.md` section. The path + filter keeps ordinary merges from triggering it; it also stays idempotent — a + `CHANGELOG.md` edit that doesn't bump the version is a no-op because the top + version is already tagged. + +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. +### If the release workflow fails partway + +The [release workflow][release-run] is idempotent: it skips the tag push and the +GitHub Release when they already exist, so the fix is usually just to **re-run it +before pushing anything else to `main`**. The tag is created on whatever commit +is at the top of `main` at run time, so if an unrelated commit lands before you +re-run, the tag could point at it instead of the release commit. If that happens +— and no one has fetched `module@vX.Y.Z` through the proxy yet — delete the +remote tag and GitHub Release, then re-run. Once the proxy has served the +version it is immutable; retract it instead (below). + +### Withdrawing a published version + +A version becomes immutable the first time the [Go module proxy][proxy] serves +`module@vX.Y.Z` — after that you cannot delete or move it. 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 @@ -141,3 +198,5 @@ retract ( [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