mirror of
https://github.com/wownero/wowlet.git
synced 2026-10-29 04:08:43 +01:00
Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3c5aaed087 | ||
|
|
020b177bd1 | ||
|
|
fb0c34cd70 | ||
|
|
e0670bede5 | ||
|
|
ea04f585aa | ||
|
|
25a341fd10 | ||
|
|
ec8bdf4dfe | ||
|
|
34bdecf5ac | ||
|
|
d9cd8e328e | ||
|
|
998684c1a7 | ||
|
|
bf455e06f1 | ||
|
|
4e3f74a38a |
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -22,7 +22,7 @@ jobs:
|
||||
qt6-wayland-dev qt6-declarative-dev libzip-dev libsodium-dev libgcrypt20-dev libx11-xcb-dev
|
||||
qml6-module-qtquick qml6-module-qtquick-layouts qml6-module-qtquick-window qml6-module-qtqml-workerscript qml6-module-qtqml-models
|
||||
protobuf-compiler libprotobuf-dev libhidapi-dev libzxing-dev libusb-dev
|
||||
libusb-1.0-0-dev patchelf
|
||||
libusb-1.0-0-dev libcom-err2 patchelf
|
||||
- name: configure git
|
||||
run: git config --global --add safe.directory '*'
|
||||
- uses: actions/checkout@v4
|
||||
@@ -77,10 +77,20 @@ jobs:
|
||||
export EXTRA_QT_PLUGINS="svg;multimedia;tls"
|
||||
export QML_SOURCES_PATHS="$PWD/src/assets/qml"
|
||||
export PATH="$PWD:$PATH"
|
||||
# linuxdeploy's AppImage excludelist assumes libcom_err.so.2 is present on the host.
|
||||
# Minimal Debian and openSUSE systems may omit it, but it is a transitive runtime dependency.
|
||||
LIBCOM_ERR="$(ldconfig -p | awk '$1 == "libcom_err.so.2" { print $NF; exit }')"
|
||||
if [[ -z "$LIBCOM_ERR" || ! -f "$LIBCOM_ERR" ]]; then
|
||||
echo "ERROR: libcom_err.so.2 is unavailable in the build image; cannot make a portable AppImage." >&2
|
||||
exit 1
|
||||
fi
|
||||
# Populate AppDir (libs + Qt plugins + QML modules) — no --output yet, so we can patch QML first.
|
||||
./linuxdeploy --appdir AppDir --plugin qt \
|
||||
--library "$LIBCOM_ERR" \
|
||||
-d AppDir/usr/share/applications/wowlet.desktop \
|
||||
-i AppDir/usr/share/icons/hicolor/256x256/apps/wowlet.png
|
||||
# Guard this packaging regression: the AppImage must carry the excluded system library.
|
||||
bash contrib/AppImage/check-libcom-err.sh AppDir
|
||||
# ---------------------------------------------------------------------------------------
|
||||
# Make the QML bundle COMPLETE and deterministic (no whack-a-mole).
|
||||
#
|
||||
|
||||
+1
-1
@@ -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
@@ -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
|
||||
)
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
Executable
+9
@@ -0,0 +1,9 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
appdir="${1:?usage: check-libcom-err.sh APPDIR}"
|
||||
library="$(find "$appdir/usr/lib" -name libcom_err.so.2 -print -quit 2>/dev/null || true)"
|
||||
if [[ -z "$library" || ! -e "$library" ]]; then
|
||||
echo "ERROR: AppDir is missing the bundled libcom_err.so.2 runtime library." >&2
|
||||
exit 1
|
||||
fi
|
||||
@@ -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")
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -0,0 +1,40 @@
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[3]
|
||||
CHECKER = ROOT / "contrib/AppImage/check-libcom-err.sh"
|
||||
|
||||
|
||||
class AppImageDependencyBundleTest(unittest.TestCase):
|
||||
def test_accepts_appdir_with_libcom_err(self):
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
appdir = Path(temporary) / "AppDir"
|
||||
library = appdir / "usr/lib/libcom_err.so.2"
|
||||
library.parent.mkdir(parents=True)
|
||||
library.write_bytes(b"fixture")
|
||||
|
||||
result = subprocess.run(["bash", str(CHECKER), str(appdir)], check=False)
|
||||
|
||||
self.assertEqual(result.returncode, 0)
|
||||
|
||||
def test_refuses_appdir_without_libcom_err(self):
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
appdir = Path(temporary) / "AppDir"
|
||||
(appdir / "usr/lib").mkdir(parents=True)
|
||||
|
||||
result = subprocess.run(
|
||||
["bash", str(CHECKER), str(appdir)],
|
||||
check=False,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertTrue(result.stderr.strip())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -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())
|
||||
@@ -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)
|
||||
@@ -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))
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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."
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user