feat(ci/release): every release asset now ships its own SHA256 sidecar

A release page offered a DMG, an MSIX, a setup.exe, an APK and a decky zip with
nothing to check them against — the download either matched what we built or it
didn't, and there was no way for anyone to tell which.

upsert_asset now attaches `<asset>.sha256` next to each asset, so verifying is
`sha256sum -c punktfunk-1.2.3.dmg.sha256` in the download directory. Doing it in
the helper rather than in the callers means all eight packaging workflows inherit
it at once, and a future one can't forget.

Sidecars rather than one shared SHA256SUMS: those workflows attach to the SAME
release object concurrently, so a single manifest would be a read-modify-write
race that silently drops whichever leg lost. One file per asset has no shared
mutable state.

The digest is over the file, but the name written into the sidecar is the ASSET
name — callers rename on upload (Punktfunk-$VERSION.dmg), and `sha256sum -c` looks
up the name it reads. The PowerShell twin writes the line byte-exactly (LF, no
BOM): GNU sha256sum folds a trailing CR into the filename, so PowerShell's default
CRLF would have failed every check on the box doing the verifying.

Verified on both sides — bash and POSIX sh locally (`shasum -a 256 -c` passes),
pwsh on the windows-amd64 runner (91 bytes, last byte 0x0A, no CR, no BOM, same
digest as the bash path).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-29 12:12:21 +02:00
co-authored by Claude Opus 5
parent 80c0ca69fa
commit 06ffca985d
2 changed files with 79 additions and 17 deletions
+31 -11
View File
@@ -4,7 +4,8 @@
# . scripts/ci/gitea-release.ps1 # . scripts/ci/gitea-release.ps1
# Mirrors scripts/ci/gitea-release.sh; parses JSON with ConvertFrom-Json (the Windows runner # Mirrors scripts/ci/gitea-release.sh; parses JSON with ConvertFrom-Json (the Windows runner
# has no python). Same idempotent semantics: Upsert-GiteaAsset deletes an existing asset of # has no python). Same idempotent semantics: Upsert-GiteaAsset deletes an existing asset of
# the same name before uploading, so re-runs / rolling canary uploads don't 409. # the same name before uploading, so re-runs / rolling canary uploads don't 409 — and attaches
# a `<asset>.sha256` sidecar alongside every asset, exactly like the bash twin.
# #
# Env (Gitea Actions sets the first two automatically): # Env (Gitea Actions sets the first two automatically):
# GITHUB_SERVER_URL e.g. https://git.unom.io # GITHUB_SERVER_URL e.g. https://git.unom.io
@@ -57,16 +58,10 @@ function Ensure-GiteaRelease {
} }
} }
# Upsert-GiteaAsset RELEASEID FILE [NAME] # _PutGiteaAsset RELEASEID FILE NAME
# Attach FILE, replacing any existing asset of the same name first (idempotent). # The raw attach: delete any existing asset of the same name, then POST FILE under NAME.
function Upsert-GiteaAsset { function _PutGiteaAsset {
param( param([string]$ReleaseId, [string]$File, [string]$Name)
[Parameter(Mandatory)] [string]$ReleaseId,
[Parameter(Mandatory)] [string]$File,
[string]$Name
)
if (-not (Test-Path $File)) { throw "gitea-release: asset file not found: $File" }
if (-not $Name) { $Name = Split-Path $File -Leaf }
$api = _GiteaApi; $h = _GiteaHeaders $api = _GiteaApi; $h = _GiteaHeaders
$assets = Invoke-RestMethod -Method Get -Uri "$api/releases/$ReleaseId/assets" -Headers $h $assets = Invoke-RestMethod -Method Get -Uri "$api/releases/$ReleaseId/assets" -Headers $h
$existing = $assets | Where-Object { $_.name -eq $Name } | Select-Object -First 1 $existing = $assets | Where-Object { $_.name -eq $Name } | Select-Object -First 1
@@ -80,3 +75,28 @@ function Upsert-GiteaAsset {
if ($LASTEXITCODE -ne 0) { throw "gitea-release: asset upload failed ($LASTEXITCODE): $Name" } if ($LASTEXITCODE -ne 0) { throw "gitea-release: asset upload failed ($LASTEXITCODE): $Name" }
Write-Output "gitea-release: uploaded '$Name' -> release $ReleaseId" Write-Output "gitea-release: uploaded '$Name' -> release $ReleaseId"
} }
# Upsert-GiteaAsset RELEASEID FILE [NAME]
# Attach FILE, replacing any existing asset of the same name first (idempotent), plus a
# `<NAME>.sha256` checksum sidecar so the download is verifiable. Sidecars rather than one
# shared SHA256SUMS because every platform's workflow attaches to the same release object
# concurrently — see the bash twin's comment for the full reasoning.
function Upsert-GiteaAsset {
param(
[Parameter(Mandatory)] [string]$ReleaseId,
[Parameter(Mandatory)] [string]$File,
[string]$Name
)
if (-not (Test-Path $File)) { throw "gitea-release: asset file not found: $File" }
if (-not $Name) { $Name = Split-Path $File -Leaf }
_PutGiteaAsset -ReleaseId $ReleaseId -File $File -Name $Name
if ($Name -like '*.sha256') { return }
# "<digest> <name>", LF-terminated and written byte-exactly: GNU sha256sum -c treats a trailing
# CR as part of the filename, so PowerShell's default CRLF output would make every check fail on
# the Linux/macOS box doing the verifying. The name is the ASSET name, not the local basename.
$digest = (Get-FileHash -Algorithm SHA256 -LiteralPath $File).Hash.ToLowerInvariant()
$sums = Join-Path ([IO.Path]::GetTempPath()) ([IO.Path]::GetRandomFileName())
[IO.File]::WriteAllText($sums, "$digest $Name`n", (New-Object Text.UTF8Encoding $false))
try { _PutGiteaAsset -ReleaseId $ReleaseId -File $sums -Name "$Name.sha256" }
finally { Remove-Item -LiteralPath $sums -Force -ErrorAction SilentlyContinue }
}
+48 -6
View File
@@ -7,6 +7,10 @@
# same name already exists, so re-running a workflow — or reusing the rolling `canary` # same name already exists, so re-running a workflow — or reusing the rolling `canary`
# release with stable filenames — would fail. upsert_asset deletes the old asset first. # release with stable filenames — would fail. upsert_asset deletes the old asset first.
# #
# upsert_asset also attaches a `<asset>.sha256` sidecar for every asset, so a download off a
# release page is verifiable (`sha256sum -c punktfunk-1.2.3.dmg.sha256`) — see its comment for
# why sidecars rather than one shared SHA256SUMS.
#
# Callers run under Gitea Actions' default `bash -eo pipefail`, so a non-zero return from # Callers run under Gitea Actions' default `bash -eo pipefail`, so a non-zero return from
# these functions aborts the step (the desired behaviour on a real failure). # these functions aborts the step (the desired behaviour on a real failure).
# #
@@ -101,14 +105,12 @@ PY
printf '%s' "$id" printf '%s' "$id"
} }
# upsert_asset RELEASE_ID FILE [NAME] # _put_asset RELEASE_ID FILE NAME
# Attach FILE to the release, replacing any existing asset of the same name first so that # The raw attach: delete any existing asset of the same name, then POST FILE under NAME, so
# re-runs and rolling canary re-uploads are idempotent (a plain POST 409s on a dup name). # re-runs and rolling canary re-uploads are idempotent (a plain POST 409s on a dup name).
upsert_asset() { _put_asset() {
local rid="${1:?release id}" file="${2:?file}" name="${3:-}" local rid="$1" file="$2" name="$3"
local api existing local api existing
[ -n "$name" ] || name="$(basename "$file")"
[ -f "$file" ] || { echo "gitea-release: asset file not found: $file" >&2; return 1; }
api="$(_gitea_api)" api="$(_gitea_api)"
existing=$(curl -fsS "$api/releases/$rid/assets" \ existing=$(curl -fsS "$api/releases/$rid/assets" \
-H "Authorization: token ${GITEA_TOKEN:?}" 2>/dev/null \ -H "Authorization: token ${GITEA_TOKEN:?}" 2>/dev/null \
@@ -123,6 +125,46 @@ upsert_asset() {
echo "gitea-release: uploaded '$name' -> release $rid" echo "gitea-release: uploaded '$name' -> release $rid"
} }
# _sha256 FILE -> lowercase hex digest
# python3 rather than sha256sum/shasum: python3 is already a hard dependency of this file, and
# the two coreutils spellings differ across our runners (Linux has sha256sum, macOS only shasum).
_sha256() {
python3 - "$1" <<'PY'
import hashlib, sys
h = hashlib.sha256()
with open(sys.argv[1], "rb") as f:
for chunk in iter(lambda: f.read(1 << 20), b""):
h.update(chunk)
print(h.hexdigest())
PY
}
# upsert_asset RELEASE_ID FILE [NAME]
# Attach FILE to the release AND a `<NAME>.sha256` checksum sidecar next to it, so every
# download off a release page can be verified: sha256sum -c punktfunk-1.2.3.dmg.sha256
#
# Sidecars, not one shared SHA256SUMS, ON PURPOSE: half a dozen workflows (release, windows-*,
# android, decky, deb, rpm, arch, flatpak) attach to the SAME release object concurrently, and a
# single manifest would be a read-modify-write race that silently loses whichever leg lost. One
# sidecar per asset has no shared mutable state — each leg only ever writes names it owns.
#
# Living here rather than in the callers means a new packaging workflow inherits checksums for
# free; the only way to attach an unchecksummed asset is to bypass this helper entirely.
upsert_asset() {
local rid="${1:?release id}" file="${2:?file}" name="${3:-}"
local sums
[ -n "$name" ] || name="$(basename "$file")"
[ -f "$file" ] || { echo "gitea-release: asset file not found: $file" >&2; return 1; }
_put_asset "$rid" "$file" "$name"
# A sidecar gets no sidecar of its own (and neither does a caller attaching one by hand).
case "$name" in *.sha256) return 0 ;; esac
# `sha256sum -c` wants "<digest> <filename>" with the filename as downloaded — i.e. the ASSET
# name, which is not necessarily the local basename (callers rename, e.g. Punktfunk-$VERSION.dmg).
sums="$(mktemp)"
printf '%s %s\n' "$(_sha256 "$file")" "$name" > "$sums"
if _put_asset "$rid" "$sums" "$name.sha256"; then rm -f "$sums"; else rm -f "$sums"; return 1; fi
}
# apply_release_notes RELEASE_ID TAG # apply_release_notes RELEASE_ID TAG
# Force the release body to match docs/releases/<TAG>.md (the source of truth), if that file # Force the release body to match docs/releases/<TAG>.md (the source of truth), if that file
# exists — a no-op otherwise. PATCHes ONLY the body, so name/prerelease/assets are preserved # exists — a no-op otherwise. PATCHes ONLY the body, so name/prerelease/assets are preserved