Files
dlp-io/.gitea/workflows/publish-dlp-io.yaml
T

392 lines
20 KiB
YAML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 wheelpip 安装,跨平台",
"- ``dlp_io-$version-cp3XX-cp3XX-win_amd64.whl``pyd wheelpip 安装,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 wheelWindows,示例为 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, '<a\s+href="[^"]+"[^>]*>(?<name>[^<]+)</a>', "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)"