diff --git a/.github/workflows/platform-resolution.yml b/.github/workflows/platform-resolution.yml new file mode 100644 index 0000000..3b95d46 --- /dev/null +++ b/.github/workflows/platform-resolution.yml @@ -0,0 +1,24 @@ +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 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 b44334a..977e932 100644 --- a/src/Ktav/Ktav.csproj +++ b/src/Ktav/Ktav.csproj @@ -31,6 +31,8 @@ + + @@ -43,6 +45,7 @@ + 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 @@ +