From b2f9b4550a08dfd29699536497438fa1279272ff Mon Sep 17 00:00:00 2001 From: Shrinath Rao Date: Tue, 9 Dec 2025 19:34:07 +0530 Subject: [PATCH] ci: add workflow to auto-label milestone issues on release (#9538) ## Summary Adds a new GitHub Actions workflow that automatically applies release version labels to milestone issues when a release is published. ## Changes - New workflow: `.github/workflows/release-label-applier.yml` - Triggers automatically after the Release workflow completes successfully - Can also be manually triggered via workflow_dispatch with a specific release tag ## Behavior When a release is created (e.g., `v0.30.0-rc1`): 1. Identifies the corresponding milestone (e.g., `v0.30.0`) 2. Finds all closed issues or issues with `Needs-Testing`/`Testing-InProgress` labels in that milestone 3. Applies the release tag as a label to those issues 4. Includes deduplication logic to prevent applying duplicate labels This helps track which specific release version an issue was shipped in, making it easier to identify what needs testing in each release. ## Permissions - `issues: write` - to add labels - `contents: read` - to checkout repository ## Test Plan - [ ] Workflow should trigger automatically on release completion - [ ] Manual trigger should work with provided release tag - [ ] Labels should be applied only to eligible issues - [ ] Duplicate labels should be prevented --- .github/workflows/release-label-applier.yml | 196 ++++++++++++++++++++ 1 file changed, 196 insertions(+) create mode 100644 .github/workflows/release-label-applier.yml diff --git a/.github/workflows/release-label-applier.yml b/.github/workflows/release-label-applier.yml new file mode 100644 index 0000000000..44345a8ccc --- /dev/null +++ b/.github/workflows/release-label-applier.yml @@ -0,0 +1,196 @@ +name: Release Label Applier + +on: + # Auto-trigger when Release workflow completes successfully + workflow_run: + workflows: ["Release"] + types: + - completed + + # Manual trigger option + workflow_dispatch: + inputs: + release_tag: + description: 'Release tag to apply (e.g., v0.30.0-rc1)' + required: true + type: string + +jobs: + apply-labels: + name: Apply release labels to milestone issues + runs-on: ubuntu-latest + # Only run if the Release workflow succeeded + if: ${{ github.event_name == 'workflow_dispatch' || github.event.workflow_run.conclusion == 'success' }} + + permissions: + issues: write + contents: read + + steps: + - name: Checkout repository + uses: actions/checkout@v5 + + - name: Apply labels to milestone issues + uses: actions/github-script@v7 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + // Get the release tag + let releaseTag; + if (context.eventName === 'workflow_dispatch') { + releaseTag = '${{ inputs.release_tag }}'; + } else { + // Get the tag from the completed workflow + const runId = context.payload.workflow_run.id; + const workflowRun = await github.rest.actions.getWorkflowRun({ + owner: context.repo.owner, + repo: context.repo.repo, + run_id: runId + }); + + // Extract tag from head_ref (more reliable for tag-triggered workflows) + // head_ref contains the tag name directly (e.g., "v0.30.0-rc1") + const headRef = workflowRun.data.head_ref; + if (!headRef) { + core.setFailed('Could not determine release tag from workflow run'); + return; + } + releaseTag = headRef; + } + + console.log(`šŸ“¦ Processing release tag: ${releaseTag}`); + + // Extract milestone version from tag + // Examples: v0.30.0-rc1 -> v0.30.0, v0.30.1 -> v0.30.0, v0.30.0 -> v0.30.0 + // Patch releases map to their minor version milestone (e.g., v0.30.1 -> v0.30.0) + const milestoneMatch = releaseTag.match(/^v(\d+)\.(\d+)\.(\d+)/); + if (!milestoneMatch) { + core.setFailed(`Invalid tag format: ${releaseTag}. Expected format: vX.Y.Z[-suffix]`); + return; + } + + // Construct milestone version with patch set to 0 + const milestoneVersion = `v${milestoneMatch[1]}.${milestoneMatch[2]}.0`; + console.log(`šŸŽÆ Target milestone: ${milestoneVersion}`); + + // Find the milestone + const milestones = await github.rest.issues.listMilestones({ + owner: context.repo.owner, + repo: context.repo.repo, + state: 'all' + }); + + const milestone = milestones.data.find(m => m.title === milestoneVersion); + if (!milestone) { + core.setFailed(`Milestone "${milestoneVersion}" not found`); + return; + } + + console.log(`āœ“ Found milestone: ${milestone.title} (ID: ${milestone.number})`); + + // Get all issues in the milestone + const allIssues = []; + let page = 1; + while (true) { + const issues = await github.rest.issues.listForRepo({ + owner: context.repo.owner, + repo: context.repo.repo, + milestone: milestone.number, + state: 'all', + per_page: 100, + page: page + }); + + if (issues.data.length === 0) break; + allIssues.push(...issues.data); + page++; + } + + console.log(`šŸ“‹ Found ${allIssues.length} total issues/PRs in milestone`); + + // Filter issues that need labeling + const targetLabels = ['Needs-Testing', 'Testing-InProgress']; + const issuesToLabel = allIssues.filter(issue => { + // Include if: closed OR has target labels + const isClosed = issue.state === 'closed'; + const hasTargetLabel = issue.labels.some(label => + targetLabels.includes(label.name) + ); + + return isClosed || hasTargetLabel; + }); + + console.log(`šŸŽÆ ${issuesToLabel.length} issues match criteria (closed or have Needs-Testing/Testing-InProgress labels)`); + + // Apply labels (with deduplication check) + let labeled = 0; + let skipped = 0; + const milestonePattern = new RegExp(`^${milestoneVersion.replace(/\./g, '\\.')}(-|$)`); + + for (const issue of issuesToLabel) { + // Check if issue already has a label from this milestone + const hasExistingMilestoneLabel = issue.labels.some(label => + milestonePattern.test(label.name) + ); + + if (hasExistingMilestoneLabel) { + const existingLabel = issue.labels.find(l => milestonePattern.test(l.name)); + console.log(`ā­ļø Skipping #${issue.number} - already has label: ${existingLabel.name}`); + skipped++; + continue; + } + + // Check if the label already exists on the issue (shouldn't happen, but safety check) + if (issue.labels.some(label => label.name === releaseTag)) { + console.log(`ā­ļø Skipping #${issue.number} - already has exact label: ${releaseTag}`); + skipped++; + continue; + } + + // Create label if it doesn't exist in the repo + try { + await github.rest.issues.createLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + name: releaseTag, + color: '0E8A16', // Green color for releases + description: `Released in ${releaseTag}` + }); + console.log(`āœ“ Created label: ${releaseTag}`); + } catch (error) { + if (error.status !== 422) { // 422 means label already exists + console.log(`āš ļø Error creating label: ${error.message}`); + } + } + + // Apply label to issue + try { + await github.rest.issues.addLabels({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issue.number, + labels: [releaseTag] + }); + console.log(`āœ“ Added label "${releaseTag}" to #${issue.number}: ${issue.title}`); + labeled++; + } catch (error) { + console.log(`āŒ Failed to label #${issue.number}: ${error.message}`); + } + } + + // Summary + console.log('\nšŸ“Š Summary:'); + console.log(` Release Tag: ${releaseTag}`); + console.log(` Milestone: ${milestoneVersion}`); + console.log(` Total issues in milestone: ${allIssues.length}`); + console.log(` Issues matching criteria: ${issuesToLabel.length}`); + console.log(` āœ“ Labeled: ${labeled}`); + console.log(` ā­ļø Skipped (already labeled): ${skipped}`); + + core.summary + .addHeading('Release Label Application Summary') + .addRaw(`**Release Tag:** ${releaseTag}`, true) + .addRaw(`**Milestone:** ${milestoneVersion}`, true) + .addRaw(`**Issues Labeled:** ${labeled}`, true) + .addRaw(`**Issues Skipped:** ${skipped} (already had milestone release label)`, true) + .write();