Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
4 changes: 2 additions & 2 deletions .github/workflows/nuget-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,10 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: Set up .NET 9
- name: Set up .NET 10
uses: actions/setup-dotnet@v5
with:
dotnet-version: 9
dotnet-version: 10
- name: Check .NET version
run: dotnet --version
- name: Check .NET SDKs
Expand Down
149 changes: 149 additions & 0 deletions eng/public-api.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
#!/usr/bin/env python3
"""Sort the PublicApiAnalyzers tracking files, and optionally promote unshipped API to shipped.

Two jobs, deliberately separate:

*Sorting* (always). The analyzer does not care about order and its code fix appends, so these files
accrete in whatever order entries were added, and drift out of any order they are put into. Nothing
keeps them sorted between runs - this is what does, run at a moment somebody is already editing them.

*Promotion* (``--promote`` only). Moves every entry from ``PublicAPI.Unshipped.txt`` into its sibling
``PublicAPI.Shipped.txt``. "Shipped" means released and frozen, so doing this mid-cycle would freeze
API that has not shipped and cannot then be changed without it looking like a break. It is therefore
opt in, and belongs to the release: ship the package, then promote.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Promote APIs before publishing the release

When a release contains unshipped entries, following the documented “ship the package, then promote” sequence makes the release fail: .github/workflows/nuget-release.yml verifies that every PublicAPI.Unshipped.txt is empty before its Build and Publish steps and exits nonzero otherwise. The promotion therefore needs to be run and committed before publishing the GitHub release, not afterward.

Useful? React with 👍 / 👎.


eng/public-api.py # sort in place, promote nothing
eng/public-api.py --promote # the release step: fold unshipped into shipped, and sort
eng/public-api.py --check # CI: write nothing, exit 1 if anything is unsorted
"""

import argparse
import os
import sys

# Modifiers the analyzer writes ahead of a signature. Stripped for sorting so a type's surface stays in
# one block: sorting the raw line groups by modifier instead, splitting every type across an
# abstract/override/static/virtual block and a bare one.
MODIFIERS = ("abstract ", "override ", "static ", "virtual ", "readonly ", "const ", "sealed ")


def sort_key(line):
s = line
# an experimental annotation - [SER009]Foo.Bar - sorts with its symbol, not as punctuation
if s.startswith("["):
close = s.find("]")
if close > 0:
s = s[close + 1:]
changed = True
while changed:
changed = False
for m in MODIFIERS:
if s.startswith(m):
s = s[len(m):]
changed = True
# tie-break on the whole line so the order is total and stable
return (s, line)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Sort key ignores nullability prefix

Medium Severity

sort_key strips experimental [...] annotations and member modifiers so a type's APIs stay in one block, but it never strips the PublicApiAnalyzers ~ prefix for nullability-oblivious members. Those lines then sort after every normal signature, so they are torn out of their type and land at the end of PublicAPI.Shipped.txt.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit c73cc81. Configure here.



class ApiFile:
def __init__(self, path):
self.path = path
raw = open(path, "rb").read()
self.bom = raw.startswith(b"\xef\xbb\xbf")
text = raw.decode("utf-8-sig")
self.newline = "\r\n" if "\r\n" in text else "\n"
lines = text.replace("\r\n", "\n").split("\n")
# header directives (#nullable enable) stay at the top; blank lines are not content, and go
self.header = [l for l in lines if l.startswith("#")]
self.entries = [l for l in lines if l and not l.startswith("#")]

def render(self, entries):
text = self.newline.join(self.header + entries) + self.newline
return (b"\xef\xbb\xbf" if self.bom else b"") + text.encode("utf-8")

def write(self, entries):
open(self.path, "wb").write(self.render(entries))


def find_shipped(root):
out = []
for dirpath, dirnames, filenames in os.walk(os.path.join(root, "src")):
dirnames[:] = [d for d in dirnames if d not in ("obj", "bin")]
if "PublicAPI.Shipped.txt" in filenames:
out.append(os.path.join(dirpath, "PublicAPI.Shipped.txt"))
return sorted(out)


def main():
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("--promote", action="store_true",
help="move unshipped entries into shipped; do this only just after a release")
ap.add_argument("--check", action="store_true",
help="write nothing; exit 1 if any file would change")
args = ap.parse_args()

root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
shipped_paths = find_shipped(root)
if not shipped_paths:
print(f"no PublicAPI.Shipped.txt found under {root}/src", file=sys.stderr)
return 2

changed, problems = [], []

for shipped_path in shipped_paths:
shipped = ApiFile(shipped_path)
unshipped_path = os.path.join(os.path.dirname(shipped_path), "PublicAPI.Unshipped.txt")
unshipped = ApiFile(unshipped_path) if os.path.exists(unshipped_path) else None

rel = os.path.relpath(shipped_path, root)
entries = list(shipped.entries)
promoted = []

if args.promote and unshipped and unshipped.entries:
promoted = list(unshipped.entries)
# an entry in both files is not something to silently dedupe: the analyzer rejects
# duplicates (RS0025), and it means one of the two was hand-edited
clash = [e for e in promoted if e in set(entries)]
if clash:
problems.append(f"{rel}: {len(clash)} entr(y/ies) already shipped, e.g. {clash[0]}")
continue
entries += promoted

new_shipped = sorted(entries, key=sort_key)
if shipped.render(new_shipped) != shipped.render(shipped.entries) or promoted:
note = f" (+{len(promoted)} promoted)" if promoted else ""
changed.append(rel + note)
if not args.check:
shipped.write(new_shipped)
Comment on lines +116 to +117

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Validate every API pair before writing any of them

When more than one project has API tracking files and a later pair contains a shipped/unshipped clash, earlier pairs have already been written here before problems is checked at the end. The command then exits with status 2 while leaving those earlier shipped files promoted and their unshipped files emptied, producing a partial failed operation; collect and validate all planned changes before performing any writes.

Useful? React with 👍 / 👎.


if unshipped:
rel_un = os.path.relpath(unshipped_path, root)
# promoted entries leave; whatever remains is sorted too, so that appending to it from two
# branches collides less often
new_unshipped = [] if promoted else sorted(unshipped.entries, key=sort_key)
if unshipped.render(new_unshipped) != unshipped.render(unshipped.entries):
if not promoted:
changed.append(rel_un)
if not args.check:
unshipped.write(new_unshipped)

for p in problems:
print(f"ERROR: {p}", file=sys.stderr)
if problems:
return 2

if not changed:
print("public API files are sorted; nothing to do.")
return 0

for c in changed:
print(("would change: " if args.check else "updated: ") + c)

if args.check:
print("run 'eng/public-api.py' to fix.", file=sys.stderr)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Preserve promotion mode in the suggested fix command

When --promote --check detects pending APIs, this message instructs the caller to run the script without --promote. That command only sorts the files and leaves the unshipped entries in place, so the same check continues to fail indefinitely; the suggested command needs to include --promote whenever promotion mode was requested.

Useful? React with 👍 / 👎.

return 1
return 0


if __name__ == "__main__":
sys.exit(main())
Loading
Loading