From 3f44fcaf025329b3b8cf25c6fd64a00cd42a2dbe Mon Sep 17 00:00:00 2001 From: Michael Brooks Date: Fri, 28 Aug 2026 16:40:21 -0700 Subject: [PATCH 1/3] ci: triage the GitHub milestone after a release The "Next Release" milestone is renamed to the published release tag and closed, while a new "Next Release" milestone gathers the issues and pull requests that remain open. Items without a milestone are left alone. This was a manual step of the release runbook. The logic lives in scripts/triage-milestone.sh so a maintainer can rehearse it with DRY_RUN or run it by hand, and completed steps are skipped so an interrupted run is safe to repeat. The releaser app needed a new Issues permission to write milestones. --- .github/MAINTAINERS_GUIDE.md | 22 ++++- .github/workflows/milestone.yml | 60 ++++++++++++ Makefile | 5 + scripts/triage-milestone-test.sh | 47 ++++++++++ scripts/triage-milestone.sh | 151 +++++++++++++++++++++++++++++++ 5 files changed, 282 insertions(+), 3 deletions(-) create mode 100644 .github/workflows/milestone.yml create mode 100755 scripts/triage-milestone-test.sh create mode 100755 scripts/triage-milestone.sh diff --git a/.github/MAINTAINERS_GUIDE.md b/.github/MAINTAINERS_GUIDE.md index be775f1e..b71f5e1d 100644 --- a/.github/MAINTAINERS_GUIDE.md +++ b/.github/MAINTAINERS_GUIDE.md @@ -192,7 +192,8 @@ pkg/ ### `scripts/` -Installation and setup scripts for various operating systems are available here. +Installation and setup scripts for various operating systems are available here, +along with scripts that support the release process. ### `test/` @@ -526,15 +527,20 @@ For these changes to complete, certain application permissions are needed: - **Actions**: Read and write - **Contents**: Read and write +- **Issues**: Read and write - **Metadata**: Read - **Pull requests**: Read and write - **Workflows**: Read and write +The **Issues** permission is needed to create, rename, and close milestones. +Note that adding a permission to the app is not enough on its own, since each +existing installation must also be granted the new permission. + Access to this project is also required with the selected application scopes. Credentials and secrets for the app can be stored as the following variables: -- `GH_APP_ID_RELEASER` +- `GH_APP_CLIENT_ID_RELEASER` - `GH_APP_PRIVATE_KEY_RELEASER` #### Bumping Go package versions @@ -800,7 +806,16 @@ Steps to triage a pull request: 4. **Milestone**: - A milestone should be assigned when possible, usually as the `Next Release` - After a release, the `Next Release` milestone is renamed to the tagged - version + version and closed, while a new `Next Release` milestone gathers the issues + and pull requests that remain open + - This happens automatically with [this workflow][wf-milestone] when a + production release is published, but can also be done by hand: + ```sh + $ DRY_RUN=true ./scripts/triage-milestone.sh v4.8.0 + $ ./scripts/triage-milestone.sh v4.8.0 + ``` + - The `Next Release` milestone must keep this exact name because release and + dependency automation assigns pull requests to it by title #### Pull request: merge @@ -853,3 +868,4 @@ When in doubt, find the other maintainers and ask. [sync]: https://github.com/slackapi/slack-cli/blob/main/.github/workflows/sync-docs-from-cli-repo.yml [vscode]: https://github.com/slackapi/slack-cli/blob/main/.vscode/settings.json [wf-dependencies]: ./workflows/dependencies.yml +[wf-milestone]: ./workflows/milestone.yml diff --git a/.github/workflows/milestone.yml b/.github/workflows/milestone.yml new file mode 100644 index 00000000..a104381d --- /dev/null +++ b/.github/workflows/milestone.yml @@ -0,0 +1,60 @@ +name: Milestone + +on: + release: + types: + - published + workflow_dispatch: + inputs: + tag: + description: The release tag to triage + required: true + type: string + +concurrency: ${{ github.workflow }} + +jobs: + triage: + name: Triage + # Development and feature builds are published as prereleases by CircleCI + if: >- + github.event_name == 'workflow_dispatch' || + (github.event.release.prerelease == false && github.event.release.draft == false) + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Gather credentials + id: credentials + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + with: + client-id: ${{ secrets.GH_APP_CLIENT_ID_RELEASER }} + private-key: ${{ secrets.GH_APP_PRIVATE_KEY_RELEASER }} + + - name: Checkout repo + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Decide the release tag + id: release + env: + TAG: ${{ inputs.tag || github.event.release.tag_name }} + run: | + if [[ ! "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "The $TAG tag is not a production release, nothing to triage" + echo "should_triage=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + + echo "should_triage=true" >> "$GITHUB_OUTPUT" + echo "tag=$TAG" >> "$GITHUB_OUTPUT" + echo "Triaging the milestone of $TAG" + + - name: Triage the milestone + if: steps.release.outputs.should_triage == 'true' + env: + GH_TOKEN: ${{ steps.credentials.outputs.token }} + REPO: ${{ github.repository }} + TAG: ${{ steps.release.outputs.tag }} + run: ./scripts/triage-milestone.sh "$TAG" diff --git a/Makefile b/Makefile index 907e8540..22070685 100644 --- a/Makefile +++ b/Makefile @@ -44,6 +44,11 @@ test-install: clean bash scripts/install-test.sh bash scripts/install-dev-test.sh +# Run milestone triage script tests +.PHONY: test-milestone +test-milestone: + bash scripts/triage-milestone-test.sh + # Report test coverage .PHONY: coverage coverage: diff --git a/scripts/triage-milestone-test.sh b/scripts/triage-milestone-test.sh new file mode 100755 index 00000000..ffe08376 --- /dev/null +++ b/scripts/triage-milestone-test.sh @@ -0,0 +1,47 @@ +#!/usr/bin/env bash +# Copyright 2022-2026 Salesforce, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +set -euxo pipefail + +# +# USAGE: +# ./scripts/triage-milestone-test.sh +# +# EXAMPLES: +# $ ./scripts/triage-milestone-test.sh +# +# DESCRIPTION: +# Confirm the milestone triage script rejects unexpected release tags. +# +# Only the checks that happen before a GitHub API call are covered, so no +# credentials or network access are needed. + +TRIAGE_SCRIPT="$(dirname "$0")/triage-milestone.sh" + +# Lint +bash -n "$TRIAGE_SCRIPT" + +# Missing release tag +if bash "$TRIAGE_SCRIPT" >/dev/null 2>&1; then + echo "Error: A missing release tag should exit with an error" + exit 1 +fi + +# Release tags of an unexpected format +for tag in "4.8.0" "v4.8" "v4.8.0.1" "v4.8.0-example-feature" "dev-build" ""; do + if bash "$TRIAGE_SCRIPT" "$tag" >/dev/null 2>&1; then + echo "Error: The '$tag' release tag should exit with an error" + exit 1 + fi +done diff --git a/scripts/triage-milestone.sh b/scripts/triage-milestone.sh new file mode 100755 index 00000000..3119cf75 --- /dev/null +++ b/scripts/triage-milestone.sh @@ -0,0 +1,151 @@ +#!/usr/bin/env bash +# Copyright 2022-2026 Salesforce, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +set -euo pipefail + +# +# USAGE: +# ./scripts/triage-milestone.sh +# +# EXAMPLES: +# Triage the milestone after the v4.8.0 release is published: +# +# $ ./scripts/triage-milestone.sh v4.8.0 +# +# Print the changes without making them: +# +# $ DRY_RUN=true ./scripts/triage-milestone.sh v4.8.0 +# +# Rehearse on a scratch repository before a real release: +# +# $ REPO=example/scratch ./scripts/triage-milestone.sh v0.1.0 +# +# DESCRIPTION: +# Roll the "Next Release" milestone over to a published release tag. +# +# The "Next Release" milestone is renamed to the release tag and closed while +# a new "Next Release" milestone collects the issues and pull requests that +# remain open. Merged and closed items stay on the release tag milestone and +# items without a milestone are left alone. +# +# The gh command is required, along with a GH_TOKEN that can write issues. +# +# Completed steps are skipped, so an interrupted run is repeated safely. + +REPO=${REPO:-slackapi/slack-cli} +DRY_RUN=${DRY_RUN:-false} +UPCOMING_TITLE="Next Release" + +main() { + if [ $# -lt 1 ]; then + echo "Missing parameters: $0 " + exit 1 + fi + + TAG=${1} + + if [[ ! "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "Error: The '$TAG' tag is not a production release tag of vX.Y.Z" + exit 1 + fi + + if ! command -v gh >/dev/null 2>&1; then + echo "Error: The gh command is required but was not found" + exit 1 + fi + + echo "Triaging the \"$UPCOMING_TITLE\" milestone of $REPO for $TAG" + + # The release tag milestone might exist if an earlier run was interrupted + released=$(find_milestone "$TAG" "all") + upcoming="" + + if [ -z "$released" ]; then + released=$(find_milestone "$UPCOMING_TITLE" "open") + if [ -z "$released" ]; then + echo "-> No \"$UPCOMING_TITLE\" milestone was found, so nothing is triaged" + exit 0 + fi + echo "-> Renaming milestone #$released to $TAG" + edit_milestone "$released" "title=$TAG" + else + echo "-> Milestone $TAG exists as #$released" + upcoming=$(find_milestone "$UPCOMING_TITLE" "open") + fi + + if [ -z "$upcoming" ]; then + echo "-> Creating the next \"$UPCOMING_TITLE\" milestone" + upcoming=$(create_milestone "$UPCOMING_TITLE") + else + echo "-> Milestone \"$UPCOMING_TITLE\" exists as #$upcoming" + fi + + echo "-> Moving open issues and pull requests to \"$UPCOMING_TITLE\"" + move_open_issues "$released" "$upcoming" + + echo "-> Closing milestone $TAG" + edit_milestone "$released" "state=closed" + + echo "Triaged the milestone of $TAG" +} + +# Check if changes should be printed instead of made +is_dry_run() { + [ "$DRY_RUN" = "true" ] +} + +# Output the number of the milestone matching a title and state, if one exists +find_milestone() { + local numbers + numbers=$(gh api "repos/$REPO/milestones?state=${2}&per_page=100" --paginate \ + --jq ".[] | select(.title == \"${1}\") | .number") + echo "$numbers" | head -n 1 +} + +# Create a milestone with a title and output the new milestone number +create_milestone() { + if is_dry_run; then + echo "0" + return + fi + gh api --method POST "repos/$REPO/milestones" -f "title=${1}" --jq ".number" +} + +# Change a single field of an existing milestone +edit_milestone() { + if is_dry_run; then + echo " Skipping the '${2}' change of milestone #${1}" + return + fi + gh api --method PATCH "repos/$REPO/milestones/${1}" -f "${2}" --silent +} + +# Assign the open issues and pull requests of a milestone to another milestone +# +# Both issues and pull requests are returned by the issues endpoint, which is +# preferred over a search because search results are not immediately current. +move_open_issues() { + local number + for number in $(gh api "repos/$REPO/issues?milestone=${1}&state=open&per_page=100" \ + --paginate --jq ".[].number"); do + if is_dry_run; then + echo " Skipping the milestone change of #$number" + continue + fi + echo " #$number" + gh api --method PATCH "repos/$REPO/issues/$number" -F "milestone=${2}" --silent + done +} + +main "$@" From dd6964446ac213fddc80a8180812628f1042a1ce Mon Sep 17 00:00:00 2001 From: Michael Brooks Date: Fri, 28 Aug 2026 16:51:01 -0700 Subject: [PATCH 2/3] ci: name the milestone job for the checks list --- .github/workflows/milestone.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/milestone.yml b/.github/workflows/milestone.yml index a104381d..03706b2c 100644 --- a/.github/workflows/milestone.yml +++ b/.github/workflows/milestone.yml @@ -15,7 +15,7 @@ concurrency: ${{ github.workflow }} jobs: triage: - name: Triage + name: Triage Milestone # Development and feature builds are published as prereleases by CircleCI if: >- github.event_name == 'workflow_dispatch' || From 5c913897d065755520ceb0b0413168532719dc70 Mon Sep 17 00:00:00 2001 From: Michael Brooks Date: Fri, 28 Aug 2026 17:00:14 -0700 Subject: [PATCH 3/3] ci: drop the milestone test make target --- Makefile | 5 ----- 1 file changed, 5 deletions(-) diff --git a/Makefile b/Makefile index 22070685..907e8540 100644 --- a/Makefile +++ b/Makefile @@ -44,11 +44,6 @@ test-install: clean bash scripts/install-test.sh bash scripts/install-dev-test.sh -# Run milestone triage script tests -.PHONY: test-milestone -test-milestone: - bash scripts/triage-milestone-test.sh - # Report test coverage .PHONY: coverage coverage: