Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
2f19d56
Initial plan
Copilot Nov 20, 2025
8cc3634
Add GitHub Action workflow to block strict version pins without archi…
Copilot Nov 20, 2025
d0e5840
Add comprehensive documentation for strict version pin workflow
Copilot Nov 20, 2025
c2684be
Fix CI errors: add pull-requests write permission and move README out…
Copilot Nov 20, 2025
45517b7
Remove codeowner names from README documentation
Copilot Nov 20, 2025
67cc772
Add .github/scripts/** to cspell ignorePaths
Copilot Nov 20, 2025
a14b5c9
testing
l0lawrence Nov 24, 2025
ee75f22
Improve Python code quality and use file-specific cspell ignore
Copilot Nov 24, 2025
92f07da
Remove CODEOWNERS entries for setup.py and pyproject.toml - CI workfl…
Copilot Nov 24, 2025
f93a1ae
Revert accidental change to eventgrid setup.py
Copilot Nov 24, 2025
044c899
Add architect names to cspell ignore in check_strict_pins.py
Copilot Nov 24, 2025
79fd49b
Add cspell ignore comments to README and workflow files
Copilot Nov 24, 2025
fdc7cc2
Merge branch 'main' into copilot/add-github-action-block-pr-merging
l0lawrence Dec 1, 2025
4addcdd
remove GH action, add to analyze stage for now
l0lawrence Dec 2, 2025
53b46af
mini changes
l0lawrence Dec 2, 2025
8d799b9
Test: Add strict pin to requests
l0lawrence Dec 2, 2025
2a41637
Test: Add strict pin to requests
l0lawrence Dec 2, 2025
d98f9ff
Test: add strict pin to requests==2.31.0
l0lawrence Dec 2, 2025
02a98bb
Test: Add strict pin to requests
l0lawrence Dec 2, 2025
e08dca5
no pr test
l0lawrence Dec 2, 2025
73a5920
oops
l0lawrence Dec 2, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
304 changes: 304 additions & 0 deletions .github/scripts/check_strict_pins.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,304 @@
#!/usr/bin/env python3
# cspell:ignore pyproject kashifkhan annatisch johanste
"""
Script to detect strict version pins (==) in Python package dependencies.

This script checks for new strict version pins introduced in setup.py and pyproject.toml
files within the sdk directory, focusing on main runtime dependencies (install_requires
for setup.py, [project] dependencies for pyproject.toml).

It ignores:
- dev/test/extras dependencies
- comments
- Changes outside of main dependency sections
"""

import json
import os
import re
import subprocess
import sys
import urllib.request
import urllib.parse
Copy link

Copilot AI Dec 1, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The urllib.parse module is imported but never used in the code. Consider removing this unused import for cleaner code.

Suggested change
import urllib.parse

Copilot uses AI. Check for mistakes.
from typing import Dict, List, Optional
Copy link

Copilot AI Dec 1, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The Optional type from typing is imported but never used in the code. Consider removing this unused import for cleaner code.

Suggested change
from typing import Dict, List, Optional
from typing import Dict, List

Copilot uses AI. Check for mistakes.


def run_git_command(cmd: List[str]) -> str:
"""Run a git command and return its output."""
try:
result = subprocess.run(
cmd,
capture_output=True,
text=True,
check=True
)
return result.stdout
except subprocess.CalledProcessError as e:
print(f"Error running git command: {e}")
print(f"stderr: {e.stderr}")
return ""


def get_changed_files(base_ref: str, head_ref: str) -> List[str]:
"""Get list of changed setup.py and pyproject.toml files in sdk directory."""
diff_output = run_git_command([
"git", "diff", "--name-only", "--diff-filter=AM",
Copy link

Copilot AI Dec 1, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The --diff-filter=AM flag only considers Added and Modified files, but excludes Renamed files (R). If a file is renamed that contains strict version pins, those changes would not be detected. Consider adding R to the filter (i.e., --diff-filter=AMR) to handle renamed files that may also contain modifications.

Suggested change
"git", "diff", "--name-only", "--diff-filter=AM",
"git", "diff", "--name-only", "--diff-filter=AMR",

Copilot uses AI. Check for mistakes.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For the purposes of the functionality of this PR, I disagree.

base_ref, head_ref
])

files = []
for line in diff_output.strip().split('\n'):
if line and line.startswith('sdk/'):
if line.endswith('/setup.py') or line.endswith('/pyproject.toml'):
files.append(line)

return files


def extract_strict_pins_from_setup_py_diff(diff_content: str) -> List[str]:
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Install eng/tools/azure-sdk-tools and use it to parse the directory of the changed pyproject.toml or setup.py. using ParsedSetup.from_path(). It should handle all edge cases. You don't need any of this extra code IMO.

"""
Extract strict version pins from a setup.py diff.
Only considers additions in install_requires section.
"""
strict_pins = []
in_install_requires = False
bracket_depth = 0

for line in diff_content.split('\n'):
# Skip the +++ and --- file markers
if line.startswith('+++') or line.startswith('---'):
continue

# Process all lines to track context, but only extract from added lines
is_added = line.startswith('+')
is_removed = line.startswith('-')
is_context = line.startswith(' ')

if is_added or is_removed or is_context:
actual_line = line[1:].strip()
else:
actual_line = line.strip()

# Detect start of install_requires in any line
if 'install_requires' in actual_line and '=' in actual_line:
in_install_requires = True
bracket_depth = 0
# Check if the array starts on the same line
if '[' in actual_line:
bracket_depth = actual_line.count('[') - actual_line.count(']')

# Detect end of install_requires or start of other sections
if in_install_requires:
if 'extras_require' in actual_line or 'tests_require' in actual_line:
in_install_requires = False
continue

# Track brackets in all lines
bracket_depth += actual_line.count('[') - actual_line.count(']')
Comment on lines +85 to +97
Copy link

Copilot AI Dec 1, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The bracket depth tracking logic has a potential issue when install_requires is detected on a line. On line 85, bracket_depth is reset to 0, but then on line 88, the bracket depth is calculated by counting brackets on the install_requires line. However, on line 97, brackets are counted again for all lines in the install_requires section, which could lead to double-counting if we've already counted brackets on line 88 for the same line.

Consider restructuring this to avoid double-counting: either don't count brackets on the install_requires line initially, or skip the bracket counting on line 97 for the first iteration after detecting install_requires.

Copilot uses AI. Check for mistakes.

# If we close all brackets, we're done with install_requires
if bracket_depth <= 0 and (']' in actual_line or '),' in actual_line):
# Check current line before exiting if it's an added line
if is_added and '==' in actual_line and not actual_line.startswith('#'):
match = re.search(r'["\']([^"\']+==[\d.]+[^"\']*)["\']', actual_line)
Copy link

Copilot AI Dec 1, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same issue as line 103 - the regex pattern will fail to match version specifiers with letters in them (pre-release, post-release, dev versions, etc.). Consider using a more comprehensive pattern to capture all valid PEP 440 version specifiers.

Copilot uses AI. Check for mistakes.
if match:
strict_pins.append(match.group(1))
in_install_requires = False
continue

# Look for strict pins in added lines within install_requires
if in_install_requires and is_added:
if '==' in actual_line and not actual_line.startswith('#'):
# Match package==version pattern
match = re.search(r'["\']([^"\']+==[\d.]+[^"\']*)["\']', actual_line)
Copy link

Copilot AI Dec 1, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The regex pattern r'["\']([^"\']+==[\d.]+[^"\']*)["\']' may not correctly match all valid version specifiers. Specifically, [\d.]+ only matches digits and dots, which excludes:

  • Pre-release versions (e.g., package==1.0.0a1, package==1.0.0rc1)
  • Post-release versions (e.g., package==1.0.0.post1)
  • Dev versions (e.g., package==1.0.0.dev1)
  • Version strings with letters in them

Consider using a more comprehensive pattern like [\w.]+ or [^"\']+ (since you already have [^"\']* after the version) to capture all valid PEP 440 version specifiers.

Copilot uses AI. Check for mistakes.
if match:
strict_pins.append(match.group(1))

return strict_pins


def extract_strict_pins_from_pyproject_diff(diff_content: str) -> List[str]:
"""
Extract strict version pins from a pyproject.toml diff.
Only considers additions in [project] dependencies section.
"""
strict_pins = []
in_project_dependencies = False
in_other_section = False

for line in diff_content.split('\n'):
# Skip the +++ and --- file markers
if line.startswith('+++') or line.startswith('---'):
continue

# Process all lines to track context
is_added = line.startswith('+')
is_removed = line.startswith('-')
is_context = line.startswith(' ')

if is_added or is_removed or is_context:
actual_line = line[1:].strip()
else:
actual_line = line.strip()

# Detect [project] section markers in any line (context or changes)
if actual_line.startswith('['):
if actual_line.startswith('[project]'):
Copy link

Copilot AI Dec 1, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The logic for detecting TOML sections has a bug. On line 146, it checks if actual_line.startswith('[project]'), but this will fail to match [project] sections that have trailing content or are nested (e.g., [project.optional-dependencies]).

The current logic will incorrectly treat [project.optional-dependencies] as "not other section" because it starts with [project, but then it's supposed to be filtered out by line 154. However, if a line like [project.something-else] appears, it would be incorrectly treated as the main [project] section.

Consider changing line 146 to an exact match: if actual_line == '[project]': to ensure you're only matching the main [project] section header, not subsections.

Suggested change
if actual_line.startswith('[project]'):
if actual_line == '[project]':

Copilot uses AI. Check for mistakes.
in_other_section = False
else:
in_other_section = True
in_project_dependencies = False

# Detect start of dependencies in [project] section
if not in_other_section and 'dependencies' in actual_line and '=' in actual_line:
if not ('optional-dependencies' in actual_line or
'dev-dependencies' in actual_line):
in_project_dependencies = True
continue

# Detect end of dependencies array
if in_project_dependencies and ']' in actual_line and '==' not in actual_line:
in_project_dependencies = False
continue

# Look for strict pins in added lines within [project] dependencies
if in_project_dependencies and is_added:
if '==' in actual_line and not actual_line.startswith('#'):
# Match package==version pattern
match = re.search(r'["\']([^"\']+==[\d.]+[^"\']*)["\']', actual_line)
Copy link

Copilot AI Dec 1, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same issue as line 113 - the regex pattern r'["\']([^"\']+==[\d.]+[^"\']*)["\']' will fail to match version specifiers with letters in them (pre-release, post-release, dev versions, etc.). Consider using a more comprehensive pattern like [\w.]+ to capture all valid PEP 440 version specifiers.

Suggested change
match = re.search(r'["\']([^"\']+==[\d.]+[^"\']*)["\']', actual_line)
match = re.search(r'["\']([^"\']+==[\w.]+[^"\']*)["\']', actual_line)

Copilot uses AI. Check for mistakes.
if match:
strict_pins.append(match.group(1))

return strict_pins


def check_file_for_strict_pins(filepath: str, base_ref: str, head_ref: str) -> List[str]:
"""Check a specific file for new strict version pins."""
# Get the diff for this file
diff_output = run_git_command([
"git", "diff", base_ref, head_ref, "--", filepath
])

if not diff_output:
return []

if filepath.endswith('setup.py'):
return extract_strict_pins_from_setup_py_diff(diff_output)
elif filepath.endswith('pyproject.toml'):
return extract_strict_pins_from_pyproject_diff(diff_output)

return []


def check_architect_approval(pr_number: str, repo: str, github_token: str) -> bool:
"""
Check if any of the architects have approved the PR.
Architects: kashifkhan, annatisch, johanste
"""
architects = {'kashifkhan', 'annatisch', 'johanste'}

# GitHub API to get PR reviews
url = f"https://api.github.com/repos/{repo}/pulls/{pr_number}/reviews"

headers = {
'Authorization': f'token {github_token}',
Copy link

Copilot AI Dec 1, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nitpick] The authorization header format 'Authorization': f'token {github_token}' is using the older format. While this still works, GitHub recommends using the newer format 'Authorization': f'Bearer {github_token}' for consistency with OAuth 2.0 standards. Consider updating to the Bearer format for better alignment with current best practices.

Suggested change
'Authorization': f'token {github_token}',
'Authorization': f'Bearer {github_token}',

Copilot uses AI. Check for mistakes.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If you bring in azure-sdk-tools you can also install the ghtools extra and get access to pygithub so you don't need to formulate the request yourself.

'Accept': 'application/vnd.github.v3+json'
}

try:
req = urllib.request.Request(url, headers=headers)
with urllib.request.urlopen(req) as response:
reviews = json.loads(response.read())

for review in reviews:
if review['state'] == 'APPROVED':
reviewer = review['user']['login']
if reviewer in architects:
print(f"✅ Architect {reviewer} has approved this PR")
return True

print("❌ No architect approval found")
return False

except Exception as e:
print(f"Error checking PR reviews: {e}")
# In case of error, we should fail open to not block legitimate PRs
# if the API is down
return False
Copy link

Copilot AI Dec 1, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The comment states "fail open to not block legitimate PRs if the API is down", but returning False when the approval check fails means the workflow will still block the PR (because the script checks if approval is false to exit with error code 1). If the intent is to "fail open" (allow the PR when API is down), this should return True instead. However, this may not be desirable from a security perspective as it could allow unapproved strict pins through if the API is temporarily unavailable.

Consider either:

  1. Changing the return to True if you truly want to fail open
  2. Updating the comment to reflect that this actually "fails closed" (blocks PRs when API is unavailable)
  3. Adding a more sophisticated retry mechanism or clear error messaging to help PR authors understand the API failure
Suggested change
return False
return True

Copilot uses AI. Check for mistakes.


def set_output(name: str, value: str) -> None:
"""Set GitHub Actions output."""
github_output = os.getenv('GITHUB_OUTPUT')
if github_output:
with open(github_output, 'a') as f:
# Escape newlines and special characters
escaped_value = value.replace('%', '%25').replace('\n', '%0A').replace('\r', '%0D')
f.write(f"{name}={escaped_value}\n")
else:
print(f"::set-output name={name}::{value}")
Copy link

Copilot AI Dec 1, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nitpick] The ::set-output command is deprecated by GitHub Actions. While the code correctly uses the newer GITHUB_OUTPUT file method as the primary approach (lines 233-237), the fallback on line 239 uses the deprecated syntax. This fallback is likely never executed in modern GitHub Actions runners, but for completeness, consider either removing this fallback or updating it to write to stdout without the deprecated syntax.

Suggested change
print(f"::set-output name={name}::{value}")
# Fallback: print a plain message for debugging (do not use deprecated syntax)
print(f"[set_output fallback] {name}={value}")

Copilot uses AI. Check for mistakes.


def main() -> int:
"""Main function to check for strict version pins."""
base_ref = os.getenv('BASE_REF', 'origin/main')
head_ref = os.getenv('HEAD_REF', 'HEAD')
pr_number = os.getenv('PR_NUMBER')
repo = os.getenv('REPO')
github_token = os.getenv('GITHUB_TOKEN')

print("Checking for strict version pins...")
print(f"Base: {base_ref}, Head: {head_ref}")

# Get changed files
changed_files = get_changed_files(base_ref, head_ref)

if not changed_files:
print("No relevant files changed")
set_output('strict_pins_found', 'false')
set_output('architect_approved', 'false')
set_output('strict_pins_details', '')
return 0

print(f"Checking {len(changed_files)} file(s):")
for file_path in changed_files:
print(f" - {file_path}")

# Check each file for strict pins
all_strict_pins: Dict[str, List[str]] = {}
for filepath in changed_files:
strict_pins = check_file_for_strict_pins(filepath, base_ref, head_ref)
if strict_pins:
all_strict_pins[filepath] = strict_pins

if not all_strict_pins:
print("✅ No new strict version pins found")
set_output('strict_pins_found', 'false')
set_output('architect_approved', 'false')
set_output('strict_pins_details', '')
return 0

# Format the findings
details = []
for filepath, pins in all_strict_pins.items():
details.append(f"{filepath}:")
for pin in pins:
details.append(f" - {pin}")

details_str = '\n'.join(details)
print(f"\n⚠️ Strict version pins found:\n{details_str}")

# Check for architect approval
architect_approved = False
if pr_number and repo and github_token:
architect_approved = check_architect_approval(pr_number, repo, github_token)

set_output('strict_pins_found', 'true')
set_output('architect_approved', 'true' if architect_approved else 'false')
set_output('strict_pins_details', details_str)

return 0


if __name__ == '__main__':
sys.exit(main())
4 changes: 4 additions & 0 deletions eng/pipelines/templates/steps/analyze.yml
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,10 @@ steps:
SourceDirectory: $(Build.SourcesDirectory)
Condition: succeededOrFailed()

- template: ../steps/check_strict_pins.yml
parameters:
ServiceDirectory: ${{ parameters.ServiceDirectory }}

- template: ../steps/verify-autorest.yml
parameters:
ServiceDirectory: ${{ parameters.ServiceDirectory }}
Expand Down
10 changes: 10 additions & 0 deletions eng/pipelines/templates/steps/check_strict_pins.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
parameters:
ServiceDirectory: ''

steps:
- task: PythonScript@0
displayName: 'Check for Strict Dependency Version Pins'
condition: and(succeededOrFailed(), eq(variables['Build.Reason'], 'PullRequest'), ne(variables['Skip.StrictPinCheck'], 'true'))
inputs:
scriptPath: 'scripts/devops_tasks/check_strict_pins.py'
arguments: '--base origin/$(System.PullRequest.TargetBranch) --head $(Build.SourceVersion) --pr-number $(System.PullRequest.PullRequestNumber)'
Loading
Loading