Skip to content

Automate milestone closure on stable and pre-release publishes #833

@WilliamBerryiii

Description

@WilliamBerryiii

Summary

Add close-milestone jobs to both release workflows so that GitHub milestones are automatically closed when a release ships. Milestones currently remain open indefinitely after releases publish, requiring manual intervention. v3.1.0's milestone was discovered open with 46 items after v3.1.46 had already published.

Motivation

  • Milestones remain open after releases ship, creating stale sprint tracking artifacts.
  • Manual milestone closure is error-prone and easy to forget.
  • Automating this eliminates a recurring housekeeping step from the sprint workflow.

Requirements

Stable Release Channel (main.yml)

Add a close-milestone job after the release-please job that:

  1. Runs when release_created == 'true'.
  2. Maps the release-please version output (e.g., 3.2.0) to milestone title v3.2.0.
  3. Closes the matching open milestone via gh api.
  4. Logs an informational message and exits cleanly if no matching milestone exists.

Placement: Parallel with reset-prerelease and other post-release jobs. No dependency on artifact packaging jobs.

Pre-Release Channel (prerelease-release.yml)

Add a close-milestone job after the prerelease-tag job that:

  1. Runs after prerelease-tag succeeds.
  2. Extracts MAJOR and MINOR from the version output (e.g., 3.3.12) and constructs milestone title v{MAJOR}.{MINOR}.0 (e.g., v3.3.0).
  3. Closes the matching open milestone via gh api.
  4. Logs an informational message and exits cleanly if no matching milestone exists.

Placement: Parallel with extension-package-prerelease. No dependency on artifact packaging.

Pre-Release Idempotency

Pre-release milestones close on the first prerelease/next PR merge for that minor version. Subsequent pre-releases under the same minor are a no-op because the state=open filter excludes already-closed milestones.

Permissions and Security

  • Both jobs use ${{ github.token }} (default GITHUB_TOKEN) — no new secrets or GitHub App tokens required.
  • Job-level permissions: issues: write scoped to only the new jobs, following least-privilege conventions already used in both workflow files.
  • No changes to existing job permissions or dependencies.

Implementation Detail

Stable Release Job (main.yml)

close-milestone:
  name: Close Release Milestone
  needs: [release-please]
  if: ${{ needs.release-please.outputs.release_created == 'true' }}
  runs-on: ubuntu-latest
  permissions:
    issues: write
  steps:
    - name: Close milestone for released version
      env:
        GH_TOKEN: ${{ github.token }}
        VERSION: ${{ needs.release-please.outputs.version }}
      run: |
        REPO="${{ github.repository }}"
        TITLE="v${VERSION}"

        NUMBER=$(gh api "/repos/$REPO/milestones?state=open&per_page=100" \
          --jq ".[] | select(.title == \"$TITLE\") | .number")

        if [ -n "$NUMBER" ]; then
          gh api "/repos/$REPO/milestones/$NUMBER" \
            -X PATCH -f state=closed
          echo "✅ Closed milestone $TITLE (#$NUMBER)"
        else
          echo "ℹ️ No open milestone found matching '$TITLE' — skipping"
        fi

Pre-Release Job (prerelease-release.yml)

close-milestone:
  name: Close Pre-Release Milestone
  needs: [prerelease-tag]
  runs-on: ubuntu-latest
  permissions:
    issues: write
  steps:
    - name: Close milestone for pre-release version
      env:
        GH_TOKEN: ${{ github.token }}
        VERSION: ${{ needs.prerelease-tag.outputs.version }}
      run: |
        REPO="${{ github.repository }}"
        MAJOR=$(echo "$VERSION" | cut -d. -f1)
        MINOR=$(echo "$VERSION" | cut -d. -f2)
        TITLE="v${MAJOR}.${MINOR}.0"

        NUMBER=$(gh api "/repos/$REPO/milestones?state=open&per_page=100" \
          --jq ".[] | select(.title == \"$TITLE\") | .number")

        if [ -n "$NUMBER" ]; then
          gh api "/repos/$REPO/milestones/$NUMBER" \
            -X PATCH -f state=closed
          echo "✅ Closed milestone $TITLE (#$NUMBER)"
        else
          echo "ℹ️ No open milestone found matching '$TITLE' — skipping"
        fi

Edge Cases

Scenario Expected Behavior
Milestone does not exist for the released version No-op with informational log message
Milestone already closed state=open filter excludes it; no-op
Milestone has open issues/PRs remaining Milestone closes; open items remain under the closed milestone
Multiple milestones with the same title First match is closed (unlikely given naming convention)
Version v4.0.0 with no preceding pre-release Stable job closes v4.0.0 only
Pre-release v3.3.12 ships but v3.3.0 milestone absent No-op with log message
per_page=100 pagination Safe for foreseeable future (current count ~6 milestones)

Scope Boundaries

In scope:

  • Milestone close automation for both release channels

Out of scope (potential follow-ups):

  • Issue cascading: automatically moving open items from a closed milestone to the next open one
  • Milestone creation automation: auto-creating the next milestone when one closes
  • Milestone due date enforcement

Files Changed

File Change
.github/workflows/main.yml Add close-milestone job (~18 lines)
.github/workflows/prerelease-release.yml Add close-milestone job (~20 lines)

No changes to prerelease.yml, release-please-config.json, .release-please-manifest.json, scripts, instructions, or documentation.

Acceptance Criteria

  • A stable release of v3.2.0 closes the v3.2.0 milestone if it exists
  • A pre-release publish of v3.3.x closes the v3.3.0 milestone if it exists
  • No error when the target milestone does not exist
  • No new secrets or App tokens required
  • No changes to existing job dependencies or release artifact flows
  • Job-level permissions scoped to issues: write only

Metadata

Metadata

Labels

automationCI/CD and automation improvementsenhancementNew feature or requestgithub-actionsGitHub Actions workflowsworkflowsGitHub Actions workflows

Projects

No projects

Milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions