name: publish-dlp-io # Publish the standalone dlp-io distribution from an immutable dlp-io-vX.Y.Z tag. # The Windows runner cannot reach github.com, so this workflow uses no third-party actions. on: push: tags: - "dlp-io-v*.*.*" workflow_dispatch: inputs: version: description: "Immutable package tag, for example dlp-io-v0.1.0" required: true type: string env: PIP_INDEX_URL: https://pypi.tuna.tsinghua.edu.cn/simple jobs: publish: runs-on: windows-latest defaults: run: working-directory: repo steps: - name: Clone immutable package tag shell: pwsh working-directory: . env: ACTION_EVENT_NAME: ${{ github.event_name }} ACTION_REF_NAME: ${{ github.ref_name }} ACTION_SERVER_URL: ${{ github.server_url }} ACTION_REPOSITORY: ${{ github.repository }} ACTION_CLONE_TOKEN: ${{ secrets.GITHUB_TOKEN }} DLP_IO_INPUT_VERSION: ${{ inputs.version }} run: | $ErrorActionPreference = "Stop" $tag = if ($env:ACTION_EVENT_NAME -eq "workflow_dispatch") { $env:DLP_IO_INPUT_VERSION } else { $env:ACTION_REF_NAME } if ($tag -notmatch '^dlp-io-v\d+\.\d+\.\d+$') { throw "Package tag must use dlp-io-vMAJOR.MINOR.PATCH format: $tag" } $packageVersion = $tag.Substring("dlp-io-v".Length) $token = $env:ACTION_CLONE_TOKEN if ([string]::IsNullOrWhiteSpace($token)) { throw "GITHUB_TOKEN is required to clone the repository" } $server = [Uri]$env:ACTION_SERVER_URL $clone = "$($server.Scheme)://x-access-token:$token@$($server.Authority)/$($env:ACTION_REPOSITORY).git" $masked = [regex]::Escape($token) $tagRef = "refs/tags/$tag" $peeledRef = "$tagRef^{}" $remoteOutput = @(git ls-remote --tags $clone $tagRef $peeledRef 2>&1) $remoteExit = $LASTEXITCODE $remoteOutput | ForEach-Object { $_ -replace $masked, "***" } if ($remoteExit -ne 0) { throw "remote tag lookup failed: $remoteExit" } $remoteRefs = @{} foreach ($line in $remoteOutput) { if ($line -match '^([0-9a-f]{40})\s+(.+)$') { $remoteRefs[$Matches[2]] = $Matches[1] } } if (-not $remoteRefs.ContainsKey($tagRef)) { throw "Remote package tag does not exist: $tagRef" } if (-not $remoteRefs.ContainsKey($peeledRef)) { throw "Package tag must be annotated: $tagRef" } $remoteCommit = $remoteRefs[$peeledRef] git clone -c core.autocrlf=true --depth 1 --branch $tag $clone repo 2>&1 | ForEach-Object { $_ -replace $masked, "***" } if ($LASTEXITCODE -ne 0) { throw "tagged source clone failed: $LASTEXITCODE" } Push-Location repo try { $sourceVersion = (& py -3.13 -c "import dlp_io; print(dlp_io.__version__)").Trim() } finally { Pop-Location } if ($sourceVersion -ne $packageVersion) { throw "dlp-io tag/version mismatch: tag=$tag package=$sourceVersion" } $tagCommit = (git -C repo rev-list -n 1 $tag).Trim() $headCommit = (git -C repo rev-parse HEAD).Trim() if ($tagCommit -ne $remoteCommit -or $remoteCommit -ne $headCommit) { throw "Immutable tag checkout mismatch: remote=$remoteCommit local=$tagCommit HEAD=$headCommit" } "DLP_IO_PACKAGE_TAG=$tag" | Out-File $env:GITHUB_ENV -Encoding utf8 -Append "DLP_IO_PACKAGE_VERSION=$packageVersion" | Out-File $env:GITHUB_ENV -Encoding utf8 -Append Write-Host "package source: $tag @ $headCommit" - name: Test Python 3.10-3.14 shell: pwsh run: | $ErrorActionPreference = "Stop" foreach ($pythonVersion in @("3.10", "3.11", "3.12", "3.13", "3.14")) { & py "-$pythonVersion" --version if ($LASTEXITCODE -ne 0) { throw "Python $pythonVersion is required" } & py "-$pythonVersion" -m pip install -r requirements-dev.txt --progress-bar off if ($LASTEXITCODE -ne 0) { throw "test dependency install failed on $pythonVersion" } $abi = $pythonVersion.Replace(".", "") $baseTemp = Join-Path $env:TEMP "dlp-io-py$abi-$PID" & py "-$pythonVersion" -m pytest -q --basetemp $baseTemp if ($LASTEXITCODE -ne 0) { throw "full pytest failed on $pythonVersion" } } - name: Build and check distribution shell: pwsh run: | $ErrorActionPreference = "Stop" py -3.13 -m pip install build twine --progress-bar off if ($LASTEXITCODE -ne 0) { throw "build dependency install failed" } py -3.13 -m build if ($LASTEXITCODE -ne 0) { throw "dlp-io build failed" } $version = $env:DLP_IO_PACKAGE_VERSION $expected = @( "dlp_io-$version-py3-none-any.whl", "dlp_io-$version.tar.gz" ) foreach ($pythonVersion in @("3.10", "3.11", "3.12", "3.13", "3.14")) { $abi = $pythonVersion.Replace(".", "") $wheel = "dlp_io-$version-cp$abi-cp$abi-win_amd64.whl" $env:DLP_IO_PYD = "1" try { & py "-$pythonVersion" -m build --wheel --no-isolation if ($LASTEXITCODE -ne 0) { throw "pyd build failed on $pythonVersion" } } finally { Remove-Item Env:DLP_IO_PYD } if (-not (Test-Path -LiteralPath (Join-Path dist $wheel) -PathType Leaf)) { throw "expected pyd wheel missing: $wheel" } $expected += $wheel } py -3.13 -m twine check "dist\dlp_io-$($env:DLP_IO_PACKAGE_VERSION)*" if ($LASTEXITCODE -ne 0) { throw "twine check failed" } $actual = @(Get-ChildItem -LiteralPath dist -File | ForEach-Object Name) if (Compare-Object $expected $actual) { throw "distribution file set mismatch: $($actual -join ', ')" } $assetsDir = (New-Item -ItemType Directory -Force assets).FullName Copy-Item -LiteralPath (Resolve-Path dlp_io.py) -Destination $assetsDir Add-Type -AssemblyName System.IO.Compression.FileSystem foreach ($wheelFile in @(Get-ChildItem -LiteralPath dist -Filter "*-cp3*-win_amd64.whl" -File)) { $zip = [System.IO.Compression.ZipFile]::OpenRead($wheelFile.FullName) try { $pydEntries = @($zip.Entries | Where-Object { $_.FullName -like "*.pyd" }) if ($pydEntries.Count -ne 1) { throw "pyd wheel must contain exactly one .pyd: $($wheelFile.Name)" } $target = Join-Path $assetsDir $pydEntries[0].Name [System.IO.Compression.ZipFileExtensions]::ExtractToFile($pydEntries[0], $target, $true) } finally { $zip.Dispose() } } $bareAssets = @(Get-ChildItem -LiteralPath $assetsDir -File | ForEach-Object Name) if ($bareAssets.Count -ne 6) { throw "bare asset set mismatch: $($bareAssets -join ', ')" } - name: Smoke test release artifacts shell: pwsh run: | $ErrorActionPreference = "Stop" $root = Join-Path $env:TEMP ("dlp-io-wheel-smoke-" + $PID) $rootFull = [IO.Path]::GetFullPath($root) $tempFull = [IO.Path]::GetFullPath($env:TEMP).TrimEnd('\') + '\' if (-not $rootFull.StartsWith($tempFull, [StringComparison]::OrdinalIgnoreCase)) { throw "unsafe wheel smoke path: $rootFull" } try { py -3.13 -m venv $rootFull if ($LASTEXITCODE -ne 0) { throw "wheel smoke venv creation failed" } $python = Join-Path $rootFull "Scripts\python.exe" $pureWheel = (Resolve-Path (Join-Path dist "dlp_io-$($env:DLP_IO_PACKAGE_VERSION)-py3-none-any.whl")).Path & $python -m pip install --no-index $pureWheel --progress-bar off if ($LASTEXITCODE -ne 0) { throw "wheel installation failed" } Push-Location $rootFull try { & $python -c "import dlp_io,sys,tempfile; from pathlib import Path; assert dlp_io.__version__ == '$env:DLP_IO_PACKAGE_VERSION'; td=tempfile.TemporaryDirectory(); p=Path(td.name)/'payload.bin'; w=dlp_io.open(p,'wb'); assert w.write(b'0123456789') == 10; w.close(); dlp_io.configure(python_executable=sys.executable); r=dlp_io.open(p,'rb'); assert r.read(4) == b'0123'; assert r.seek(-3,2) == 7; assert r.read() == b'789'; r.close(); dlp_io.shutdown(); td.cleanup(); print('wheel smoke passed')" if ($LASTEXITCODE -ne 0) { throw "wheel smoke failed" } } finally { Pop-Location } } finally { if (Test-Path -LiteralPath $rootFull) { Remove-Item -LiteralPath $rootFull -Recurse -Force } } foreach ($pythonVersion in @("3.10", "3.11", "3.12", "3.13", "3.14")) { $abi = $pythonVersion.Replace(".", "") $pydWheel = (Resolve-Path (Join-Path dist "dlp_io-$($env:DLP_IO_PACKAGE_VERSION)-cp$abi-cp$abi-win_amd64.whl")).Path $pydRoot = Join-Path $env:TEMP ("dlp-io-pyd-smoke-$abi-" + $PID) $pydRootFull = [IO.Path]::GetFullPath($pydRoot) if (-not $pydRootFull.StartsWith($tempFull, [StringComparison]::OrdinalIgnoreCase)) { throw "unsafe pyd smoke path: $pydRootFull" } try { & py "-$pythonVersion" -m venv $pydRootFull if ($LASTEXITCODE -ne 0) { throw "pyd smoke venv creation failed on $pythonVersion" } $pydPython = Join-Path $pydRootFull "Scripts\python.exe" & $pydPython -m pip install --no-index $pydWheel --progress-bar off if ($LASTEXITCODE -ne 0) { throw "pyd wheel installation failed on $pythonVersion" } Push-Location $pydRootFull try { & $pydPython -c "import dlp_io,sys,tempfile; from pathlib import Path; assert dlp_io.__version__ == '$env:DLP_IO_PACKAGE_VERSION'; td=tempfile.TemporaryDirectory(); p=Path(td.name)/'payload.bin'; w=dlp_io.open(p,'wb'); assert w.write(b'0123456789') == 10; w.close(); dlp_io.configure(python_executable=sys.executable); r=dlp_io.open(p,'rb'); assert r.read(4) == b'0123'; assert r.seek(-3,2) == 7; assert r.read() == b'789'; r.close(); dlp_io.shutdown(); td.cleanup(); print('pyd smoke passed on $pythonVersion')" if ($LASTEXITCODE -ne 0) { throw "pyd wheel smoke failed on $pythonVersion" } } finally { Pop-Location } } finally { if (Test-Path -LiteralPath $pydRootFull) { Remove-Item -LiteralPath $pydRootFull -Recurse -Force } } } $bareRoot = Join-Path $env:TEMP ("dlp-io-bare-smoke-" + $PID) $bareRootFull = [IO.Path]::GetFullPath($bareRoot) if (-not $bareRootFull.StartsWith($tempFull, [StringComparison]::OrdinalIgnoreCase)) { throw "unsafe bare smoke path: $bareRootFull" } try { foreach ($pythonVersion in @("3.10", "3.11", "3.12", "3.13", "3.14")) { $abi = $pythonVersion.Replace(".", "") $sandbox = Join-Path $bareRootFull "cp$abi" New-Item -ItemType Directory -Force $sandbox | Out-Null Copy-Item -LiteralPath (Resolve-Path (Join-Path assets "dlp_io.cp$abi-win_amd64.pyd")) -Destination $sandbox Push-Location $sandbox try { & py "-$pythonVersion" -c "import dlp_io,sys,tempfile; from pathlib import Path; assert dlp_io.__version__ == '$env:DLP_IO_PACKAGE_VERSION'; assert dlp_io.__file__.endswith('.pyd'); td=tempfile.TemporaryDirectory(); p=Path(td.name)/'payload.bin'; w=dlp_io.open(p,'wb'); assert w.write(b'0123456789') == 10; w.close(); dlp_io.configure(python_executable=sys.executable); r=dlp_io.open(p,'rb'); assert r.read(4) == b'0123'; assert r.seek(-3,2) == 7; assert r.read() == b'789'; r.close(); dlp_io.shutdown(); td.cleanup(); print('bare pyd smoke passed on $pythonVersion')" if ($LASTEXITCODE -ne 0) { throw "bare pyd smoke failed on $pythonVersion" } } finally { Pop-Location } } $pySandbox = Join-Path $bareRootFull "py" New-Item -ItemType Directory -Force $pySandbox | Out-Null Copy-Item -LiteralPath (Resolve-Path (Join-Path assets "dlp_io.py")) -Destination $pySandbox Push-Location $pySandbox try { & py -3.13 -c "import dlp_io,sys,tempfile; from pathlib import Path; assert dlp_io.__version__ == '$env:DLP_IO_PACKAGE_VERSION'; assert dlp_io.__file__.endswith('.py'); td=tempfile.TemporaryDirectory(); p=Path(td.name)/'payload.bin'; w=dlp_io.open(p,'wb'); assert w.write(b'0123456789') == 10; w.close(); dlp_io.configure(python_executable=sys.executable); r=dlp_io.open(p,'rb'); assert r.read(4) == b'0123'; assert r.seek(-3,2) == 7; assert r.read() == b'789'; r.close(); dlp_io.shutdown(); td.cleanup(); print('bare py smoke passed')" if ($LASTEXITCODE -ne 0) { throw "bare py smoke failed" } } finally { Pop-Location } } finally { if (Test-Path -LiteralPath $bareRootFull) { Remove-Item -LiteralPath $bareRootFull -Recurse -Force } } - name: Create Gitea release with wheels shell: pwsh env: GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITEA_SERVER: ${{ gitea.server_url }} GITEA_REPOSITORY: ${{ github.repository }} run: | $ErrorActionPreference = "Stop" if ([string]::IsNullOrWhiteSpace($env:GITEA_TOKEN)) { throw "GITHUB_TOKEN is required to publish the release" } $tag = $env:DLP_IO_PACKAGE_TAG $version = $env:DLP_IO_PACKAGE_VERSION $api = "$env:GITEA_SERVER/api/v1/repos/$env:GITEA_REPOSITORY" $headers = @{ Authorization = "token $env:GITEA_TOKEN" } $artifacts = @(Get-ChildItem -LiteralPath dist -File) + @(Get-ChildItem -LiteralPath assets -File) if ($artifacts.Count -eq 0) { throw "no distribution files to publish" } $release = $null try { $release = Invoke-RestMethod -Headers $headers -Uri "$api/releases/tags/$tag" } catch { if ([int]$_.Exception.Response.StatusCode -ne 404) { throw } } if ($null -eq $release) { $notes = @( "dlp-io $version", "", "## 产物", "", "- ``dlp_io-$version-py3-none-any.whl``:纯 Python wheel,pip 安装,跨平台", "- ``dlp_io-$version-cp3XX-cp3XX-win_amd64.whl``:pyd wheel,pip 安装,Windows 按解释器版本选用(cp310-cp314)", "- ``dlp_io-$version.tar.gz``:sdist 源码包", "- ``dlp_io.py`` / ``dlp_io.cp3XX-win_amd64.pyd``:免安装单文件,直接放进项目目录即可 ``import dlp_io``", "", "## 安装", "", "纯 Python wheel(跨平台):", "", "``````", "py -m pip install $env:GITEA_SERVER/$env:GITEA_REPOSITORY/releases/download/$tag/dlp_io-$version-py3-none-any.whl", "``````", "", "pyd wheel(Windows,示例为 CPython 3.13,其它版本替换文件名中的 cp313):", "", "``````", "py -3.13 -m pip install $env:GITEA_SERVER/$env:GITEA_REPOSITORY/releases/download/$tag/dlp_io-$version-cp313-cp313-win_amd64.whl", "``````" ) -join "`n" $body = @{ tag_name = $tag name = "dlp-io $version" body = $notes draft = $false prerelease = $false } | ConvertTo-Json $release = Invoke-RestMethod -Method Post -Headers $headers -Uri "$api/releases" -Body $body -ContentType "application/json" Write-Host "created release: $tag" } else { Write-Host "release already exists: $tag" } foreach ($artifact in $artifacts) { $existing = @($release.assets | Where-Object name -eq $artifact.Name) if ($existing.Count -gt 0) { Write-Host "asset already exists, skipping: $($artifact.Name)" continue } Invoke-RestMethod -Method Post -Headers $headers -Uri "$api/releases/$($release.id)/assets?name=$($artifact.Name)" -Form @{ attachment = Get-Item $artifact.FullName } | Out-Null Write-Host "uploaded asset: $($artifact.Name)" } $published = Invoke-RestMethod -Headers $headers -Uri "$api/releases/$($release.id)" $publishedNames = @($published.assets | ForEach-Object name) $localNames = @($artifacts | ForEach-Object Name) if (Compare-Object $localNames $publishedNames) { throw "release asset mismatch: $($publishedNames -join ', ')" } Write-Host "release verified: $tag ($($localNames.Count) files)" - name: Publish packages to Gitea PyPI shell: pwsh env: TWINE_USERNAME: antior TWINE_PASSWORD: ${{ secrets.CI_PACKAGE_TOKEN }} TWINE_REPOSITORY_URL: ${{ gitea.server_url }}/api/packages/antior/pypi run: | $ErrorActionPreference = "Stop" if ([string]::IsNullOrWhiteSpace($env:TWINE_PASSWORD)) { throw "CI_PACKAGE_TOKEN repository secret (PAT with write:package) is required" } $headers = @{ Authorization = "token $env:TWINE_PASSWORD" } $simpleUrl = "$env:TWINE_REPOSITORY_URL/simple/dlp-io/" function Get-RegistryFileNames { try { $response = Invoke-WebRequest -Headers $headers -Uri $simpleUrl } catch { if ([int]$_.Exception.Response.StatusCode -eq 404) { return @() } throw } return @([regex]::Matches($response.Content, ']*>(?[^<]+)', "IgnoreCase") | ForEach-Object { [Net.WebUtility]::HtmlDecode($_.Groups["name"].Value) }) } $published = @(Get-RegistryFileNames) $missing = @(Get-ChildItem -LiteralPath dist -File | Where-Object { $published -notcontains $_.Name }) if ($missing.Count -gt 0) { $uploadArgs = @("-3.13", "-m", "twine", "upload", "--non-interactive", "--disable-progress-bar") + @($missing | ForEach-Object FullName) & py @uploadArgs if ($LASTEXITCODE -ne 0) { throw "twine upload failed" } } else { Write-Host "all registry artifacts already published" } $expected = @(Get-ChildItem -LiteralPath dist -File | ForEach-Object Name) for ($attempt = 1; $attempt -le 20; $attempt++) { $published = @(Get-RegistryFileNames) $unpublished = @($expected | Where-Object { $published -notcontains $_ }) if ($unpublished.Count -eq 0) { break } if ($attempt -lt 20) { Start-Sleep -Seconds 3 } } if ($unpublished.Count -ne 0) { throw "registry package files missing after upload: $($unpublished -join ', ')" } Write-Host "registry verified: dlp-io==$($env:DLP_IO_PACKAGE_VERSION) ($($expected.Count) files)"