From 3b7db63fde467a28d8e35ccb864b73f3fdb7a9fa Mon Sep 17 00:00:00 2001 From: Arch-AI-tech Date: Wed, 28 Oct 2026 17:41:28 +0100 Subject: [PATCH 1/4] build: resolve MIT platform metadata for netstandard Co-Authored-By: Claude Sonnet 5.5 --- src/Ktav/Ktav.csproj | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Ktav/Ktav.csproj b/src/Ktav/Ktav.csproj index b44334a..2cac15d 100644 --- a/src/Ktav/Ktav.csproj +++ b/src/Ktav/Ktav.csproj @@ -43,6 +43,7 @@ + From 383a6a706280fbbbf2d21b86b30cd65b4ff30dde Mon Sep 17 00:00:00 2001 From: Arch-AI-tech Date: Wed, 28 Oct 2026 17:42:30 +0100 Subject: [PATCH 2/4] test: verify platform license resolution and package surface Co-Authored-By: Claude Sonnet 5.5 --- .github/workflows/platform-resolution.yml | 55 +++++++++++++++++++++++ 1 file changed, 55 insertions(+) create mode 100644 .github/workflows/platform-resolution.yml diff --git a/.github/workflows/platform-resolution.yml b/.github/workflows/platform-resolution.yml new file mode 100644 index 0000000..9e2fde3 --- /dev/null +++ b/.github/workflows/platform-resolution.yml @@ -0,0 +1,55 @@ +name: Platform license resolution + +on: + push: + branches: [main] + pull_request: + +permissions: + contents: read + +jobs: + verify-platform-resolution: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-dotnet@v4 + with: + dotnet-version: '8.0.x' + - name: Restore and pack + run: | + dotnet restore src/Ktav/Ktav.csproj + dotnet pack src/Ktav/Ktav.csproj --no-restore --configuration Release --output "$RUNNER_TEMP/ktav-pack" + - name: Verify resolved license and package dependencies + shell: python + run: | + import json + import os + from pathlib import Path + import xml.etree.ElementTree as ET + import zipfile + + assets = json.loads(Path('src/Ktav/obj/project.assets.json').read_text(encoding='utf-8')) + targets = [value for name, value in assets['targets'].items() if 'netstandard' in name.lower()] + assert len(targets) == 1, list(assets['targets']) + platforms = [key for key in targets[0] if key.lower().startswith('microsoft.netcore.platforms/')] + assert len(platforms) == 1 and platforms[0].lower() == 'microsoft.netcore.platforms/3.1.0', platforms + library = assets['libraries'][platforms[0]] + candidates = [Path(folder) / library['path'] for folder in assets['packageFolders']] + directories = [path for path in candidates if path.is_dir()] + assert len(directories) == 1, candidates + manifests = list(directories[0].glob('*.nuspec')) + assert len(manifests) == 1, manifests + metadata = ET.parse(manifests[0]).getroot() + licenses = [element for element in metadata.iter() if element.tag.rsplit('}', 1)[-1] == 'license'] + assert len(licenses) == 1 and licenses[0].get('type') == 'expression' and licenses[0].text == 'MIT' + + packages = list((Path(os.environ['RUNNER_TEMP']) / 'ktav-pack').glob('*.nupkg')) + assert len(packages) == 1, packages + with zipfile.ZipFile(packages[0]) as package: + names = [name for name in package.namelist() if name.endswith('.nuspec')] + assert len(names) == 1, names + nuspec = ET.fromstring(package.read(names[0])) + dependencies = [element.get('id') for element in nuspec.iter() if element.tag.rsplit('}', 1)[-1] == 'dependency'] + assert 'Microsoft.NETCore.Platforms' not in dependencies, dependencies + assert 'System.Memory' in dependencies and 'System.Text.Json' in dependencies, dependencies From bfd31cb0586d1296307056b998b4ed87ac34ef08 Mon Sep 17 00:00:00 2001 From: Arch-AI-tech Date: Wed, 28 Oct 2026 18:02:43 +0100 Subject: [PATCH 3/4] test: resolve MIT platform metadata for test graph Co-Authored-By: Claude Sonnet 5.5 --- tests/Ktav.Tests/Ktav.Tests.csproj | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/Ktav.Tests/Ktav.Tests.csproj b/tests/Ktav.Tests/Ktav.Tests.csproj index 835cf81..f4d749c 100644 --- a/tests/Ktav.Tests/Ktav.Tests.csproj +++ b/tests/Ktav.Tests/Ktav.Tests.csproj @@ -9,6 +9,7 @@ + From 11aeef912c04eb9a788ec989a0f4032c7841580c Mon Sep 17 00:00:00 2001 From: Arch-AI-tech Date: Thu, 29 Oct 2026 23:19:27 +0100 Subject: [PATCH 4/4] fix: require MIT platform metadata for netstandard consumers Expose the already reviewed platform metadata 3.1.0 requirement instead of hiding it with PrivateAssets. Keep test overrides private and preserve both public target frameworks, project version and other package versions. Check a real locally packed consumer with an isolated package cache, no consumer override and all six supported RIDs. Require original MIT terms and exact package identity; retain both Ktav license texts in the package. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/platform-resolution.yml | 35 +-------- ci/check-platform-consumer.py | 92 +++++++++++++++++++++++ src/Ktav/Ktav.csproj | 4 +- 3 files changed, 97 insertions(+), 34 deletions(-) create mode 100644 ci/check-platform-consumer.py diff --git a/.github/workflows/platform-resolution.yml b/.github/workflows/platform-resolution.yml index 9e2fde3..3b95d46 100644 --- a/.github/workflows/platform-resolution.yml +++ b/.github/workflows/platform-resolution.yml @@ -20,36 +20,5 @@ jobs: run: | dotnet restore src/Ktav/Ktav.csproj dotnet pack src/Ktav/Ktav.csproj --no-restore --configuration Release --output "$RUNNER_TEMP/ktav-pack" - - name: Verify resolved license and package dependencies - shell: python - run: | - import json - import os - from pathlib import Path - import xml.etree.ElementTree as ET - import zipfile - - assets = json.loads(Path('src/Ktav/obj/project.assets.json').read_text(encoding='utf-8')) - targets = [value for name, value in assets['targets'].items() if 'netstandard' in name.lower()] - assert len(targets) == 1, list(assets['targets']) - platforms = [key for key in targets[0] if key.lower().startswith('microsoft.netcore.platforms/')] - assert len(platforms) == 1 and platforms[0].lower() == 'microsoft.netcore.platforms/3.1.0', platforms - library = assets['libraries'][platforms[0]] - candidates = [Path(folder) / library['path'] for folder in assets['packageFolders']] - directories = [path for path in candidates if path.is_dir()] - assert len(directories) == 1, candidates - manifests = list(directories[0].glob('*.nuspec')) - assert len(manifests) == 1, manifests - metadata = ET.parse(manifests[0]).getroot() - licenses = [element for element in metadata.iter() if element.tag.rsplit('}', 1)[-1] == 'license'] - assert len(licenses) == 1 and licenses[0].get('type') == 'expression' and licenses[0].text == 'MIT' - - packages = list((Path(os.environ['RUNNER_TEMP']) / 'ktav-pack').glob('*.nupkg')) - assert len(packages) == 1, packages - with zipfile.ZipFile(packages[0]) as package: - names = [name for name in package.namelist() if name.endswith('.nuspec')] - assert len(names) == 1, names - nuspec = ET.fromstring(package.read(names[0])) - dependencies = [element.get('id') for element in nuspec.iter() if element.tag.rsplit('}', 1)[-1] == 'dependency'] - assert 'Microsoft.NETCore.Platforms' not in dependencies, dependencies - assert 'System.Memory' in dependencies and 'System.Text.Json' in dependencies, dependencies + - name: Verify exact packed consumer without platform override + run: python3 -I ci/check-platform-consumer.py "$RUNNER_TEMP/ktav-pack" "$RUNNER_TEMP/platform-consumer" diff --git a/ci/check-platform-consumer.py b/ci/check-platform-consumer.py new file mode 100644 index 0000000..db2dcaf --- /dev/null +++ b/ci/check-platform-consumer.py @@ -0,0 +1,92 @@ +"""Проверить, что MIT platform metadata приходит из нашего NuGet, не override consumer.""" + +import base64 +import hashlib +import json +import os +from pathlib import Path +import subprocess +import sys +import xml.etree.ElementTree as ET +import zipfile + +RIDS = ('linux-x64', 'linux-arm64', 'osx-x64', 'osx-arm64', 'win-x64', 'win-arm64') + + +def local(tag): + return tag.rsplit('}', 1)[-1] + + +if len(sys.argv) != 3: + raise SystemExit('usage: check-platform-consumer.py PACKAGE_DIRECTORY WORKSPACE') +packages, workspace = map(Path, sys.argv[1:]) +workspace.mkdir() +archives = list(packages.glob('*.nupkg')) +if len(archives) != 1: + raise SystemExit('expected exactly one source package') +source = ET.parse('src/Ktav/Ktav.csproj').getroot() +if [node.text for node in source.iter() if local(node.tag) == 'TargetFrameworks'] != ['net8.0;netstandard2.0']: + raise SystemExit('public target frameworks changed') +if [node.text for node in source.iter() if local(node.tag) == 'Version'] != ['0.8.1']: + raise SystemExit('project version changed') +with zipfile.ZipFile(archives[0]) as archive: + nuspecs = [name for name in archive.namelist() if name.endswith('.nuspec')] + if len(nuspecs) != 1: + raise SystemExit('expected one nuspec') + spec = ET.fromstring(archive.read(nuspecs[0])) + groups = [node for node in spec.iter() if local(node.tag) == 'group' and 'netstandard' in node.get('targetFramework', '').lower()] + if len(groups) != 1: + raise SystemExit('expected exactly one netstandard dependency group') + net8 = [node for node in spec.iter() if local(node.tag) == 'group' and node.get('targetFramework', '').lower() == 'net8.0'] + if len(net8) != 1 or any(node.get('id') == 'Microsoft.NETCore.Platforms' for node in net8[0]): + raise SystemExit('net8 consumer dependency group changed') + platforms = [node for node in groups[0] if node.get('id') == 'Microsoft.NETCore.Platforms'] + if len(platforms) != 1 or platforms[0].get('version', '').replace(' ', '') not in ('3.1.0', '[3.1.0,)'): + raise SystemExit('MIT platform requirement is not exposed to consumers') + for name in ('LICENSE-MIT', 'LICENSE-APACHE'): + if archive.namelist().count(name) != 1 or archive.read(name) != Path(name).read_bytes(): + raise SystemExit('source package license bytes missing or changed') + +consumer = workspace / 'consumer' +consumer.mkdir() +project = ET.Element('Project', {'Sdk': 'Microsoft.NET.Sdk'}) +properties = ET.SubElement(project, 'PropertyGroup') +ET.SubElement(properties, 'TargetFramework').text = 'netstandard2.0' +ET.SubElement(properties, 'RuntimeIdentifiers').text = ';'.join(RIDS) +references = ET.SubElement(project, 'ItemGroup') +ET.SubElement(references, 'PackageReference', {'Include': 'Ktav', 'Version': '0.8.1'}) +ET.ElementTree(project).write(consumer / 'Consumer.csproj', encoding='utf-8', xml_declaration=True) +configuration = ET.Element('configuration') +sources = ET.SubElement(configuration, 'packageSources') +ET.SubElement(sources, 'clear') +ET.SubElement(sources, 'add', {'key': 'local', 'value': str(packages.resolve())}) +ET.SubElement(sources, 'add', {'key': 'nuget', 'value': 'https://api.nuget.org/v3/index.json'}) +mapping = ET.SubElement(configuration, 'packageSourceMapping') +ET.SubElement(ET.SubElement(mapping, 'packageSource', {'key': 'local'}), 'package', {'pattern': 'Ktav'}) +ET.SubElement(ET.SubElement(mapping, 'packageSource', {'key': 'nuget'}), 'package', {'pattern': '*'}) +ET.ElementTree(configuration).write(consumer / 'NuGet.Config', encoding='utf-8', xml_declaration=True) +cache = workspace / 'packages' +environment = {**os.environ, 'NUGET_PACKAGES': str(cache.resolve())} +subprocess.run(['dotnet', 'restore', str(consumer / 'Consumer.csproj'), '--configfile', str(consumer / 'NuGet.Config')], env=environment, check=True) +assets = json.loads((consumer / 'obj/project.assets.json').read_text(encoding='utf-8')) +platforms = [name for name in assets['libraries'] if name.lower().startswith('microsoft.netcore.platforms/')] +if len(platforms) != 1 or platforms[0].lower() != 'microsoft.netcore.platforms/3.1.0': + raise SystemExit(f'consumer resolved non-MIT platform metadata: {platforms}') +if any(not any(name.endswith('/' + rid) for name in assets['targets']) for rid in RIDS): + raise SystemExit('consumer did not restore all six supported RIDs') +ktav = [name for name in assets['libraries'] if name.lower() == 'ktav/0.8.1'] +if len(ktav) != 1: + raise SystemExit('consumer did not restore exactly one Ktav package') +package = assets['libraries'][ktav[0]] +expected = base64.b64encode(hashlib.sha512(archives[0].read_bytes()).digest()).decode() +if package['sha512'].removeprefix('sha512-') != expected: + raise SystemExit('consumer did not install the exact locally packed Ktav package') +metadata = ET.parse(next((cache / 'microsoft.netcore.platforms/3.1.0').glob('*.nuspec'))).getroot() +licenses = [node for node in metadata.iter() if local(node.tag) == 'license'] +if len(licenses) != 1 or licenses[0].get('type') != 'expression' or licenses[0].text != 'MIT': + raise SystemExit('consumer platform package is not declared MIT') +license_files = [path for path in (cache / 'microsoft.netcore.platforms/3.1.0').iterdir() if path.is_file() and path.name.upper().startswith('LICENSE')] +if len(license_files) != 1 or b'Permission is hereby granted, free of charge' not in license_files[0].read_bytes(): + raise SystemExit('consumer platform package has no original MIT license text') +print('Exact local NuGet consumer resolved MIT platform metadata 3.1.0 for all six RIDs without a consumer override') +print('Target frameworks and Ktav version unchanged; no claim about untested legacy RID fallback imports') diff --git a/src/Ktav/Ktav.csproj b/src/Ktav/Ktav.csproj index 2cac15d..977e932 100644 --- a/src/Ktav/Ktav.csproj +++ b/src/Ktav/Ktav.csproj @@ -31,6 +31,8 @@ + + @@ -43,7 +45,7 @@ - +