Compare commits

..
Author SHA1 Message Date
John Winter MurphyandGitHub 020b177bd1 Merge pull request #4 from wownero/fix/macos-signing-keychain-515
fix(release): restore Mac signing keychain search
2026-10-17 08:29:38 -04:00
such-gitea fb0c34cd70 fix(release): restore Mac signing keychain search 2026-10-17 08:27:14 -04:00
John Winter MurphyandGitHub e0670bede5 Merge pull request #3: retain candidate source and signing evidence
fix(release): bind candidate files and signing evidence to each run
2026-10-17 06:21:55 -04:00
such-gitea ea04f585aa fix(release): bind candidate hashes and signing evidence to each run 2026-10-17 06:20:40 -04:00
John Winter MurphyandGitHub 25a341fd10 Merge pull request #2: prepare Wowlet 5.1.5 security release
chore(release): prepare Wowlet 5.1.5
2026-10-17 06:04:13 -04:00
such-gitea ec8bdf4dfe fix(wallet): include the Wownero 0.11.5.0 security library 2026-10-17 06:03:15 -04:00
such-gitea 34bdecf5ac fix(release): embed the verified Wownero 0.11.5.0 daemon 2026-10-17 05:59:54 -04:00
such-gitea d9cd8e328e fix(release): check macOS signing inputs before compilation 2026-10-17 05:52:26 -04:00
such-gitea 998684c1a7 ci(release): bind private Wowlet candidates to reviewed source 2026-10-17 05:51:46 -04:00
such-gitea bf455e06f1 chore(release): prepare Wowlet 5.1.5 2026-10-17 05:46:57 -04:00
John Winter MurphyandGitHub 4e3f74a38a fix: use Wownero support contacts
Use Wownero support contacts and preserve them when regenerating bundled guides. Run lightweight source checks on GitHub; release builds remain in the private Builds system.
2026-10-17 05:32:51 -04:00
20 changed files with 759 additions and 273 deletions
+24 -54
View File
@@ -11,7 +11,16 @@ name: linux appimage (gitea self-hosted)
#
# Dispatch-only. The runner is capacity 1 and shared with the fleet gates.
on: [workflow_dispatch]
on:
workflow_dispatch:
inputs:
expected_sha:
description: Exact reviewed Builds commit
required: true
type: string
env:
EXPECTED_SHA: ${{ inputs.expected_sha }}
jobs:
appimage:
@@ -39,22 +48,16 @@ jobs:
- uses: actions/checkout@v4
with:
submodules: recursive
ref: ${{ github.sha }}
- name: fetch wownerod to embed
- name: Verify exact reviewed source
run: python3 -B contrib/release/check_dispatch.py
- name: fetch verified wownerod to embed
run: |
# ubuntu-latest and windows-latest both resolve to the same host in
# host mode, so a fixed /tmp path lets a concurrently dispatched job's
# `rm -rf` delete this job's binary between fetch and configure. Scope
# every extract to the run.
EMBED="/tmp/wowlet-${GITHUB_RUN_ID:-local}"
rm -rf "$EMBED/wownerod-embed" && mkdir -p "$EMBED/wownerod-embed"
curl -fL -o "$EMBED/wownero.tar.bz2" \
https://github.com/wownero/wownero-codeberg-mirror/releases/download/v0.11.4.0/wownero-x86_64-linux-gnu-v0.11.4.0.tar.bz2
rm -rf "$EMBED/wow-x" && mkdir -p "$EMBED/wow-x"
tar xjf "$EMBED/wownero.tar.bz2" -C "$EMBED/wow-x"
find "$EMBED/wow-x" -name wownerod -type f -exec cp {} "$EMBED/wownerod-embed/" \;
chmod +x "$EMBED/wownerod-embed/wownerod"
ls -la "$EMBED/wownerod-embed"
python3 -B contrib/release/fetch_wownerod.py \
--target x86_64-linux-gnu \
--destination "/tmp/wowlet-${GITHUB_RUN_ID}/wownerod-embed"
- name: fetch tor to embed
run: |
@@ -146,49 +149,16 @@ jobs:
ARCH=x86_64 ./appimagetool AppDir wowlet-x86_64.AppImage
ls -la wowlet-x86_64.AppImage
- name: Record candidate source and hashes
run: python3 -B contrib/release/write_receipt.py --platform linux --file wowlet-x86_64.AppImage --output wowlet-linux-receipt.json
- name: upload wowlet AppImage
# v3: Gitea implements the older artifact protocol and rejects
# @actions/artifact v2 clients (upload-artifact@v4+) as unsupported.
uses: actions/upload-artifact@v3
with:
name: wowlet-x86_64-linux-AppImage
path: wowlet-x86_64.AppImage
path: |
wowlet-x86_64.AppImage
wowlet-linux-receipt.json
if-no-files-found: error
- name: publish the AppImage to the tag's release
# The artifact above uploads fine but this Gitea's REST listing reports
# zero artifacts for every run, so a tag build's output cannot be fetched
# back through the API. Release assets can, so publish there as well.
# Fail-safe by design: with no usable token this says why and exits 0,
# it never turns a green build red.
if: startsWith(github.ref, 'refs/tags/')
env:
RELEASE_TOKEN: ${{ github.token }}
run: |
set -eu
FILE=wowlet-x86_64.AppImage
TAG="${GITHUB_REF#refs/tags/}"
API="${GITHUB_SERVER_URL:-https://git.such.software}/api/v1/repos/$GITHUB_REPOSITORY"
if [ -z "${RELEASE_TOKEN:-}" ]; then
echo "no token available to this runner, skipping the release upload" >&2
exit 0
fi
H="Authorization: token $RELEASE_TOKEN"
# Single-line python on purpose: a continuation line at column 0 would
# end this block scalar and break the workflow's YAML. `|| true` keeps
# a malformed response from aborting the step under `set -e`.
rid=$(curl -sS -H "$H" "$API/releases/tags/$TAG" | python3 -c "import json,sys;print(json.load(sys.stdin).get('id',''))" 2>/dev/null || true)
if [ -z "$rid" ]; then
rid=$(curl -sS -H "$H" -H "Content-Type: application/json" -X POST \
-d "{\"tag_name\":\"$TAG\",\"name\":\"wowlet $TAG\"}" "$API/releases" | python3 -c "import json,sys;print(json.load(sys.stdin).get('id',''))" 2>/dev/null || true)
fi
if [ -z "$rid" ]; then
echo "could not resolve or create a release for $TAG, skipping the upload" >&2
exit 0
fi
code=$(curl -sS -H "$H" -o /dev/null -w '%{http_code}' -X POST \
-F "attachment=@$FILE" "$API/releases/$rid/assets?name=$(basename "$FILE")")
echo "release asset upload returned HTTP $code"
if [ "$code" != "201" ]; then
echo "the release upload did not return 201; the run artifact still holds the build" >&2
fi
+44 -112
View File
@@ -12,9 +12,18 @@ name: macos build (gitea self-hosted)
# APPLE_CERTIFICATE (b64 Developer ID Application .p12),
# APPLE_CERTIFICATE_PASSWORD, APPLE_SIGNING_IDENTITY, APPLE_TEAM_ID,
# APPLE_API_ISSUER, APPLE_API_KEY, APPLE_API_KEY_P8_BASE64 (notarytool).
# If APPLE_CERTIFICATE is unset the build ships unsigned instead of failing.
# Missing signing or notarization inputs refuse before compilation.
on: [workflow_dispatch]
on:
workflow_dispatch:
inputs:
expected_sha:
description: Exact reviewed Builds commit
required: true
type: string
env:
EXPECTED_SHA: ${{ inputs.expected_sha }}
jobs:
build-macos:
@@ -31,30 +40,36 @@ jobs:
- uses: actions/checkout@v4
with:
submodules: recursive
ref: ${{ github.sha }}
- name: install dependencies (brew)
run: |
export PATH="/opt/homebrew/bin:$PATH"
set +o pipefail # yes(1) takes SIGPIPE when brew has nothing to ask
export NONINTERACTIVE=1 HOMEBREW_NO_ENV_HINTS=1 HOMEBREW_NO_INSTALL_UPGRADE=1
yes | brew install --quiet qt libsodium libzip qrencode unbound cmake boost hidapi openssl expat protobuf pkgconfig zxing-cpp
- name: Verify exact reviewed source
run: python3 -B contrib/release/check_dispatch.py
- name: fetch wownerod to embed
- name: Check signing inputs before compiling
run: python3 -B contrib/release/sign_macos.py --check-inputs
- name: Verify Fleet-managed build dependencies
run: |
set -e
ARCH=$(uname -m)
case "$ARCH" in
arm64) WOWTAR=wownero-aarch64-apple-darwin11-v0.11.4.0.tar.bz2 ;;
*) WOWTAR=wownero-x86_64-apple-darwin11-v0.11.4.0.tar.bz2 ;;
export PATH="/opt/homebrew/bin:$PATH"
for formula in qt libsodium libzip qrencode unbound cmake boost hidapi openssl expat protobuf zxing-cpp; do
brew --prefix "$formula" >/dev/null || {
echo "Missing Fleet-managed build dependency: $formula" >&2
exit 1
}
done
- name: fetch verified wownerod to embed
run: |
set -e
case "$(uname -m)" in
arm64) target=aarch64-apple-darwin11 ;;
x86_64) target=x86_64-apple-darwin11 ;;
*) echo "Unsupported macOS build architecture" >&2; exit 1 ;;
esac
mkdir -p /tmp/wownerod-embed /tmp/wow-extract
curl -L -o /tmp/wownero.tar.bz2 \
"https://github.com/wownero/wownero-codeberg-mirror/releases/download/v0.11.4.0/$WOWTAR"
# /tmp is a symlink on macOS — find(1) won't descend it as a bare argument
tar xjf /tmp/wownero.tar.bz2 -C /tmp/wow-extract
find /tmp/wow-extract -name wownerod -type f -exec cp {} /tmp/wownerod-embed/ \;
chmod +x /tmp/wownerod-embed/wownerod
ls -la /tmp/wownerod-embed
EMBED="/tmp/wowlet-${GITHUB_RUN_ID}/wownerod-embed"
python3 -B contrib/release/fetch_wownerod.py --target "$target" --destination "$EMBED"
echo "WOWNEROD_DIR=$EMBED" >> "$GITHUB_ENV"
- name: fetch tor to embed
run: |
@@ -88,7 +103,7 @@ jobs:
export CPATH="$(brew --prefix)/include"
export LIBRARY_PATH="$(brew --prefix)/lib"
mkdir -p build && cd build
cmake -DWITH_SCANNER=OFF -DUSE_DEVICE_TREZOR=OFF -DBoost_USE_STATIC_LIBS=ON -DWOWNEROD_DIR=/tmp/wownerod-embed -DTOR_DIR=/tmp/tor-embed -DTOR_VERSION="$TOR_VERSION" -DCMAKE_PREFIX_PATH="$(brew --prefix qt)" ..
cmake -DWITH_SCANNER=OFF -DUSE_DEVICE_TREZOR=OFF -DBoost_USE_STATIC_LIBS=ON -DWOWNEROD_DIR="$WOWNEROD_DIR" -DTOR_DIR=/tmp/tor-embed -DTOR_VERSION="$TOR_VERSION" -DCMAKE_PREFIX_PATH="$(brew --prefix qt)" ..
cmake --build . -j "$(sysctl -n hw.ncpu)"
- name: package app bundle
@@ -184,47 +199,8 @@ jobs:
ls -la "$FW" | grep -iE "webp|sharpyuv|brotli" || true
ls -la dist/
- name: sign and notarize (skipped when secrets absent)
run: |
set -e
cd build
if [ -z "$APPLE_CERTIFICATE" ]; then
echo "APPLE_CERTIFICATE not set — shipping UNSIGNED build"
exit 0
fi
KC=/tmp/wowlet-ci.keychain-db
KCPW=$(/usr/bin/uuidgen)
echo "$KCPW" > /tmp/wowlet-ci-kcpw # for the cleanup step only
security create-keychain -p "$KCPW" "$KC"
security set-keychain-settings -lut 3600 "$KC"
security unlock-keychain -p "$KCPW" "$KC"
printf '%s' "$APPLE_CERTIFICATE" | base64 -d > /tmp/dev_id.p12
security import /tmp/dev_id.p12 -k "$KC" -P "$APPLE_CERTIFICATE_PASSWORD" \
-T /usr/bin/codesign
security set-key-partition-list -S apple-tool:,apple: -s -k "$KCPW" "$KC" >/dev/null
security list-keychains -d user -s "$KC" login.keychain-db
# Sign inside-out: every Mach-O NOT inside a .framework (embedded
# wownerod/tor, the bundled webp/sharpyuv/brotli dylibs, and the
# image-format plugins whose signatures install_name_tool just
# invalidated — keyed on `file`, not exec bit, since plugins/dylibs
# aren't +x), then each Qt framework as a unit, then the bundle.
SIGN() { codesign --force --options runtime --timestamp --keychain "$KC" \
--sign "$APPLE_SIGNING_IDENTITY" "$@"; }
find dist/Wowlet.app -type f ! -path "*/*.framework/*" \
| while read -r f; do file "$f" | grep -q Mach-O && SIGN "$f" || true; done
find dist/Wowlet.app/Contents/Frameworks -maxdepth 1 -name "*.framework" 2>/dev/null \
| while read -r fw; do SIGN "$fw"; done
SIGN dist/Wowlet.app
codesign --verify --deep --strict dist/Wowlet.app
printf '%s' "$APPLE_API_KEY_P8_BASE64" | base64 -d > /tmp/notary.p8
ditto -c -k --keepParent dist/Wowlet.app /tmp/wowlet-notarize.zip
xcrun notarytool submit /tmp/wowlet-notarize.zip \
--key /tmp/notary.p8 --key-id "$APPLE_API_KEY" --issuer "$APPLE_API_ISSUER" \
--wait --timeout 30m
xcrun stapler staple dist/Wowlet.app
echo "signed + notarized + stapled"
- name: Sign and notarize the private candidate
run: python3 -B contrib/release/sign_macos.py
- name: zip artifact
run: |
@@ -233,61 +209,17 @@ jobs:
ditto -c -k --keepParent Wowlet.app ../wowlet-macos-$(uname -m).zip
ls -la ../wowlet-macos-*.zip
- name: cleanup signing material
if: always()
run: |
if [ -f /tmp/wowlet-ci-kcpw ]; then
security delete-keychain /tmp/wowlet-ci.keychain-db || true
fi
rm -f /tmp/dev_id.p12 /tmp/notary.p8 /tmp/wowlet-ci-kcpw /tmp/wowlet-notarize.zip
- name: Record signed candidate source and hashes
run: python3 -B contrib/release/write_receipt.py --platform macos --file build/wowlet-macos-$(uname -m).zip --output build/wowlet-macos-receipt.json
- name: upload artifact
uses: actions/upload-artifact@v3
with:
name: wowlet-macos
path: build/wowlet-macos-*.zip
path: |
build/wowlet-macos-*.zip
build/wowlet-macos-receipt.json
retention-days: 14
# Without this an empty upload passes silently, which is how a signed
# build can appear to publish while shipping nothing.
if-no-files-found: error
- name: publish the signed zip to the tag's release
# Same reason as the Linux job: run artifacts upload but this Gitea's
# REST listing returns zero for every run, so the notarized zip cannot
# be fetched back through the API. Release assets can be. Fail-safe:
# with no usable token this says why and exits 0.
if: startsWith(github.ref, 'refs/tags/')
env:
RELEASE_TOKEN: ${{ github.token }}
run: |
set -eu
FILE=$(ls build/wowlet-macos-*.zip 2>/dev/null | head -1)
if [ -z "$FILE" ]; then
echo "no macOS zip to publish; the packaging step should have failed first" >&2
exit 0
fi
TAG="${GITHUB_REF#refs/tags/}"
API="${GITHUB_SERVER_URL:-https://git.such.software}/api/v1/repos/$GITHUB_REPOSITORY"
if [ -z "${RELEASE_TOKEN:-}" ]; then
echo "no token available to this runner, skipping the release upload" >&2
exit 0
fi
H="Authorization: token $RELEASE_TOKEN"
# Single-line python on purpose: a continuation line at column 0 would
# end this block scalar and break the workflow's YAML. `|| true` keeps
# a malformed response from aborting the step under `set -e`.
rid=$(curl -sS -H "$H" "$API/releases/tags/$TAG" | python3 -c "import json,sys;print(json.load(sys.stdin).get('id',''))" 2>/dev/null || true)
if [ -z "$rid" ]; then
rid=$(curl -sS -H "$H" -H "Content-Type: application/json" -X POST \
-d "{\"tag_name\":\"$TAG\",\"name\":\"wowlet $TAG\"}" "$API/releases" | python3 -c "import json,sys;print(json.load(sys.stdin).get('id',''))" 2>/dev/null || true)
fi
if [ -z "$rid" ]; then
echo "could not resolve or create a release for $TAG, skipping the upload" >&2
exit 0
fi
code=$(curl -sS -H "$H" -o /dev/null -w '%{http_code}' -X POST \
-F "attachment=@$FILE" "$API/releases/$rid/assets?name=$(basename "$FILE")")
echo "release asset upload returned HTTP $code"
if [ "$code" != "201" ]; then
echo "the release upload did not return 201; the run artifact still holds the build" >&2
fi
+70 -57
View File
@@ -20,9 +20,16 @@ name: windows build + sign (gitea self-hosted)
#
# Dispatch-only. Both runners are capacity 1 and shared with other lanes.
on: [workflow_dispatch]
on:
workflow_dispatch:
inputs:
expected_sha:
description: Exact reviewed Builds commit
required: true
type: string
env:
EXPECTED_SHA: ${{ inputs.expected_sha }}
HOST: x86_64-w64-mingw32
jobs:
@@ -52,6 +59,10 @@ jobs:
- uses: actions/checkout@v4
with:
submodules: recursive
ref: ${{ github.sha }}
- name: Verify exact reviewed source
run: python3 -B contrib/release/check_dispatch.py
# depends is the slow part (static Qt6/boost/openssl/tor from source).
# Cache the source tarballs and the compiled packages so only a cold run
@@ -96,19 +107,11 @@ jobs:
path: contrib/depends/built
key: depends-built-mingw32-${{ hashFiles('contrib/depends/packages/*', 'contrib/depends/hosts/*', 'contrib/depends/toolchain.cmake.in') }}
- name: fetch wownerod.exe to embed
- name: fetch verified wownerod to embed
run: |
# This job and the Linux one land on the same host in host mode, and a
# fixed /tmp path let each wipe the other's binary mid-build. Scope the
# extract to the run.
EMBED="/tmp/wowlet-${GITHUB_RUN_ID:-local}"
rm -rf "$EMBED/wownerod-embed" && mkdir -p "$EMBED/wownerod-embed"
curl -fL -o "$EMBED/wownero-win.zip" \
https://github.com/wownero/wownero-codeberg-mirror/releases/download/v0.11.4.0/wownero-x86_64-w64-mingw32-v0.11.4.0.zip
# -j flattens the versioned subdir so wownerod.exe is a DIRECT child
# (WownerodQrcGenerator globs ${WOWNEROD_DIR}/* non-recursively).
unzip -o -j "$EMBED/wownero-win.zip" 'wownero-x86_64-w64-mingw32-v0.11.4.0/wownerod.exe' -d "$EMBED/wownerod-embed"
ls -la "$EMBED/wownerod-embed"
python3 -B contrib/release/fetch_wownerod.py \
--target x86_64-w64-mingw32 \
--destination "/tmp/wowlet-${GITHUB_RUN_ID}/wownerod-embed"
- name: build wowlet.exe
run: |
@@ -138,6 +141,9 @@ jobs:
(cp LICENSE stage/LICENSE.txt 2>/dev/null || cp COPYING stage/LICENSE.txt 2>/dev/null) || true
ls -la stage stage/tor
- name: Record unsigned build source and hash
run: python3 -B contrib/release/write_receipt.py --platform windows-build --file stage/wowlet.exe --output stage/wowlet-build.json
- name: upload unsigned staging tree
# v3, not v4: Gitea Actions implements the older artifact protocol and
# rejects @actions/artifact v2 clients with
@@ -153,12 +159,39 @@ jobs:
needs: build
runs-on: windows # gitea label windows:host -> desktop-iim2fs2
steps:
- name: Prepare the exact signing run workspace
shell: powershell
run: |
$ErrorActionPreference = 'Stop'
if ($env:EXPECTED_SHA -notmatch '^[0-9a-f]{40}$' -or $env:GITHUB_SHA -ne $env:EXPECTED_SHA) {
throw "Signing run differs from the reviewed source"
}
foreach ($path in @('stage', 'unsigned', 'wowlet-build.json', 'wowlet-windows-receipt.json', 'wowlet-x86_64-windows.zip', 'wowlet-x86_64-windows-unsigned.zip')) {
if (Test-Path -LiteralPath $path) { Remove-Item -LiteralPath $path -Recurse -Force }
}
- name: download unsigned staging tree
uses: actions/download-artifact@v3
with:
name: wowlet-x86_64-windows-unsigned
path: stage
- name: Verify exact signing source and unsigned input
shell: powershell
run: |
$ErrorActionPreference = 'Stop'
if ($env:EXPECTED_SHA -notmatch '^[0-9a-f]{40}$' -or $env:GITHUB_SHA -ne $env:EXPECTED_SHA) {
throw "Signing run differs from the reviewed source"
}
$build = Get-Content 'stage/wowlet-build.json' -Raw | ConvertFrom-Json
if ($build.dispatch_commit -ne $env:EXPECTED_SHA -or $build.platform -ne 'windows-build' -or $build.artifacts.Count -ne 1) {
throw "Unsigned build receipt differs from the reviewed signing run"
}
if ($build.artifacts[0].name -ne 'wowlet.exe' -or $build.artifacts[0].sha256 -ne (Get-FileHash 'stage/wowlet.exe' -Algorithm SHA256).Hash.ToLowerInvariant()) {
throw "Unsigned executable differs from its build receipt"
}
Move-Item 'stage/wowlet-build.json' 'wowlet-build.json'
- name: keep the unsigned tree for the reproducible archive
shell: powershell
run: |
@@ -262,6 +295,29 @@ jobs:
(Get-FileHash $archive -Algorithm SHA256).Hash
}
- name: Record signed candidate source, hashes and certificate
shell: powershell
run: |
$ErrorActionPreference = 'Stop'
$build = Get-Content 'wowlet-build.json' -Raw | ConvertFrom-Json
$sig = Get-AuthenticodeSignature 'stage/wowlet.exe'
if ($sig.Status -ne 'Valid' -or -not $sig.TimeStamperCertificate -or $sig.SignerCertificate.Subject -notmatch 'Such Software LLC') {
throw "Final executable signature is not valid, timestamped and from the expected publisher"
}
$files = @('wowlet-x86_64-windows.zip', 'wowlet-x86_64-windows-unsigned.zip') | ForEach-Object {
@{name=$_;size=(Get-Item $_).Length;sha256=(Get-FileHash $_ -Algorithm SHA256).Hash.ToLowerInvariant()}
}
$receipt = @{
schema_version=1;app='wowlet';version=$build.version;platform='windows';
dispatch_commit=$env:GITHUB_SHA;wallet_library_commit=$build.wallet_library_commit;
environment_run_id=$env:GITHUB_RUN_ID;environment_run_attempt=$env:GITHUB_RUN_ATTEMPT;
artifacts=@($files);unsigned_executable=$build.artifacts[0];
signed_executable_sha256=(Get-FileHash 'stage/wowlet.exe' -Algorithm SHA256).Hash.ToLowerInvariant();
signing=@{status=$sig.Status.ToString();timestamped=$true;subject=$sig.SignerCertificate.Subject;
certificate_thumbprint=$sig.SignerCertificate.Thumbprint;certificate_not_after=$sig.SignerCertificate.NotAfter.ToUniversalTime().ToString('o')}
}
$receipt | ConvertTo-Json -Depth 6 | Set-Content -Encoding UTF8 'wowlet-windows-receipt.json'
- name: upload Windows zips
uses: actions/upload-artifact@v3
with:
@@ -269,48 +325,5 @@ jobs:
path: |
wowlet-x86_64-windows.zip
wowlet-x86_64-windows-unsigned.zip
wowlet-windows-receipt.json
if-no-files-found: error
- name: publish the Windows zips to the tag's release
# Same reason as the Linux and macOS jobs: artifacts upload fine, but
# this Gitea's REST listing reports zero for every run, so the signed exe
# cannot be fetched back through the API. Release assets can be.
#
# This runs after the Authenticode, timestamp and signer checks above, so
# an unsigned or untimestamped binary never reaches a release. curl.exe,
# not Invoke-WebRequest: `shell: powershell` is Windows PowerShell 5.1,
# whose Invoke-WebRequest has no -Form for multipart uploads.
#
# Fail-safe: a build that produced a correctly signed binary must not go
# red because publishing failed, so every bad path reports and exits 0.
if: startsWith(github.ref, 'refs/tags/')
shell: powershell
env:
RELEASE_TOKEN: ${{ github.token }}
run: |
$ErrorActionPreference = 'Continue'
if (-not $env:RELEASE_TOKEN) {
Write-Host "no token available to this runner, skipping the release upload"
exit 0
}
$tag = $env:GITHUB_REF -replace '^refs/tags/', ''
$server = if ($env:GITHUB_SERVER_URL) { $env:GITHUB_SERVER_URL } else { 'https://git.such.software' }
$api = "$server/api/v1/repos/$env:GITHUB_REPOSITORY"
$auth = "Authorization: token $env:RELEASE_TOKEN"
$rid = ''
try { $rid = ((curl.exe -sS -H $auth "$api/releases/tags/$tag") | ConvertFrom-Json).id } catch { $rid = '' }
if (-not $rid) {
try {
$body = "{""tag_name"":""$tag"",""name"":""wowlet $tag""}"
$rid = ((curl.exe -sS -H $auth -H "Content-Type: application/json" -X POST -d $body "$api/releases") | ConvertFrom-Json).id
} catch { $rid = '' }
}
if (-not $rid) {
Write-Host "could not resolve or create a release for $tag, skipping the upload"
exit 0
}
foreach ($f in @('wowlet-x86_64-windows.zip', 'wowlet-x86_64-windows-unsigned.zip')) {
if (-not (Test-Path $f)) { Write-Host "missing $f, nothing to publish for it"; continue }
$code = curl.exe -sS -o NUL -w '%{http_code}' -H $auth -F "attachment=@$f" "$api/releases/$rid/assets?name=$f"
Write-Host "release asset upload for $f returned HTTP $code"
}
+15
View File
@@ -0,0 +1,15 @@
name: Wowlet verify
on:
push:
branches: [master]
pull_request:
jobs:
verify:
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/checkout@v4
- name: Check support guides and release source admission
run: make check
+1 -1
View File
@@ -1,6 +1,6 @@
[submodule "monero"]
path = monero
url = https://codeberg.org/wownero/wownero.git
url = https://github.com/wownero/wownero.git
branch = gui
[submodule "src/third-party/polyseed"]
path = src/third-party/polyseed
+1 -1
View File
@@ -1,7 +1,7 @@
cmake_minimum_required(VERSION 3.18)
project(feather
VERSION "5.1.4"
VERSION "5.1.5"
DESCRIPTION "A free Monero desktop wallet"
LANGUAGES CXX C ASM
)
+1
View File
@@ -15,5 +15,6 @@ default: build
check:
PYTHONDONTWRITEBYTECODE=1 python3 -m unittest discover -s contrib/docs/tests -v
PYTHONDONTWRITEBYTECODE=1 python3 -m unittest discover -s contrib/release/tests -v
.PHONY: default build attest verify clean check
+40 -45
View File
@@ -1,46 +1,41 @@
# Release process
# Wowlet release process
- Before a minor release:
- Update `src/assets/restore_heights_monero_{mainnet,stagenet}.txt`
- To obtain values, run `contrib/generate-restore-heights/heights.py`
- Update default node lists in `src/assets/nodes.json`
- Bump `openssl`, `qt`, `tor_*` packages in `contrib/depends/packages`
- Update or patch any statically linked dependencies that have known vulnerabilities
- Run `feather-utils/depends/vulns.py` to check
- Rebase on top of latest Monero version
- Update `src/assets/ack.txt`
- Update documentation (`feather-wallet/feather-docs`) and `external/feather-docs` submodule
- Update the version number in `CMakeLists.txt`
- A hardfork-ready release must bump major version
- Create an annotated tag (`git tag x.x.x -a`)
- Tag must match version in `CMakeLists.txt`
- Only commits that update the version number in `CMakeLists.txt` may be tagged
- Push the master branch and tags
- `git push --tags origin master`
- Run `guix` builds in a clean repo:
- ```bash
git clone https://github.com/feather-wallet/feather.git
cd feather
git checkout <TAG>
git submodule update --init --recursive
./contrib/guix/guix-build
```
- Alternatively, run `feather-utils/guix/run-build.sh`
- Attest the builds (`contrib/guix/guix-attest`) and PR to `feather/feather-sigs`.
- Wait until at least one other person has independently verified reproducibility
- If builds are not reproducible: fix any reproducibility defects and bump patch version. Do not sign or release non-reproducible builds.
- Sign release artifacts and hashlists.
- Transfer files in `guix-build-x.x.x/output` to release signing machine
- Run `feather-utils/release/make-release.sh`
- Update the site (`feather-wallet/feather-site`)
- Run the `feather-utils/site/bump-version.py` script to create a template commit.
- Edit the changelog in `content/changelog`
- If a release includes security fixes add "(includes security fixes)" to the title.
- Update the version number, file sizes and paths in `data/release.json`
- Upload releases, signatures and signed hashlists.
- Follow the directory structure defined in `MainWindow::onShowUpdateCheck`.
- Make `depends` source files [available](https://featherwallet.org/files/sources/):
- `make -C contrib/depends download`
- Announce release on irc/Matrix
- Update websocket servers to notify clients of new release
- Wait up to 7 days to allow for bug reports before major rollout
GitHub `wownero/wowlet` owns source and public releases. Private `Builds/wowlet`
is the build ingress on our hardware. Fleet owns dispatch and publication plans.
A source merge, a build, a code signature and a published release are separate outcomes.
1. Prepare and review a GitHub pull request with the version, wallet-library pin,
support documentation and release inputs. Publish a new library commit to its
canonical repository before pinning it. Keep source and dependency revisions exact.
2. `make check` runs the cheap source checks. Run the private Linux, Windows and
macOS workflows through reviewed Fleet admission with `expected_sha` equal to
the protected Builds commit. Preserve canonical source and Builds commit identities
separately. Do not run release builds on GitHub.
3. The build helpers verify embedded Wownero archives against `contrib/release/wownerod.json`.
That file records the reviewed checksum provenance. Do not describe an unsigned
checksum list as a PGP verification.
4. Collect artifacts by their run and artifact identities. Require a Linux AppImage,
macOS archive, signed Windows archive and unsigned Windows archive. Check archive
paths, bundled runtimes and packaging requirements. Never select an artifact by size.
5. Require Developer ID signing, accepted notarization and stapling for macOS.
Require valid, timestamped Such Software LLC Authenticode signing for Windows.
The EV token stays on its signing host; CI never receives its password or key.
6. Record any manual wallet checks actually performed. Manual testing and independent
reproducibility are separate evidence; successful builds do not prove either.
The unsigned Windows archive is provided for reproduction, without claiming an
independent reproduction occurred.
7. Create `hashes.txt` over the four final archives. Sign it on the owner's signing
machine with the existing release key `9F9930300530DF724761B7055A881347310EB79F`.
Verify the detached signature and all hashes before publication. Never transfer
the private key or put a passphrase in a command, environment or log.
8. Create an annotated `vX.Y.Z` tag from the exact reviewed canonical source, matching
`CMakeLists.txt`. Use Fleet's reviewed publication plan to upload the four archives,
`hashes.txt` and `hashes.txt.asc` to GitHub Releases. Refuse replacement of existing
tags or assets; recover partial publication from retained intent.
9. Download the public assets again and verify their hashes and PGP signature. Record
the tag, source commit, Builds commit, run identities, signing evidence and public
URL in the private journal. Release notes should say when security fixes are included.
Additional architectures and Guix builds remain separate lanes. Do not claim a
platform is released merely because its workflow exists. Announcements are an
owner action, separate from publishing the release files.
+22
View File
@@ -0,0 +1,22 @@
"""Refuse a build whose resolved source differs from its reviewed request."""
import os
import re
import subprocess
def check(expected, reported, checked_out):
if not re.fullmatch(r"[0-9a-f]{40}", expected or ""):
raise ValueError("expected_sha must identify the exact reviewed commit")
if reported != expected:
raise ValueError("provider run head differs from expected_sha")
if checked_out != expected:
raise ValueError("checked-out source differs from expected_sha")
if __name__ == "__main__":
head = subprocess.check_output(["git", "rev-parse", "HEAD"], text=True).strip()
try:
check(os.environ.get("EXPECTED_SHA"), os.environ.get("GITHUB_SHA"), head)
except ValueError as error:
raise SystemExit(str(error)) from None
print("Exact requested build source verified")
+73
View File
@@ -0,0 +1,73 @@
"""Fetch the pinned daemon archive and copy only its verified daemon binary."""
import argparse
import hashlib
import json
from pathlib import Path, PurePosixPath
import shutil
import tarfile
import tempfile
import urllib.request
import zipfile
MAX_ARCHIVE = 300 * 1024 * 1024
MAX_BINARY = 128 * 1024 * 1024
TARGETS = {"x86_64-linux-gnu", "x86_64-w64-mingw32", "aarch64-apple-darwin11", "x86_64-apple-darwin11"}
def copy_daemon(archive, destination, windows):
name = "wownerod.exe" if windows else "wownerod"
if windows:
container = zipfile.ZipFile(archive)
members = [m for m in container.infolist() if PurePosixPath(m.filename).name == name]
valid = lambda m: not m.is_dir() and (m.external_attr >> 16 & 0o170000) in (0, 0o100000) and 0 < m.file_size <= MAX_BINARY
reader = container.open
else:
container = tarfile.open(archive, "r:bz2")
members = [m for m in container.getmembers() if PurePosixPath(m.name).name == name]
valid = lambda m: m.isfile() and 0 < m.size <= MAX_BINARY
reader = container.extractfile
with container:
if len(members) != 1 or not valid(members[0]):
raise ValueError("Pinned archive must contain exactly one regular daemon binary")
# Do not extract archive paths, symlinks or unrelated files.
with reader(members[0]) as source, (destination / name).open("xb") as output:
shutil.copyfileobj(source, output)
(destination / name).chmod(0o755)
def fetch(target, destination):
manifest = json.loads(Path(__file__).with_name("wownerod.json").read_text())
suffix = ".zip" if target.endswith("mingw32") else ".tar.bz2"
filename = f"wownero-{target}-{manifest['version']}{suffix}"
expected = manifest["archives"][filename]
destination.mkdir(parents=True, exist_ok=True)
with tempfile.TemporaryDirectory(prefix="wowlet-daemon-") as staging:
archive = Path(staging) / filename
digest = hashlib.sha256()
size = 0
url = f"https://github.com/wownero/wownero/releases/download/{manifest['version']}/{filename}"
with urllib.request.urlopen(url, timeout=120) as response, archive.open("xb") as output:
if not response.geturl().startswith("https://"):
raise ValueError("Daemon download redirected outside HTTPS")
while chunk := response.read(1024 * 1024):
size += len(chunk)
if size > MAX_ARCHIVE:
raise ValueError("Daemon archive exceeds size bound")
digest.update(chunk)
output.write(chunk)
if digest.hexdigest() != expected:
raise ValueError("Daemon archive SHA-256 differs from the reviewed release pin")
copy_daemon(archive, destination, target.endswith("mingw32"))
print(f"Verified {filename}: {expected}")
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--target", required=True, choices=sorted(TARGETS))
parser.add_argument("--destination", required=True, type=Path)
args = parser.parse_args()
fetch(args.target, args.destination)
if __name__ == "__main__":
main()
+204
View File
@@ -0,0 +1,204 @@
"""Sign one private candidate without placing passwords in process arguments.
Apple SecurityTool reads one quoted operation from stdin in interactive mode:
https://github.com/apple-oss-distributions/SecurityTool/blob/main/security.c
Output stays captured because native diagnostics may repeat supplied input.
"""
import base64
from contextlib import contextmanager
import json
import os
from pathlib import Path
import secrets
import shlex
import subprocess
import sys
import tempfile
import uuid
FIELDS = (
"APPLE_CERTIFICATE", "APPLE_CERTIFICATE_PASSWORD", "APPLE_SIGNING_IDENTITY",
"APPLE_API_ISSUER", "APPLE_API_KEY", "APPLE_API_KEY_P8_BASE64",
)
def argument(value):
if any(ord(char) < 32 or ord(char) == 127 for char in value):
raise ValueError("Keychain input contains an unsupported control character")
return '"' + value.replace('\\', '\\\\').replace('"', '\\"') + '"'
def child_environment():
return {key: os.environ[key] for key in ("HOME", "PATH", "TMPDIR", "LANG", "DEVELOPER_DIR") if key in os.environ}
def security(operation, *values):
if operation not in {
"create-keychain", "set-keychain-settings", "unlock-keychain", "import",
"set-key-partition-list", "delete-keychain", "list-keychains",
}:
raise ValueError("Keychain operation is outside candidate signing")
command = " ".join(argument(value) for value in (operation, *values)) + "\n"
if len(command.encode()) > 1024:
raise ValueError("Keychain input exceeds the bounded operation size")
result = subprocess.run(
["/usr/bin/security", "-i", "-q"], input=command,
text=True, capture_output=True, env=child_environment(), timeout=90,
)
if result.returncode:
# Never forward diagnostics or operation data. Report recognized causes.
causes = {
"MAC verification failed": "PKCS12 encoding or password verification failed",
"User interaction is not allowed": "keychain requires interactive access",
"already exists": "ephemeral keychain or imported identity already exists",
"could not be found": "requested keychain item was not found",
}
cause = next((cause for marker, cause in causes.items() if marker in result.stderr),
"native keychain operation returned an error")
raise RuntimeError(f"{operation}: {cause} (exit {result.returncode}); raw diagnostics withheld")
return result.stdout
def run(arguments, *, timeout=120):
result = subprocess.run(arguments, text=True, capture_output=True,
env=child_environment(), timeout=timeout)
if result.returncode:
causes = {
"no identity found": "expected signing identity was not found",
"User interaction is not allowed": "signing key requires interactive access",
"errSecInternalComponent": "keychain denied access to the signing identity",
"not signed at all": "candidate contains an unsigned component",
"resource fork, Finder information": "candidate contains metadata forbidden by code signing",
"unable to build chain": "signing certificate chain could not be built",
"unsealed contents present": "embedded bundle has unsealed root contents",
"bundle format unrecognized": "component has an unsupported bundle format",
"timestamp service": "Apple timestamp service failed",
"Timestamping service": "Apple timestamp service failed",
"certificate has expired": "signing certificate has expired",
"CSSMERR_TP_CERT_EXPIRED": "signing certificate has expired",
}
cause = next((cause for marker, cause in causes.items() if marker in result.stderr),
"native signing operation returned an error")
raise RuntimeError(f"{Path(arguments[0]).name}: {cause} (exit {result.returncode}); raw diagnostics withheld")
return result.stdout
@contextmanager
def keychain_search(keychain):
previous = [value for value in shlex.split(security("list-keychains", "-d", "user"))
if Path(value) != keychain]
if not previous or any(not Path(value).is_absolute() for value in previous):
raise RuntimeError("Existing user keychain search list is absent or malformed")
try:
security("list-keychains", "-d", "user", "-s", str(keychain), *previous)
yield
finally:
security("list-keychains", "-d", "user", "-s", *previous)
def signable_files(app):
for path in sorted(app.rglob("*")):
if path.is_symlink() or not path.is_file() or any(part.endswith(".framework") for part in path.parts):
continue
if "Mach-O" in run(["/usr/bin/file", "-b", str(path)]):
yield path
def private_file(path, content):
descriptor = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
with os.fdopen(descriptor, "wb") as stream:
stream.write(content)
def signing_inputs():
credentials = {name: os.environ.get(name, "") for name in FIELDS}
missing = [name for name, value in credentials.items() if not value]
if missing:
raise ValueError("Signing input is missing: " + ", ".join(missing))
if "Such Software" not in credentials["APPLE_SIGNING_IDENTITY"]:
raise ValueError("Developer ID identity does not name the expected publisher")
return credentials
def main():
credentials = signing_inputs()
app = Path("build/dist/Wowlet.app").resolve()
if not app.is_dir():
raise ValueError("Packaged Wowlet.app is missing")
old_umask = os.umask(0o077)
try:
with tempfile.TemporaryDirectory(prefix=".wowlet-signing-", dir=app.parent) as temporary:
workspace = Path(temporary)
keychain = workspace / "candidate.keychain-db"
certificate = workspace / "identity.p12"
notary_key = workspace / "notary.p8"
private_file(certificate, base64.b64decode(credentials["APPLE_CERTIFICATE"]))
private_file(notary_key, base64.b64decode(credentials["APPLE_API_KEY_P8_BASE64"]))
password = secrets.token_hex(32)
created = False
try:
security("create-keychain", "-p", password, str(keychain))
created = True
security("set-keychain-settings", "-lut", "3600", str(keychain))
security("unlock-keychain", "-p", password, str(keychain))
security("import", str(certificate), "-k", str(keychain), "-P",
credentials["APPLE_CERTIFICATE_PASSWORD"], "-T", "/usr/bin/codesign")
security("set-key-partition-list", "-S", "apple-tool:,apple:", "-s",
"-k", password, str(keychain))
with keychain_search(keychain):
def sign(path):
print("Signing component: " + (str(path.relative_to(app)) if path != app else "Wowlet.app"), flush=True)
run(["/usr/bin/codesign", "--force", "--options", "runtime", "--timestamp",
"--keychain", str(keychain), "--sign",
credentials["APPLE_SIGNING_IDENTITY"], str(path)])
for path in signable_files(app):
sign(path)
frameworks = app / "Contents/Frameworks"
for path in sorted(frameworks.glob("*.framework")):
sign(path)
sign(app)
run(["/usr/bin/codesign", "--verify", "--deep", "--strict", str(app)])
archive = workspace / "candidate.zip"
run(["/usr/bin/ditto", "-c", "-k", "--keepParent", str(app), str(archive)])
raw = run(["/usr/bin/xcrun", "notarytool", "submit", str(archive),
"--key", str(notary_key), "--key-id", credentials["APPLE_API_KEY"],
"--issuer", credentials["APPLE_API_ISSUER"], "--wait", "--timeout", "30m",
"--output-format", "json"], timeout=1900)
response = json.loads(raw)
if response.get("status") != "Accepted":
raise RuntimeError("Apple notarization did not return Accepted")
try:
uuid.UUID(response.get("id", ""))
except (ValueError, TypeError, AttributeError):
raise RuntimeError("Apple notarization submission identity is missing or malformed") from None
run(["/usr/bin/xcrun", "stapler", "staple", str(app)])
run(["/usr/bin/xcrun", "stapler", "validate", str(app)])
private_file(app.parent / "macos-signing.json", json.dumps({
"developer_id_verified": True, "notarization_status": "Accepted",
"notary_submission_id": response.get("id"), "staple_validated": True,
}, sort_keys=True).encode())
print("Developer ID signature verified; notarization accepted; staple validated")
finally:
if created:
security("delete-keychain", str(keychain))
finally:
os.umask(old_umask)
if __name__ == "__main__":
try:
if sys.argv[1:] == ["--check-inputs"]:
signing_inputs()
print("Required signing inputs are present")
elif sys.argv[1:]:
raise ValueError("Use no arguments to sign or --check-inputs for the cheap input guard")
else:
main()
except (OSError, ValueError, RuntimeError, subprocess.TimeoutExpired) as error:
# These exceptions contain logical fields, native result classes or
# public file paths. Captured child output is never included.
raise SystemExit(str(error)) from None
+20
View File
@@ -0,0 +1,20 @@
import importlib.util
from pathlib import Path
import unittest
spec = importlib.util.spec_from_file_location("dispatch", Path(__file__).parents[1] / "check_dispatch.py")
dispatch = importlib.util.module_from_spec(spec)
spec.loader.exec_module(dispatch)
class DispatchTests(unittest.TestCase):
def test_exact_source_is_required_before_building(self):
sha = "a" * 40
dispatch.check(sha, sha, sha)
for expected, reported, checked_out in (
(None, sha, sha), ("master", sha, sha),
(sha, "b" * 40, sha), (sha, sha, "b" * 40),
):
with self.subTest(expected=expected, reported=reported, checked_out=checked_out):
with self.assertRaises(ValueError):
dispatch.check(expected, reported, checked_out)
@@ -0,0 +1,49 @@
import importlib.util
import io
from pathlib import Path
import tarfile
import tempfile
import unittest
from unittest.mock import patch
SPEC = importlib.util.spec_from_file_location("fetch_wownerod", Path(__file__).parents[1] / "fetch_wownerod.py")
fetcher = importlib.util.module_from_spec(SPEC)
SPEC.loader.exec_module(fetcher)
class DaemonTests(unittest.TestCase):
def test_checksum_failure_never_extracts_a_daemon(self):
response = io.BytesIO(b"untrusted archive bytes")
response.geturl = lambda: "https://release-assets.githubusercontent.com/archive"
with tempfile.TemporaryDirectory() as tmp, patch.object(fetcher.urllib.request, "urlopen", return_value=response), patch.object(fetcher, "copy_daemon") as extract:
with self.assertRaisesRegex(ValueError, "SHA-256"):
fetcher.fetch("x86_64-linux-gnu", Path(tmp) / "daemon")
extract.assert_not_called()
self.assertFalse((Path(tmp) / "daemon/wownerod").exists())
def test_archive_paths_cannot_write_outside_destination(self):
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
archive = root / "daemon.tar.bz2"
with tarfile.open(archive, "w:bz2") as output:
info = tarfile.TarInfo("../../wownerod")
info.size = 3
output.addfile(info, io.BytesIO(b"exe"))
destination = root / "stage"
destination.mkdir()
fetcher.copy_daemon(archive, destination, False)
self.assertEqual((destination / "wownerod").read_bytes(), b"exe")
self.assertFalse((root / "wownerod").exists())
def test_symlink_daemon_is_refused(self):
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
archive = root / "daemon.tar.bz2"
with tarfile.open(archive, "w:bz2") as output:
info = tarfile.TarInfo("wownerod")
info.type = tarfile.SYMTYPE
info.linkname = "/etc/passwd"
output.addfile(info)
with self.assertRaisesRegex(ValueError, "regular daemon"):
fetcher.copy_daemon(archive, root, False)
self.assertFalse((root / "wownerod").exists())
+26
View File
@@ -0,0 +1,26 @@
import importlib.util
from pathlib import Path
import sys
import tempfile
import unittest
sys.path.insert(0, str(Path(__file__).parents[1]))
SPEC = importlib.util.spec_from_file_location("write_receipt", Path(__file__).parents[1] / "write_receipt.py")
receipt = importlib.util.module_from_spec(SPEC)
SPEC.loader.exec_module(receipt)
class ReceiptTests(unittest.TestCase):
def test_hash_changes_with_artifact_bytes_and_symlinks_refuse(self):
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / "candidate.zip"
path.write_bytes(b"candidate")
first = receipt.file_record(path)
path.write_bytes(b"different candidate")
second = receipt.file_record(path)
self.assertNotEqual(first["sha256"], second["sha256"])
self.assertEqual(second["size"], path.stat().st_size)
link = Path(tmp) / "link.zip"
link.symlink_to(path)
with self.assertRaisesRegex(ValueError, "regular file"):
receipt.file_record(link)
+95
View File
@@ -0,0 +1,95 @@
import importlib.util
from pathlib import Path
from types import SimpleNamespace
import unittest
import tempfile
from unittest.mock import patch
spec = importlib.util.spec_from_file_location("sign_macos", Path(__file__).parents[1] / "sign_macos.py")
signing = importlib.util.module_from_spec(spec)
spec.loader.exec_module(signing)
class MacSigningTests(unittest.TestCase):
def test_password_uses_private_input_and_diagnostics_never_echo_it(self):
synthetic = 'synthetic "quoted" value \\ with spaces'
captured = []
def execute(arguments, **options):
captured.append((arguments, options))
return SimpleNamespace(returncode=1, stdout=synthetic,
stderr="MAC verification failed " + synthetic)
with patch.object(signing.subprocess, "run", execute):
with self.assertRaises(RuntimeError) as caught:
signing.security("import", "candidate.p12", "-P", synthetic)
self.assertNotIn(synthetic, str(caught.exception))
arguments, options = captured[0]
self.assertNotIn(synthetic, " ".join(arguments))
self.assertIn("quoted", options["input"])
self.assertTrue(options["capture_output"])
self.assertNotIn("APPLE_CERTIFICATE_PASSWORD", options["env"])
def test_missing_inputs_refuse_without_native_operations(self):
with patch.dict(signing.os.environ, {}, clear=True), patch.object(signing.subprocess, "run") as execute:
with self.assertRaises(ValueError):
signing.signing_inputs()
execute.assert_not_called()
def test_control_characters_and_unrelated_keychain_operations_refuse(self):
with patch.object(signing.subprocess, "run") as execute:
for value in ("line\nbreak", "nul\x00byte", "carriage\rreturn"):
with self.assertRaises(ValueError):
signing.security("unlock-keychain", "-p", value, "candidate.keychain")
with self.assertRaises(ValueError):
signing.security("dump-keychain", "login.keychain")
execute.assert_not_called()
def test_user_keychain_search_is_restored_after_signing_failure(self):
previous = ['/Users/runner/Library/Keychains/login.keychain-db', '/Library/Keychains/System.keychain']
calls = []
def security(operation, *values):
calls.append((operation, values))
return ' '.join('"' + value + '"' for value in previous)
with patch.object(signing, "security", security):
with self.assertRaisesRegex(RuntimeError, "synthetic signing failure"):
with signing.keychain_search(Path('/private/candidate.keychain-db')):
self.assertEqual(calls[-1][1][-len(previous):], tuple(previous))
self.assertIn('/private/candidate.keychain-db', calls[-1][1])
raise RuntimeError('synthetic signing failure')
self.assertEqual(calls[-1], ('list-keychains', ('-d', 'user', '-s', *previous)))
def test_failed_search_list_write_still_restores_original_list(self):
calls = []
previous = '/Users/runner/Library/Keychains/login.keychain-db'
def security(operation, *values):
calls.append((operation, values))
if '/private/candidate.keychain-db' in values:
raise RuntimeError('synthetic search write failure')
return '"' + previous + '"'
with patch.object(signing, "security", security):
with self.assertRaises(RuntimeError):
with signing.keychain_search(Path('/private/candidate.keychain-db')):
self.fail('failed search-list setup entered signing')
self.assertEqual(calls[-1][1], ('-d', 'user', '-s', previous))
def test_signing_walk_skips_symlink_aliases_and_framework_internals(self):
with tempfile.TemporaryDirectory() as temporary:
app = Path(temporary)
executable = app / 'actual.dylib'
executable.write_bytes(b'mach-o fixture')
(app / 'alias.dylib').symlink_to(executable.name)
framework = app / 'QtCore.framework'
framework.mkdir()
(framework / 'QtCore').write_bytes(b'framework fixture')
with patch.object(signing, 'run', return_value='Mach-O arm64'):
self.assertEqual(list(signing.signable_files(app)), [executable])
def test_native_failure_cause_does_not_echo_untrusted_diagnostics(self):
sentinel = 'must-not-echo-native-input'
result = SimpleNamespace(returncode=1, stdout='', stderr='unable to build chain ' + sentinel)
with patch.object(signing.subprocess, 'run', return_value=result):
with self.assertRaises(RuntimeError) as caught:
signing.run(['/usr/bin/codesign', 'public-component'])
self.assertIn('certificate chain', str(caught.exception))
self.assertNotIn(sentinel, str(caught.exception))
+12
View File
@@ -0,0 +1,12 @@
{
"version": "v0.11.5.0",
"source_commit": "b22382c55fa2a97cd70aca450026b8e2bb8711a6",
"checksum_evidence": "https://github.com/wownero/wownero/actions/runs/37402323531",
"checksum_signature": "The release tag build replaced the archives after SHA256SUMS.txt.asc was signed. These hashes match the successful tag build and current release metadata; that signature does not cover these archives.",
"archives": {
"wownero-x86_64-w64-mingw32-v0.11.5.0.zip": "46dffbd313de1df4667516acf23737f986eb2e131c06a8dde1d11dcff2eb06e2",
"wownero-aarch64-apple-darwin11-v0.11.5.0.tar.bz2": "bad8cc7f1c78d026e63dbe3ffc0f2175d50b986bbde3fc18826a04ff693866d4",
"wownero-x86_64-apple-darwin11-v0.11.5.0.tar.bz2": "ae562c90a10856fcdd183dc9530dff73e46ed6eece9fb8dcfaa77f1e07174787",
"wownero-x86_64-linux-gnu-v0.11.5.0.tar.bz2": "2d0cc62c24255d45c51936616559651378011501c55a8caadb9a125bb875849b"
}
}
+59
View File
@@ -0,0 +1,59 @@
"""Bind candidate archive hashes to the checked source and provider run."""
import argparse
import hashlib
import json
import os
from pathlib import Path
import re
import subprocess
from check_dispatch import check
def file_record(path):
if not path.is_file() or path.is_symlink() or path.stat().st_size == 0:
raise ValueError("Candidate artifact must be a nonempty regular file")
digest = hashlib.sha256()
with path.open("rb") as stream:
while chunk := stream.read(1024 * 1024):
digest.update(chunk)
return {"name": path.name, "size": path.stat().st_size, "sha256": digest.hexdigest()}
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--platform", required=True, choices=("linux", "macos", "windows-build"))
parser.add_argument("--file", type=Path, required=True, action="append")
parser.add_argument("--output", type=Path, required=True)
args = parser.parse_args()
head = subprocess.check_output(["git", "rev-parse", "HEAD"], text=True).strip()
check(os.environ.get("EXPECTED_SHA"), os.environ.get("GITHUB_SHA"), head)
run_id = os.environ.get("GITHUB_RUN_ID", "")
if not re.fullmatch(r"[1-9][0-9]*", run_id):
raise ValueError("Provider run identity is absent")
version = re.findall(r'^\s*VERSION "([0-9]+\.[0-9]+\.[0-9]+)"\s*$', Path("CMakeLists.txt").read_text(), re.M)
if len(version) != 1:
raise ValueError("Committed Wowlet version is ambiguous")
module = subprocess.check_output(["git", "ls-tree", "HEAD", "monero"], text=True).split()
if len(module) != 4 or module[:2] != ["160000", "commit"] or module[3] != "monero":
raise ValueError("Committed wallet-library pin is absent")
files = [file_record(path) for path in args.file]
if len({row["name"] for row in files}) != len(files):
raise ValueError("Candidate artifact filenames repeat")
receipt = {"schema_version": 1, "app": "wowlet", "version": version[0], "platform": args.platform,
"dispatch_commit": head, "wallet_library_commit": module[2],
"environment_run_id": run_id, "environment_run_attempt": os.environ.get("GITHUB_RUN_ATTEMPT"),
"artifacts": files}
if args.platform == "macos":
signing = json.loads(Path("build/dist/macos-signing.json").read_text())
if signing.get("developer_id_verified") is not True or signing.get("notarization_status") != "Accepted" or signing.get("staple_validated") is not True:
raise ValueError("macOS signing evidence is incomplete")
receipt["signing"] = signing
with args.output.open("x") as stream:
json.dump(receipt, stream, indent=2)
stream.write("\n")
print("Candidate source and artifact hashes recorded")
if __name__ == "__main__":
main()
+1 -1
Submodule monero updated: 8b05051b57...324867ad42
+1 -1
View File
@@ -606,7 +606,7 @@ Rectangle {
horizontalAlignment: Text.AlignHCenter
color: "#f2dcb4"
font.pointSize: 9
text: "Mining needs your own node.\nYou are connected to a remote one — " +
text: "Mining needs your own node.\nYou are connected to a remote one. " +
"start the built-in node or point the wallet at a local daemon " +
"under Settings to mine."
}
+1 -1
View File
@@ -137,7 +137,7 @@
<item row="3" column="1">
<widget class="QLabel" name="label_11">
<property name="text">
<string>codeberg.org/wownero/wowlet</string>
<string>github.com/wownero/wowlet</string>
</property>
<property name="textInteractionFlags">
<set>Qt::TextInteractionFlag::LinksAccessibleByMouse|Qt::TextInteractionFlag::TextSelectableByMouse</set>