344 lines
17 KiB
YAML
344 lines
17 KiB
YAML
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)
|
||
foreach ($asset in $existing) {
|
||
Invoke-RestMethod -Method Delete -Headers $headers -Uri "$api/releases/assets/$($asset.id)" | Out-Null
|
||
Write-Host "replaced asset: $($artifact.Name)"
|
||
}
|
||
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)"
|