added pr automation workflows from developer (#1568)
Build on RHEL9 docker image / build (push) Successful in 3m33s
Build on RHEL8 docker image / build (push) Successful in 4m41s
Run Simulator Tests on local RHEL9 / build (push) Failing after 9m9s
Run Simulator Tests on local RHEL8 / build (push) Successful in 22m20s

This commit is contained in:
maliakal_d authored and GitHub committed 2026-09-21 14:15:48 +02:00
1 parent 094d0cbb1f
commit dba175e986
7 files changed
+1453

No files matched your search

+260
View File
@@ -0,0 +1,260 @@
# This reusable workflow detects detector labels for pull requests. This workflow is ignored for Infrastructure Prs. It checks whole word matching of detector names in server folders, changed code and PR title/description. If a detector is detected, the corresponding label is added to the PR. Existing labels are left untouched. Detector labels are never removed automatically.
name: PR Detector Labels
on:
workflow_call:
inputs:
pr_number:
required: true
type: number
permissions:
pull-requests: write
issues: read
jobs:
detector-labels:
name: Detect detector labels
runs-on: ubuntu-latest
steps:
- name: Detect detector labels
uses: actions/github-script@v8
with:
script: |
const prNumber = ${{ inputs.pr_number }};
/*
* Get the current PR labels.
*/
const { data: currentLabels } =
await github.rest.issues.listLabelsOnIssue({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: prNumber
});
const currentLabelNames =
currentLabels.map(label => label.name);
if (currentLabelNames.includes('Infrastructure')) {
core.info(
'Infrastructure label found. Skipping detector label detection.'
);
return;
}
core.info(
`Infrastructure label not found. Proceeding with detector label detection.`
);
/*
* Known detector labels.
*/
const detectorLabels = [
'Ctb',
'Eiger',
'Moench',
'Mythen3',
'Jungfrau',
'Gotthard2',
'Matterhorn',
'Xilinx_ctb'
];
/*
* Check whether a detector name appears as a
* complete word, case-insensitively.
*/
function containsDetector(text, detector) {
const regex = new RegExp(
`\\b${detector}\\b`,
'i'
);
return regex.test(text);
}
/*
* Detectors detected by any detection method.
*/
const detectedDetectors = new Set();
/*
* --------------------------------------------------
* 1. Get all files changed by the PR
* --------------------------------------------------
*/
const files = await github.paginate(
github.rest.pulls.listFiles,
{
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: prNumber,
per_page: 100
}
);
core.info(
`Found ${files.length} changed files.`
);
/*
* --------------------------------------------------
* 2. Detect detector from changed files
* --------------------------------------------------
*
* We inspect each file once and short-circuit as soon
* as a detector-server folder confirms a label. That
* avoids scanning the same detector folder again and
* avoids checking the rest of the file for unrelated
* detector names.
*
* Examples:
*
* slsDetectorServers/eigerDetectorServer/...
* ↓
* Eiger
*
* slsDetectorServers/xilinx_ctbDetectorServer/...
* ↓
* Xilinx_ctb
*/
const ignoredDetectorServerFolders = new Set();
for (const file of files) {
const serverMatch = file.filename.match(
/^slsDetectorServers\/([^/]+)DetectorServer\//
);
if (serverMatch) {
const detectorFolder = serverMatch[0];
// Skip all later files under a detected detector folder.
if (ignoredDetectorServerFolders.has(detectorFolder)) {
core.info(
`Skipping file in ignored detector-server folder: ${file.filename}`
);
continue;
}
const detectorName = serverMatch[1];
/*
* Remove "DetectorServer" and capitalize
* the first character.
*/
const detector =
detectorName.charAt(0).toUpperCase() +
detectorName.slice(1);
if (detectorLabels.includes(detector)) {
detectedDetectors.add(detector);
core.info(
`Detected ${detector} from detector-server path: ${file.filename}`
);
// Once a detector is confirmed from the server folder,
// do not inspect this file or the rest of that folder.
ignoredDetectorServerFolders.add(detectorFolder);
continue;
}
}
// Only inspect the PR patch, never the full file.
if (!file.patch) {
continue;
}
for (const detector of detectorLabels) {
if (
containsDetector(
file.patch,
detector
)
) {
detectedDetectors.add(detector);
core.info(
`Detected ${detector} in changed code: ${file.filename}`
);
}
}
}
/*
* --------------------------------------------------
* 3. Detect detector from PR title/description
* --------------------------------------------------
*/
const { data: pr } =
await github.rest.pulls.get({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: prNumber
});
const prText = [
pr.title || '',
pr.body || ''
].join('\n');
for (const detector of detectorLabels) {
if (
containsDetector(
prText,
detector
)
) {
detectedDetectors.add(detector);
core.info(
`Detected ${detector} in PR title or description.`
);
}
}
/*
* --------------------------------------------------
* 5. Add detected detector labels
* --------------------------------------------------
*
* Existing labels are left untouched.
*
* Detector labels are NEVER removed automatically.
*/
for (const detector of detectedDetectors) {
if (!currentLabelNames.includes(detector)) {
await github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: prNumber,
labels: [detector]
});
core.info(
`Added detector label: ${detector}`
);
} else {
core.info(
`Detector label already exists: ${detector}`
);
}
}
/*
* --------------------------------------------------
* 6. Summary
* --------------------------------------------------
*/
core.info(
`Detected detectors: ${
detectedDetectors.size > 0
? [...detectedDetectors].join(', ')
: 'none'
}`
);
+182
View File
@@ -0,0 +1,182 @@
# This workflow is triggered when opening/reopening PR, editing the PR description or changing labels. It ignores unrelated label changes. It runs PR type validation and if it succeeds, it runs PR milestone, release notes, file version and detector labels workflow in parallel.
name: PR Edit Trigger
on:
pull_request:
types:
- opened
- reopened
- edited
- labeled
- unlabeled
permissions:
pull-requests: write
contents: read
models: read
jobs:
check-pr-trigger:
name: Check PR Trigger
runs-on: ubuntu-latest
outputs:
require_validation: ${{ steps.check.outputs.require_validation }}
file_label_change: ${{ steps.check.outputs.file_label_change }}
pr_edited: ${{ steps.check.outputs.pr_edited }}
steps:
- name: Check PR Trigger
id: check
uses: actions/github-script@v8
with:
script: |
let requireValidation = true;
let fileLabelChange = false;
/*
* If triggered by label change, ignore unrelated labels.
*/
const action = context.payload.action;
const prEdited = action === 'edited';
if (action === 'labeled' || action === 'unlabeled') {
const changedLabel = context.payload.label?.name;
core.info(
`Changed Label: ${changedLabel}`
);
const relevantLabels = [
'Feature',
'Bug Fix',
'Infrastructure',
'Breaking API'
];
if (!relevantLabels.includes(changedLabel)) {
requireValidation = false;
}
// File Major requires Breaking API label
const fileLabels = [
'File Major',
'File Minor',
'Breaking API'
];
if (fileLabels.includes(changedLabel)) {
fileLabelChange = true;
}
}
core.setOutput(
'require_validation',
requireValidation.toString()
);
core.setOutput(
'file_label_change',
fileLabelChange.toString()
);
core.setOutput(
'pr_edited',
prEdited.toString()
);
if (requireValidation) {
core.info('PR type validation: Required.');
} else {
core.info('PR type validation: Not required - Unrelated label.');
}
if (fileLabelChange) {
core.info('File label or Breaking API: Changed.');
} else {
core.info('File label or Breaking API: No Change.');
}
validate-pr-type:
needs: check-pr-trigger
if: ${{ needs.check-pr-trigger.outputs.require_validation == 'true' }}
uses: ./.github/workflows/pr_type_validation.yaml
with:
pr_number: ${{ github.event.pull_request.number }}
permissions:
pull-requests: write
contents: read
milestone:
needs:
- check-pr-trigger
- validate-pr-type
if: >-
${{
always() &&
(
needs.check-pr-trigger.outputs.require_validation == 'false' ||
needs.validate-pr-type.result == 'success'
)
}}
uses: ./.github/workflows/pr_milestone.yaml
with:
pr_number: ${{ github.event.pull_request.number }}
permissions:
pull-requests: write
contents: read
detector-labels:
needs:
- check-pr-trigger
- validate-pr-type
if: >-
${{
always() &&
(
needs.check-pr-trigger.outputs.require_validation == 'false' ||
needs.validate-pr-type.result == 'success'
)
}}
uses: ./.github/workflows/pr_detector_labels.yaml
with:
pr_number: ${{ github.event.pull_request.number }}
permissions:
pull-requests: write
issues: read
release-notes:
needs:
- check-pr-trigger
- detector-labels
if: >-
${{
always() &&
(
needs.check-pr-trigger.outputs.require_validation == 'false' ||
needs.detector-labels.result == 'success'
)
}}
uses: ./.github/workflows/pr_release_notes.yaml
with:
pr_number: ${{ github.event.pull_request.number }}
permissions:
pull-requests: read
contents: read
file-version:
needs:
- check-pr-trigger
- validate-pr-type
if: >-
${{
always() &&
(
needs.check-pr-trigger.outputs.file_label_change == 'true'
) ||
(
needs.check-pr-trigger.outputs.require_validation == 'false' ||
needs.validate-pr-type.result == 'success'
)
}}
uses: ./.github/workflows/pr_file_version.yaml
with:
pr_number: ${{ github.event.pull_request.number }}
permissions:
pull-requests: write
contents: read
+295
View File
@@ -0,0 +1,295 @@
# This reusable workflow checks changes to receiver_defs.h.
# It validates the File Major and File Minor labels based on
# changes to HDF5_WRITER_VERSION or BINARY_WRITER_VERSION.
#
# File Major:
# The value before the decimal point changes.
#
# File Minor:
# Only the value after the decimal point changes.
#
# Existing labels are never silently removed. If the labels do not
# match the detected version change, the workflow fails.
name: PR File Version
on:
workflow_call:
inputs:
pr_number:
required: true
type: number
permissions:
pull-requests: write
contents: read
jobs:
file-version:
name: Check file version
runs-on: ubuntu-latest
steps:
- name: Check file version
uses: actions/github-script@v8
with:
script: |
const prNumber = ${{ inputs.pr_number }};
const filePath =
'slsReceiverSoftware/src/receiver_defs.h';
const versionDefinitions = [
'HDF5_WRITER_VERSION',
'BINARY_WRITER_VERSION'
];
/*
* --------------------------------------------------
* 1. Get current PR labels.
* --------------------------------------------------
*/
const { data: currentLabels } =
await github.rest.issues.listLabelsOnIssue({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: prNumber
});
const currentLabelNames =
currentLabels.map(label => label.name);
const hasFileMajor =
currentLabelNames.includes('File Major');
const hasFileMinor =
currentLabelNames.includes('File Minor');
const hasBreakingAPI =
currentLabelNames.includes('Breaking API');
/*
* File Major and File Minor are mutually exclusive.
*/
if (hasFileMajor && hasFileMinor) {
core.setFailed(
'Both "File Major" and "File Minor" labels are present. Remove both labels and let the workflow determine the correct label based on changes to receiver_defs.h.'
);
return;
}
/*
* --------------------------------------------------
* 2. Get all files changed by the PR.
* --------------------------------------------------
*/
const files = await github.paginate(
github.rest.pulls.listFiles,
{
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: prNumber,
per_page: 100
}
);
/*
* --------------------------------------------------
* 3. Check whether receiver_defs.h was changed.
* --------------------------------------------------
*/
const changedFile = files.find(
file => file.filename === filePath
);
if (!changedFile) {
if (hasFileMajor || hasFileMinor) {
core.setFailed(
`${filePath} was not changed, but a File Major/File Minor label is present. Remove the label and let the workflow determine the correct label based on changes to receiver_defs.h.`
);
return;
}
core.info(
`${filePath} was not changed. No file version label required.`
);
return;
}
core.info(
`${filePath} was changed. Checking file version definitions.`
);
/*
* --------------------------------------------------
* 4. Check the changed lines in the patch.
* --------------------------------------------------
*/
const patch = changedFile.patch || '';
const oldVersions = {};
const newVersions = {};
const versionRegex =
/^([+-])\s*#define\s+(HDF5_WRITER_VERSION|BINARY_WRITER_VERSION)\s+\((\d+)\.(\d+)\)/;
for (const line of patch.split('\n')) {
const match = line.match(versionRegex);
if (!match) {
continue;
}
const changeType = match[1];
const name = match[2];
const major = Number(match[3]);
const minor = Number(match[4]);
if (changeType === '-') {
oldVersions[name] = {
major,
minor
};
} else if (changeType === '+') {
newVersions[name] = {
major,
minor
};
}
}
/*
* --------------------------------------------------
* 5. Determine the file version change.
* --------------------------------------------------
*/
let versionChanged = false;
let majorChange = false;
for (const name of versionDefinitions) {
const oldVersion = oldVersions[name];
const newVersion = newVersions[name];
/*
* Both old and new definitions must be present
* in order to identify a version change.
*/
if (!oldVersion || !newVersion) {
continue;
}
/*
* Ignore the definition if the version did not
* actually change.
*/
if (
oldVersion.major === newVersion.major &&
oldVersion.minor === newVersion.minor
) {
continue;
}
versionChanged = true;
core.info(
`${name}: ` +
`${oldVersion.major}.${oldVersion.minor} -> ` +
`${newVersion.major}.${newVersion.minor}`
);
/*
* A change before the decimal point is a
* major version change.
*/
if (oldVersion.major !== newVersion.major) {
majorChange = true;
}
}
/*
* --------------------------------------------------
* 6. No relevant version change.
* --------------------------------------------------
*/
if (!versionChanged) {
if (hasFileMajor || hasFileMinor) {
core.setFailed(
`Neither HDF5_WRITER_VERSION nor BINARY_WRITER_VERSION was changed, but a File Major/File Minor label is present. Remove the labels and let the workflow determine the correct label based on changes to receiver_defs.h.`
);
return;
}
core.info(
'Neither HDF5_WRITER_VERSION nor BINARY_WRITER_VERSION was changed. No file version label required.'
);
return;
}
/*
* --------------------------------------------------
* 7. Major version change.
* --------------------------------------------------
*/
if (majorChange) {
if (hasFileMinor) {
core.setFailed(
'A major file version change was detected, but the PR has the "File Minor" label. Remove the "File Minor" label and let the workflow determine the correct label based on changes to receiver_defs.h.'
);
return;
}
if (!hasBreakingAPI) {
core.setFailed(
'A major file version change was detected, and the PR has not recognized "Breaking API". Please check the checkbox "Breaking API" in the PR description so that the appropriate label can be added to the PR and milestone recalculated.'
);
return;
}
if (!hasFileMajor) {
await github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: prNumber,
labels: ['File Major']
});
core.info(
'Added label: File Major'
);
} else {
core.info(
'File Major label and Breaking API label are correctly present.'
);
}
return;
}
/*
* --------------------------------------------------
* 8. Minor version change.
* --------------------------------------------------
*/
if (hasFileMajor) {
core.setFailed(
'Only a minor file version change was detected, but the PR has the "File Major" label. Remove the "File Major" label and let the workflow determine the correct label based on changes to receiver_defs.h.'
);
return;
}
if (!hasFileMinor) {
await github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: prNumber,
labels: ['File Minor']
});
core.info(
'Added label: File Minor'
);
} else {
core.info(
'File Minor label is correctly present.'
);
}
+248
View File
@@ -0,0 +1,248 @@
# This reusable workflow sets the milestone based on the
# current PR labels.
name: PR Milestone
on:
workflow_call:
inputs:
pr_number:
required: true
type: number
permissions:
pull-requests: write
contents: read
jobs:
set-milestone:
name: Set PR milestone
runs-on: ubuntu-latest
steps:
- name: Determine target milestone
id: determine-milestone
uses: actions/github-script@v8
with:
script: |
const prNumber = ${{ inputs.pr_number }};
/*
* Get the base branch of the PR.
*/
const { data: pullRequest } =
await github.rest.pulls.get({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: prNumber
});
const baseBranch = pullRequest.base.ref;
/*
* 1. Return early if base is main branch. Milestone is already
* managed and probably done when merging to main.
*/
if (baseBranch === 'main') {
core.info('PR targets main. Milestone is managed separately.');
core.setOutput('target_milestone', '');
return;
}
/*
* Helper function to set the target milestone output.
*/
const setTargetMilestoneOutput = (targetMilestone) => {
core.info(`Target milestone: ${targetMilestone}`);
core.setOutput('target_milestone', targetMilestone);
};
/*
* 2. If PR targets a release-candidate branch (for example 9.1.0.rc),
* set milestone to the release version prefix (for example 9.1.0)
* and return early.
*/
if (baseBranch.endsWith('.rc')) {
const targetMilestone = baseBranch.replace(/\.rc$/, '');
core.info(
`PR targets release-candidate branch "${baseBranch}". ` +
`Using milestone "${targetMilestone}".`
);
setTargetMilestoneOutput(targetMilestone);
return;
}
/**
* 3. If PR targets a branch that is not developer, do nothing.
*/
if (baseBranch !== 'developer') {
core.info('PR does not target developer branch. Skipping milestone management.');
core.setOutput('target_milestone', '');
return;
}
/*
* 4. If PR targets developer, check "Equivalent PRs" in the PR body.
* If an equivalent PR targets an RC branch (for example 9.1.0.rc),
* use the corresponding release milestone (for example 9.1.0).
*/
if (baseBranch === 'developer') {
const equivalentPrSectionMatch = pullRequest.body?.match(
/(^|\n)#{1,6}\s*Equivalent PRs\s*\n([\s\S]*?)(?=\n#{1,6}\s+\S|\s*$)/i
);
const equivalentPrNumbers =
equivalentPrSectionMatch?.[2]
? Array.from(
equivalentPrSectionMatch[2].matchAll(/#(\d+)/g),
match => Number(match[1])
)
: [];
for (const equivalentPrNumber of equivalentPrNumbers) {
if (equivalentPrNumber === prNumber) {
continue;
}
const { data: equivalentPr } =
await github.rest.pulls.get({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: equivalentPrNumber
});
const equivalentBaseBranch = equivalentPr.base.ref;
if (!equivalentBaseBranch.endsWith('.rc')) {
continue;
}
const targetMilestone =
equivalentBaseBranch.replace(/\.rc$/, '');
core.info(
`Developer PR references RC PR #${equivalentPrNumber} ` +
`(${equivalentBaseBranch}). Using milestone "${targetMilestone}".`
);
setTargetMilestoneOutput(targetMilestone);
return;
}
}
/*
* 5. Read release targets through the GitHub API
* from the base branch of PR.
*/
const { data: file } =
await github.rest.repos.getContent({
owner: context.repo.owner,
repo: context.repo.repo,
path: '.github/release-targets.yaml',
ref: baseBranch
});
const releaseTargetsFile =
Buffer.from(file.content, 'base64').toString('utf8');
const getTarget = (name) => {
const match = releaseTargetsFile.match(
new RegExp(`^${name}:\\s*["']?([^"'\\s]+)["']?`, 'm')
);
if (!match) {
core.setFailed(
`Release target "${name}" was not found.`
);
return null;
}
return match[1];
};
const major = getTarget('major');
const minor = getTarget('minor');
const bugFix = getTarget('bug_fix');
if (!major || !minor || !bugFix) {
core.setFailed(
'Required release targets (major, minor, bug_fix) could not be resolved from yaml file.'
);
return;
}
/*
* Get current PR labels.
*/
const { data: labels } =
await github.rest.issues.listLabelsOnIssue({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: prNumber
});
const labelNames =
labels.map(label => label.name);
/*
* Determine the milestone.
*
* Breaking API has highest priority.
*/
let targetMilestone;
if (labelNames.includes('Breaking API')) {
targetMilestone = major;
} else if (labelNames.includes('Feature')) {
targetMilestone = minor;
} else if (
labelNames.includes('Bug Fix') ||
labelNames.includes('Infrastructure')
) {
targetMilestone = bugFix;
} else {
core.setFailed(
'No milestone-related labels found.'
);
return;
}
setTargetMilestoneOutput(targetMilestone);
- name: Set milestone
if: steps.determine-milestone.outputs.target_milestone != ''
uses: actions/github-script@v8
with:
script: |
const prNumber = ${{ inputs.pr_number }};
const targetMilestone = '${{ steps.determine-milestone.outputs.target_milestone }}';
const { data: milestones } =
await github.rest.issues.listMilestones({
owner: context.repo.owner,
repo: context.repo.repo,
state: 'open'
});
const milestone = milestones.find(
milestone => milestone.title === targetMilestone
);
if (!milestone) {
core.setFailed(
`Milestone "${targetMilestone}" was not found.`
);
return;
}
await github.rest.issues.update({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: prNumber,
milestone: milestone.number
});
core.info(
`Milestone set to "${targetMilestone}".`
);
+262
View File
@@ -0,0 +1,262 @@
# This reusable workflow validates the PR release notes. It ignores comments and
# <details> blocks, checks for notes under the "## Release notes" section. If
# it is not an Infrastructure PR and release notes are not found, it should find
# them in an equivalent PR or referenced PR, else validation fails. If it is an
# Infrastructure PR and release notes are found, validation fails.
name: PR Release Notes
on:
workflow_call:
inputs:
pr_number:
required: true
type: number
permissions:
pull-requests: read
contents: read
jobs:
check-release-notes:
name: Check PR release notes
runs-on: ubuntu-latest
steps:
- name: Validate with labels
id: validate-labels
uses: actions/github-script@v8
with:
script: |
const prNumber = ${{ inputs.pr_number }};
if (!prNumber) {
core.setFailed('Unable to determine PR number.');
return;
}
core.setOutput('is_main_branch', 'false');
core.setOutput('require_release_notes', 'true');
// base branch: main => no validation needed
const { data: pr } = await github.rest.pulls.get({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: prNumber
});
const baseBranch = pr.base.ref;
if (baseBranch === 'main') {
core.info('PR targets main branch. Release notes validation is not required.');
core.setOutput('is_main_branch', 'true');
core.setOutput('require_release_notes', 'false');
return;
}
// get current PR labels
const { data: labels } =
await github.rest.issues.listLabelsOnIssue({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: prNumber
});
const labelNames = labels.map(label => label.name);
// infrastructure label
const hasInfrastructure = labelNames.includes('Infrastructure');
core.info('Infrastructure label: ' + hasInfrastructure);
// developer detector label
const developingDetectorLabels = ['Ctb', 'Xilinx Ctb', 'Matterhorn'];
const otherDetectorLabels = ['Eiger', 'Mythen3', 'Gotthard2', 'Jungfrau', 'Moench'];
const hasDevelopingDetectorLabel = labelNames.some(
label => developingDetectorLabels.includes(label)
);
const hasOtherDetectorLabel = labelNames.some(
label => otherDetectorLabels.includes(label)
);
const hasOnlyDevelopingDetectors =
hasDevelopingDetectorLabel && !hasOtherDetectorLabel;
core.info('Only developing detector labels: ' + hasOnlyDevelopingDetectors + '\n');
if (hasInfrastructure || hasOnlyDevelopingDetectors) {
core.setOutput('require_release_notes', 'false');
core.info ('Release notes required: false');
return;
}
core.info('Release notes required: true');
- name: Find release notes in current PR
id: find-current
if: steps.validate-labels.outputs.is_main_branch == 'false'
uses: actions/github-script@v8
with:
script: |
const prNumber = ${{ inputs.pr_number }};
core.setOutput('needs_further_action', 'false');
// some functions to help find release notes in current PR body
const stripIgnoredContent = (text = '') => {
return (text || '')
.replace(/<!--.*?-->/gs, '\n')
.replace(/<details\b[\s\S]*?<\/details>/gi, '\n')
.trim();
};
const findReleaseNotesSection = (body = '') => {
const sanitized = stripIgnoredContent(body || '');
if (!sanitized) return '';
const match = sanitized.match(
/(^|\n)#{1,6}\s*Release notes\s*\n([\s\S]*?)(?=\n#{1,6}\s+\S|\s*$)/i
);
if (!match) return '';
let section = (match[2] || '')
.split('\n')
.filter(line => !/^#{1,6}\s+/.test(line.trim()))
.join('\n')
.trim();
return section;
};
const { data: pr } = await github.rest.pulls.get({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: prNumber
});
// find release notes in the current PR body
const releaseNotes = findReleaseNotesSection(pr.body || '');
const hasReleaseNotesCurrent = Boolean(releaseNotes);
core.info('Release notes in current PR: ' + (hasReleaseNotesCurrent ? 'Found\n' : 'Not found\n'));
// validate release notes requirement
const requireReleaseNotes = '${{ steps.validate-labels.outputs.require_release_notes }}' === 'true';
if (!requireReleaseNotes) {
if (hasReleaseNotesCurrent) {
core.setFailed('Release notes cannot be included for this PR ' +
'(Infrastructure/developing detector). \n' +
'To fix this, \n' +
'\t - remove release notes or\n' +
'\t - change PR type or\n' +
'\t - add/remove detector labels.\n');
return;
}
core.info('No further action required (Infrastructure/developing detector).');
return;
}
if (hasReleaseNotesCurrent) {
core.info('No further action required.');
return;
}
core.setOutput('needs_further_action', 'true');
core.info('Further action required.');
- name: Find release notes in linked PRs
if: >-
${{
steps.validate-labels.outputs.is_main_branch == 'false' &&
steps.find-current.outputs.needs_further_action == 'true'
}}
uses: actions/github-script@v8
with:
script: |
const prNumber = ${{ inputs.pr_number }};
// functions to find release notes in linked PRs
const stripIgnoredContent = (text = '') => {
return (text || '')
.replace(/<!--.*?-->/gs, '\n')
.replace(/<details\b[\s\S]*?<\/details>/gi, '\n')
.trim();
};
const findReleaseNotesSection = (body = '') => {
const sanitized = stripIgnoredContent(body || '');
if (!sanitized) return '';
const match = sanitized.match(
/(^|\n)#{1,6}\s*Release notes\s*\n([\s\S]*?)(?=\n#{1,6}\s+\S|\s*$)/i
);
if (!match) return '';
return (match[2] || '')
.split('\n')
.filter(line => !/^#{1,6}\s+/.test(line.trim()))
.join('\n')
.trim();
};
const parseNumberList = (text) => {
if (!text) {
return [];
}
return Array.from(
new Set(
Array.from(text.matchAll(/#(\d+)/g), match => Number(match[1]))
)
);
};
const findReferencedPrNumbers = (body = '') => {
if (!body) {
return [];
}
const relevantSections = [
'Equivalent PRs',
'Referenced PRs'
];
const patterns = relevantSections.map(section =>
new RegExp(
`(^|\\n)#{1,6}\\s*${section.replace(/[.*+?^${}()|[\\]\\]/g, '\\$&')}\\s*\\n([\\s\\S]*?)(?=\\n#{1,6}\\s+\\S|\\s*$)`,
'i'
)
);
const numbers = [];
for (const pattern of patterns) {
const match = body.match(pattern);
if (match && match[2]) {
numbers.push(...parseNumberList(match[2]));
}
}
return Array.from(new Set(numbers));
};
// Loop thorough linked PRs to find release notes
const { data: pr } = await github.rest.pulls.get({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: prNumber
});
const equivalentAndReferencedPrNumbers = findReferencedPrNumbers(pr.body || '');
for (const referencedPrNumber of equivalentAndReferencedPrNumbers) {
if (referencedPrNumber === prNumber) {
continue;
}
try {
const { data: referencedPr } =
await github.rest.pulls.get({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: referencedPrNumber
});
const referencedNotes = findReleaseNotesSection(referencedPr.body || '');
if (referencedNotes) {
core.info(
`Release notes found: In equivalent/referenced PR ` +
`#${referencedPrNumber}. Using that as the source for this PR.`
);
return;
}
} catch (error) {
core.warning(
`Unable to inspect referenced PR #${referencedPrNumber}: ${error.message}`
);
}
}
core.setFailed(
'No release notes were found in the PR body, equivalent PRs, or referenced PRs.'
);
@@ -0,0 +1,29 @@
# This workflow is triggered when code is pushed to a pull request. It runs the reusable detector label workflow and the file version workflow in parallel.
name: PR Synchronize Trigger
on:
pull_request:
types:
- synchronize
permissions:
actions: read
pull-requests: read
jobs:
detector-labels:
uses: ./.github/workflows/pr_detector_labels.yaml
with:
pr_number: ${{ github.event.pull_request.number }}
permissions:
pull-requests: write
issues: read
file-version:
uses: ./.github/workflows/pr_file_version.yaml
with:
pr_number: ${{ github.event.pull_request.number }}
permissions:
pull-requests: write
contents: read
+177
View File
@@ -0,0 +1,177 @@
# This reusable workflow reads the primary type and breaking api selection from the PR description and validates the PR labels. It ensures that exactly one primary type is selected, and that the Breaking API label is consistent with the checkbox in the PR description. It also adds or removes labels as necessary to match the PR description.
name: PR Type Validation
on:
workflow_call:
inputs:
pr_number:
required: true
type: number
permissions:
pull-requests: write
contents: read
jobs:
validate_pr_type:
name: PR Type Validation
runs-on: ubuntu-latest
steps:
- name: PR Type Validation and Breaking API
id: validate
uses: actions/github-script@v8
with:
script: |
/*
* Read the body contents of PR
*/
const prNumber = ${{ inputs.pr_number }};
if (!prNumber) {
core.setFailed('Unable to determine PR number.');
return;
}
const { data: pr } =
await github.rest.pulls.get({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: prNumber
});
const body = pr.body || '';
/*
* Check which primary PR type is selected.
*/
const feature =
/-\s*\[[xX]\]\s*Feature\b/.test(body);
const bugFix =
/-\s*\[[xX]\]\s*Bug Fix\b/.test(body);
const infrastructure =
/-\s*\[[xX]\]\s*Infrastructure\b/.test(body);
const primaryTypes = [
feature && 'Feature',
bugFix && 'Bug Fix',
infrastructure && 'Infrastructure'
].filter(Boolean);
/*
* Exactly one primary PR type must be selected.
*/
if (primaryTypes.length !== 1) {
core.setFailed(
'Exactly one primary PR type must be selected. Select exactly one of: Feature, Bug Fix, Infrastructure in the description.'
);
return;
}
const primaryType = primaryTypes[0];
/*
* Infrastructure PRs cannot be Breaking API.
*/
const breakingApi =
/-\s*\[[xX]\]\s*This is a Breaking API change\b/.test(body);
if (infrastructure && breakingApi) {
core.setFailed(
'Infrastructure PRs cannot be Breaking API changes. Deselect Breaking API or change the PR type to Feature or Bug Fix in the description.'
);
return;
}
core.info(`Primary PR type: ${primaryType}`);
core.info(
`Breaking API: ${breakingApi ? 'yes' : 'no'}`
);
/*
* Get the labels currently on the PR.
*/
const { data: currentLabels } =
await github.rest.issues.listLabelsOnIssue({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: prNumber
});
const currentLabelNames =
currentLabels.map(label => label.name);
const primaryLabels = [
'Feature',
'Bug Fix',
'Infrastructure'
];
const selectedPrimary =
primaryLabels.filter(label =>
currentLabelNames.includes(label)
);
if (selectedPrimary.length > 1) {
core.setFailed(
`Invalid PR label state: multiple primary labels are present: ${selectedPrimary.join(', ')}. Remove these primary type labels 'Feature, Bug Fix, Infrastructure' and let the workflow set the correct label based on the PR description.`
);
return;
}
if (selectedPrimary.length === 1 && selectedPrimary[0] !== primaryType) {
core.setFailed(
`Invalid PR label state: the PR body selects ${primaryType}, but the existing label is ${selectedPrimary[0]}. Remove the existing label and let the workflow set the correct label based on the PR description.`
);
return;
}
if (
currentLabelNames.includes('Breaking API') &&
infrastructure
) {
core.setFailed(
'Infrastructure PRs cannot have the Breaking API label.'
);
return;
}
if (
!breakingApi &&
currentLabelNames.includes('Breaking API')
) {
core.setFailed(
'Breaking API label is present but the PR body does not mark the checkbox. Remove the label or check the checkbox in the PR body.'
);
return;
}
if (
breakingApi &&
!currentLabelNames.includes('Breaking API')
) {
await github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: prNumber,
labels: ['Breaking API']
});
core.info('Added label: Breaking API');
}
if (!currentLabelNames.includes(primaryType)) {
await github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: prNumber,
labels: [primaryType]
});
core.info(`Added label: ${primaryType}`);
}
core.info('PR validation passed.');