Files
wowlet/.gitea/workflows/build-macos.yml
T
jwinterm a43e9fe122 fix(ci): publish tag builds to the release, where they can be fetched back
Run artifacts upload fine, but this Gitea reports zero artifacts for every run
through its REST API, and the web download route answers "Not found." under
token, bearer and basic auth alike. A signed build was therefore unreachable
from anywhere except the browser session that produced it.

Release assets do work: create, upload, list and download all round-trip with
an ordinary token, so tag builds now attach their output to the tag's release
as well as to the run. Retrieval must use browser_download_url; the asset-id
API path returns the asset's JSON metadata even when asked for octet-stream.

The step is fail-safe on purpose. With no token, no resolvable release, or a
non-201 upload it explains itself and exits 0 rather than turning a green
build red. The macOS upload also gains if-no-files-found, without which an
empty publish passes silently.
2026-09-13 13:18:22 -04:00

294 lines
15 KiB
YAML

name: macos build (gitea self-hosted)
# Runs ONLY on the Gitea mac runner (GitHub ignores .gitea/workflows).
# Dispatch-only: the mac runner has capacity 1 and also serves the Hash Bags
# iOS lane, so macOS wowlet builds are an explicit button, not a push hook.
# All build state lands under the runner's work dir, which lives on
# /Volumes/DataSSD (never backed up: the fleet backup pulls an allowlist of
# gitea data only).
#
# Signing/notarization uses the SAME seven secrets as the smirk-monorepo
# desktop lane, set by ~/keys/suchsoftware/set-gitea-desktop-secrets.sh:
# 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.
on: [workflow_dispatch]
jobs:
build-macos:
name: "macOS (brew, native)"
runs-on: macos-latest
env:
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }}
APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }}
APPLE_API_KEY_P8_BASE64: ${{ secrets.APPLE_API_KEY_P8_BASE64 }}
steps:
- uses: actions/checkout@v4
with:
submodules: recursive
- 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: fetch wownerod to embed
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 ;;
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
- name: fetch tor to embed
run: |
set -e
ARCH=$(uname -m)
case "$ARCH" in
arm64) TORTAR=tor-expert-bundle-macos-aarch64-15.0.tar.gz ;;
*) TORTAR=tor-expert-bundle-macos-x86_64-15.0.tar.gz ;;
esac
mkdir -p /tmp/teb /tmp/tor-embed
curl -L -o /tmp/teb.tgz "https://archive.torproject.org/tor-package-archive/torbrowser/15.0/$TORTAR"
tar xzf /tmp/teb.tgz -C /tmp/teb
cp /tmp/teb/tor/tor /tmp/teb/tor/libevent-*.dylib /tmp/tor-embed/
# tor ships UNSIGNED; arm64 macOS SIGKILLs unsigned binaries. Ad-hoc sign
# BEFORE embedding — the signature travels inside the file, so the copy
# the app unpacks at runtime on user machines is runnable too.
codesign -s - -f /tmp/tor-embed/tor /tmp/tor-embed/libevent-*.dylib
chmod +x /tmp/tor-embed/tor
# expert-bundle RELEASE number (15.0) is NOT the tor version — derive it
TORVER=$(/tmp/tor-embed/tor --version | grep -oE '[0-9]+(\.[0-9]+){3}' | head -1)
echo "TOR_VERSION=$TORVER" >> "$GITHUB_ENV"
echo "embedded tor version: $TORVER"
- name: build
run: |
export PATH="/opt/homebrew/bin:$PATH"
# clang does not search /opt/homebrew/include by default; the feather
# target pulls monero headers that include boost, and modern Boost's
# CMake config leaves Boost_INCLUDE_DIRS empty. CPATH/LIBRARY_PATH make
# every linked brew header/lib findable without per-dep flag surgery.
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 --build . -j "$(sysctl -n hw.ncpu)"
- name: package app bundle
run: |
set -e
cd build
APP=$(find . -maxdepth 3 -name "*.app" -type d | head -1)
[ -n "$APP" ] || { echo "no .app bundle produced"; ls -la bin/; exit 1; }
mkdir -p dist
cp -R "$APP" dist/Wowlet.app
# cmake never places the icon in the bundle (upstream only copies it
# for the dormant `installed/` layout)
mkdir -p dist/Wowlet.app/Contents/Resources
cp ../src/assets/images/appicons/appicon.icns dist/Wowlet.app/Contents/Resources/
# bundle Qt frameworks/plugins so the app runs off-machine.
# -qmldir is not optional: our QML lives in .qrc resources, which
# macdeployqt cannot scan, so without it the bundle ships no QML
# modules at all and every page importing anything beyond QtQuick
# fails to load. That is how 5.1.2 shipped a grey mining tab on mac
# while Linux was fine: Contents/Resources held no qml directory, and
# the app logged 'module "QtQuick.Layouts" is not installed'.
/opt/homebrew/opt/qt/bin/macdeployqt dist/Wowlet.app -qmldir=../src/assets/qml
# qmlimportscanner reads only the `import` lines in our QML and does
# not follow a module's own qmldir depends chain, so transitive
# modules (QtQuick -> QtQml.WorkerScript/Models) are silently skipped.
# The AppImage build hit this twice and blanket-copies the trees; do
# the same here, before the dylib passes below so their deps resolve.
# Ask Qt where its QML modules live rather than assuming a brew
# layout; the AppImage build asks qmake the same question.
QTPREFIX="$(brew --prefix qt)"
QMLSRC="$("$QTPREFIX/bin/qmake6" -query QT_INSTALL_QML 2>/dev/null || echo "$QTPREFIX/share/qt/qml")"
[ -d "$QMLSRC" ] || { echo "ERROR: Qt QML modules not found at $QMLSRC" >&2; exit 1; }
mkdir -p dist/Wowlet.app/Contents/Resources/qml
for m in QtQuick QtQml; do
cp -Rn "$QMLSRC/$m" dist/Wowlet.app/Contents/Resources/qml/ 2>/dev/null || true
done
# Guard: the module the mining tab needs has to be in the bundle. A
# missing QML module is invisible until someone opens the tab, so it
# fails the build instead of shipping a page that paints grey.
for m in QtQuick QtQuick/Layouts; do
[ -d "dist/Wowlet.app/Contents/Resources/qml/$m" ] || {
echo "ERROR: $m missing from the bundle; QML pages will not load" >&2
exit 1
}
done
# macdeployqt bundles Qt frameworks but not the plain-dylib brew deps
# of Qt's image plugins (libwebp/libsharpyuv/libbrotli...), which are
# referenced as @rpath/lib*.dylib. Without them a clean (no-Homebrew)
# Mac can't load those image formats. Qt frameworks use a different
# ref form (@rpath/Qt*.framework/...), so keying strictly on
# `@rpath/*.dylib` bundles the stragglers WITHOUT touching Qt.
# Several passes converge (a bundled lib pulls in further deps).
APP=dist/Wowlet.app
FW="$APP/Contents/Frameworks"
mkdir -p "$FW"
for pass in 1 2 3 4; do
while IFS= read -r macho; do
file "$macho" | grep -q Mach-O || continue
chmod u+w "$macho"
otool -L "$macho" | awk 'NR>1{print $1}' | while IFS= read -r dep; do
case "$dep" in
@rpath/*.dylib)
name=$(basename "$dep")
if [ -f "$FW/$name" ]; then
install_name_tool -change "$dep" "@executable_path/../Frameworks/$name" "$macho" 2>/dev/null || true
continue
fi
real=$(find /opt/homebrew/lib /opt/homebrew/opt/*/lib -maxdepth 1 -name "$name" 2>/dev/null | head -1)
if [ -n "$real" ]; then
cp -n "$real" "$FW/$name"
chmod u+w "$FW/$name"
install_name_tool -id "@executable_path/../Frameworks/$name" "$FW/$name" 2>/dev/null || true
install_name_tool -change "$dep" "@executable_path/../Frameworks/$name" "$macho" 2>/dev/null || true
fi
;;
esac
done
done < <(find "$APP" -type f)
done
# Fail if any @rpath/*.dylib (non-Qt) remains unresolved in the bundle
leftover=$(find "$APP" -type f | while IFS= read -r m; do
file "$m" | grep -q Mach-O || continue
otool -L "$m" | awk 'NR>1{print $1}' | grep -E '^@rpath/[^/]+\.dylib$' || true
done | sort -u)
if [ -n "$leftover" ]; then
echo "UNBUNDLED @rpath dylibs remain:"; echo "$leftover"; exit 1
fi
echo "all plain-dylib deps bundled"
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: zip artifact
run: |
set -e
cd build/dist
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: upload artifact
uses: actions/upload-artifact@v3
with:
name: wowlet-macos
path: build/wowlet-macos-*.zip
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