ci(desktop): build macOS, and publish every desktop artifact to the release page

Three desktop targets, three different states, one destination.

macOS did not build at all. There was no workflow, only Cake's from-source
scripts and an Xcode project. It turns out most of the work was already done:
monero_c ships aarch64-apple-darwin and x86_64-apple-darwin, and
Runner.xcodeproj already references both dylibs with CodeSignOnCopy. So this
fetches the same prebuilt bundle the iOS and Android legs use, lipos the two
slices into universal libraries, and builds. One download serves Apple Silicon
and Intel, because a single-arch build looks correct on the machine that made
it and fails on half of everyone else's.

It signs with a Developer ID Application certificate, which is NOT the Apple
Distribution certificate the TestFlight workflow uses. They are not
interchangeable: an App Store cert on a directly downloaded app is refused by
Gatekeeper. Signing is inside-out by hand rather than --deep, which Apple
deprecated and which skips nested code it does not recognise. Then notarytool
with the App Store Connect key this repository already has, then stapler, so
Gatekeeper clears the app without a network round trip.

Every credential is optional and the preflight says, before spending forty
minutes, exactly which stages the run can reach and what the user will see if
it cannot reach them. Absent secrets downgrade the build loudly; they do not
fail it and they do not pass silently.

Windows and Linux already built. Windows already Authenticode-signs through the
Fleet broker. What both lacked was a way to get the artifact to anybody:
upload-artifact is the only publication and Gitea Actions artifacts here upload
and then list as zero, so the signed Windows zip has never been reachable. Both
now publish to the GitHub release, on the smirk pattern: tag-triggered, the
release must already exist so a half-populated one never goes public, and a
same-named asset from an earlier attempt is replaced rather than duplicated.
Windows does its API work in PowerShell because git-bash there has neither
python nor node guaranteed and ConvertFrom-Json is native.

Tags now build automatically on all three. A tag is the thing a release is cut
from, and the artifact that gets signed should come from it rather than from
whatever was dispatched by hand.

scripts/release/sign-release.sh detach-signs the collected artifacts and signs
SHA256SUMS, which is the signature that matters when a download reaches someone
from a mirror without its .asc. It verifies even immediately after signing: a
signature written but never checked invites trust it has not earned.

Not done here, and both need a human: GH_RELEASE_TOKEN does not exist in this
repository's Gitea secrets, and the release key's user ID still says Smirk.
This commit is contained in:
jwinterm
2026-09-23 08:13:10 -04:00
parent c11c79b0c5
commit c1f618176c
5 changed files with 610 additions and 7 deletions
+78 -2
View File
@@ -5,10 +5,26 @@ name: Hash Bags Linux build
# - on PRs targeting dev/main (gates merges)
# - manual via workflow_dispatch ("Run workflow" button in the UI)
on:
# Manual-only for now — auto-run on every push was too noisy. Trigger
# Manual-only for pushes — auto-run on every push was too noisy. Trigger
# via Actions → "Hash Bags Linux build" → Run workflow when you want a
# build. Add `push:` back here if/when we want pre-merge validation.
# build. Add `push: branches:` back here if/when we want pre-merge validation.
#
# Tags DO build automatically: a tag is a deliberate act and it is the thing
# a release is cut from, so the artifact that gets signed and published should
# come from the tag rather than from whatever was dispatched by hand.
push:
tags:
- 'hash-bags-*'
workflow_dispatch:
inputs:
release_tag:
description: >-
Existing release tag to publish this build to (e.g. hash-bags-1.0.3).
Leave empty to build without publishing. The release must already
exist: this never creates one, because a release created here would
go public half-populated while the other platforms are still building.
required: false
default: ''
concurrency:
group: linux-${{ github.ref }}
@@ -295,3 +311,63 @@ jobs:
name: hash-wallet-linux-appimage-${{ github.sha }}
path: build/linux/x64/release/Hash_Bags-x86_64.AppImage
retention-days: 14
# ---- Publish to the release pages -------------------------------------
# Artifact retrieval through Gitea Actions is unreliable here: uploads
# succeed and then list as zero, so a build nobody can download is a build
# that did not happen. Release assets are the path that actually returns
# bytes, and GitHub is where users are told to look.
#
# Both steps skip quietly without a token, so a fork or a local runner
# does not fail a build over a credential it was never given.
- name: Resolve the release tag
id: reltag
run: |
set -euo pipefail
if [ "${GITHUB_REF:-}" != "${GITHUB_REF#refs/tags/}" ]; then
TAG="${GITHUB_REF#refs/tags/}"
else
TAG="${{ github.event.inputs.release_tag }}"
fi
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
if [ -n "$TAG" ]; then echo "publishing to $TAG"; else echo "no tag; build only"; fi
- name: Publish the AppImage to the GitHub release
if: steps.reltag.outputs.tag != ''
env:
GH_TOKEN: ${{ secrets.GH_RELEASE_TOKEN }}
TAG: ${{ steps.reltag.outputs.tag }}
run: |
set -euo pipefail
if [ -z "${GH_TOKEN:-}" ]; then
echo "GH_RELEASE_TOKEN not set; skipping the GitHub upload"
exit 0
fi
REPO="Such-Software/hash-wallet"
API="https://api.github.com/repos/$REPO"
AUTH="Authorization: Bearer $GH_TOKEN"
FILE="build/linux/x64/release/Hash_Bags-x86_64.AppImage"
[ -f "$FILE" ] || { echo "no AppImage to upload"; exit 1; }
NAME="$(basename "$FILE")"
code=$(curl -sS -o rel-gh.json -w '%{http_code}' -H "$AUTH" "$API/releases/tags/$TAG" || true)
if [ "$code" != "200" ]; then
echo "no GitHub release for $TAG (HTTP $code); skipping"
exit 0
fi
# Fail rather than skip if there is no JSON parser. An upload that
# quietly did not happen leaves a release page that looks finished
# and a user with nothing to download.
PY=""
for c in python3 python; do command -v "$c" >/dev/null && { PY="$c"; break; }; done
[ -n "$PY" ] || { echo "no python available to parse the release JSON"; exit 1; }
ID=$($PY -c 'import json;print(json.load(open("rel-gh.json"))["id"])')
# Replace a same-named asset from an earlier attempt, so a re-run does
# not leave two that differ only by which build produced them.
for oid in $($PY -c 'import json,sys;d=json.load(open("rel-gh.json"));print(" ".join(str(a["id"]) for a in d.get("assets",[]) if a["name"]==sys.argv[1]))' "$NAME"); do
curl -sS -o /dev/null -X DELETE -H "$AUTH" "$API/releases/assets/$oid" || true
done
curl -sS --fail-with-body -o /dev/null -X POST -H "$AUTH" \
-H "Content-Type: application/octet-stream" \
--data-binary "@$FILE" \
"https://uploads.github.com/repos/$REPO/releases/$ID/assets?name=$NAME"
echo "uploaded $NAME to the GitHub release $TAG"
+353
View File
@@ -0,0 +1,353 @@
# Hash Bags macOS build: signed with Developer ID, notarized, stapled.
#
# This is DIRECT distribution, not the Mac App Store, so it needs a
# "Developer ID Application" certificate. That is a different certificate from
# the "Apple Distribution" one build-ios-testflight.yml uses, and the two are
# not interchangeable: a build signed with the App Store cert is rejected by
# Gatekeeper when a user downloads it from a web page. Notarization, on the
# other hand, reuses the App Store Connect API key that repository already has.
#
# Secrets, all optional. Absent ones do not fail the build; they downgrade it,
# loudly, and the preflight below prints exactly which stage you will lose.
# APPLE_CERTIFICATE base64 of the Developer ID Application .p12
# APPLE_CERTIFICATE_PASSWORD password set when the .p12 was exported
# APPLE_SIGNING_IDENTITY "Developer ID Application: Such Software LLC (D8PL9F7X33)"
# APPLE_TEAM_ID D8PL9F7X33
# APPLE_KEYCHAIN_PASSWORD any value; scopes a throwaway keychain
# APPLE_API_KEY_P8_BASE64 base64 of AuthKey_<id>.p8 (notarization)
# APPLE_API_KEY key id (notarization)
# APPLE_API_ISSUER issuer UUID (notarization)
# GH_RELEASE_TOKEN publishes the zip to the GitHub release
#
# The same names smirk-monorepo uses, deliberately: both repositories live under
# the Builds owner, so an organisation-level secret serves both and a reader
# comparing the two workflows is not left wondering whether the difference means
# something.
name: Hash Bags macOS build
on:
push:
tags:
- 'hash-bags-*'
workflow_dispatch:
inputs:
release_tag:
description: >-
Existing release tag to publish to (e.g. hash-bags-1.0.3). Leave empty
to build without publishing. The release must already exist; this
never creates one, because a release created here would go public
half-populated while the other platforms are still building.
required: false
default: ''
concurrency:
group: macos-${{ github.ref }}
cancel-in-progress: true
defaults:
run:
shell: bash
jobs:
build:
name: Build, sign, notarize
runs-on: macos-latest
timeout-minutes: 90
env:
APP_MACOS_TYPE: cakewallet
LANG: en_US.UTF-8
LC_ALL: en_US.UTF-8
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 1
# Cheap guards first: this reports what the run can actually produce
# before spending forty minutes finding out. Lengths only, never values.
- name: Preflight — which signing stages are available
env:
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
APPLE_API_KEY_P8_BASE64: ${{ secrets.APPLE_API_KEY_P8_BASE64 }}
APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }}
APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }}
GH_TOKEN: ${{ secrets.GH_RELEASE_TOKEN }}
run: |
set -euo pipefail
have() { [ -n "${1:-}" ] && echo "present (${#1} chars)" || echo "ABSENT"; }
echo "APPLE_CERTIFICATE : $(have "${APPLE_CERTIFICATE:-}")"
echo "APPLE_CERTIFICATE_PASSWORD: $(have "${APPLE_CERTIFICATE_PASSWORD:-}")"
echo "APPLE_SIGNING_IDENTITY : $(have "${APPLE_SIGNING_IDENTITY:-}")"
echo "APPLE_API_KEY_P8_BASE64 : $(have "${APPLE_API_KEY_P8_BASE64:-}")"
echo "APPLE_API_KEY : $(have "${APPLE_API_KEY:-}")"
echo "APPLE_API_ISSUER : $(have "${APPLE_API_ISSUER:-}")"
echo "GH_RELEASE_TOKEN : $(have "${GH_TOKEN:-}")"
echo
if [ -n "${APPLE_CERTIFICATE:-}" ] && [ -n "${APPLE_SIGNING_IDENTITY:-}" ]; then
echo "VERDICT: will code-sign with Developer ID"
else
echo "VERDICT: NO Developer ID cert — the .app will be ad-hoc signed and"
echo " Gatekeeper will refuse it on any machine but this one."
fi
if [ -n "${APPLE_API_KEY_P8_BASE64:-}" ] && [ -n "${APPLE_API_KEY:-}" ] && [ -n "${APPLE_API_ISSUER:-}" ]; then
echo "VERDICT: will notarize and staple"
else
echo "VERDICT: NO notarization credentials — users get the unidentified"
echo " developer warning even with a valid Developer ID signature."
fi
- name: Check Flutter 3.32.0
id: flutter_check
run: |
if command -v flutter >/dev/null && flutter --version 2>/dev/null | grep -q "3\.32\.0"; then
echo "installed=true" >> "$GITHUB_OUTPUT"
flutter --version
else
echo "installed=false" >> "$GITHUB_OUTPUT"
fi
- name: Install Flutter 3.32.0 (if missing)
if: steps.flutter_check.outputs.installed != 'true'
uses: subosito/flutter-action@v2
with:
flutter-version: '3.32.0'
channel: stable
cache: true
- name: Install CocoaPods (if missing)
run: |
set -euo pipefail
if command -v pod >/dev/null; then pod --version; exit 0; fi
if command -v brew >/dev/null; then
brew install cocoapods
else
export GEM_HOME="$HOME/.gem"
echo "GEM_HOME=$HOME/.gem" >> "$GITHUB_ENV"
echo "$HOME/.gem/bin" >> "$GITHUB_PATH"
gem install --user-install cocoapods
fi
# ---- Native crypto cores ----------------------------------------------
# Same prebuilt bundle the iOS and Android legs use, so all three ship the
# same wallet core rather than three builds of it.
- name: Fetch prebuilt monero_c bundle
run: |
set -euo pipefail
MONERO_C_TAG=$(cd scripts/monero_c && git describe --tags)
echo "monero_c TAG: $MONERO_C_TAG"
mkdir -p "scripts/monero_c/release/$MONERO_C_TAG"
pushd "scripts/monero_c/release/$MONERO_C_TAG"
if [ ! -d aarch64-apple-darwin ]; then
curl -fsSL -O https://github.com/MrCyjaneK/monero_c/releases/download/v0.18.4.6-RC1/release-bundle.zip
unzip -q release-bundle.zip
rm release-bundle.zip
fi
ls -la aarch64-apple-darwin x86_64-apple-darwin
popd
# Runner.xcodeproj already references these two by bare filename with
# CodeSignOnCopy, so they belong beside the project at macos/.
- name: Stage universal dylibs into macos/
run: |
set -euo pipefail
MONERO_C_TAG=$(cd scripts/monero_c && git describe --tags)
BUNDLE="scripts/monero_c/release/$MONERO_C_TAG"
for lib in libmonero_wallet2_api_c libwownero_wallet2_api_c; do
arm="$BUNDLE/aarch64-apple-darwin/$lib.dylib"
x86="$BUNDLE/x86_64-apple-darwin/$lib.dylib"
[ -f "$arm" ] || { echo "missing $arm"; exit 1; }
# Universal, so one download serves Apple Silicon and Intel. A
# single-arch build looks fine on the machine that made it and
# fails on half the users' machines, which is the worst way to
# find out.
if [ -f "$x86" ]; then
lipo -create "$arm" "$x86" -output "macos/$lib.dylib"
else
echo "no x86_64 slice for $lib; shipping arm64 only"
cp "$arm" "macos/$lib.dylib"
fi
lipo -info "macos/$lib.dylib"
done
- name: Configure the app (bundle id, version, icons)
run: |
set -euo pipefail
# Copies pubspec_description.yaml over pubspec.yaml, so the version
# comes from the one file every platform reads.
pushd scripts/macos
bash ./app_config.sh
popd
grep -m1 '^version:' pubspec.yaml
- name: flutter pub get
run: flutter pub get
- name: Build macOS release
run: |
set -euo pipefail
flutter build macos --release
ls -la build/macos/Build/Products/Release/
# ---- Developer ID signing ---------------------------------------------
- name: Import the Developer ID certificate
id: cert
env:
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
APPLE_KEYCHAIN_PASSWORD: ${{ secrets.APPLE_KEYCHAIN_PASSWORD }}
run: |
set -euo pipefail
if [ -z "${APPLE_CERTIFICATE:-}" ]; then
echo "no Developer ID certificate; skipping import"
echo "imported=false" >> "$GITHUB_OUTPUT"
exit 0
fi
KEYCHAIN_PASSWORD="${APPLE_KEYCHAIN_PASSWORD:-hashbags-ci}"
KEYCHAIN_PATH="$RUNNER_TEMP/hashbags-macos.keychain-db"
# A dedicated throwaway keychain, never the login keychain: this runner
# is a shared Mac and a build should not be able to read, or leave
# anything in, the operator's own keys.
security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
security set-keychain-settings -lut 21600 "$KEYCHAIN_PATH"
security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
security list-keychains -d user -s "$KEYCHAIN_PATH" $(security list-keychains -d user | sed 's/"//g')
P12="$RUNNER_TEMP/developer_id.p12"
echo "$APPLE_CERTIFICATE" | openssl base64 -d -A > "$P12"
security import "$P12" -k "$KEYCHAIN_PATH" -P "${APPLE_CERTIFICATE_PASSWORD:-}" \
-T /usr/bin/codesign -T /usr/bin/security
security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
rm -f "$P12"
security find-identity -v -p codesigning "$KEYCHAIN_PATH"
echo "keychain=$KEYCHAIN_PATH" >> "$GITHUB_OUTPUT"
echo "imported=true" >> "$GITHUB_OUTPUT"
- name: Codesign the app
if: steps.cert.outputs.imported == 'true'
env:
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
run: |
set -euo pipefail
APP=$(ls -d build/macos/Build/Products/Release/*.app | head -1)
echo "signing $APP"
# --deep is deliberately NOT used: Apple deprecated it and it skips
# nested code it does not recognise. Sign inside-out by hand so every
# dylib is covered and a failure names the file it could not sign.
find "$APP/Contents" -type f \( -name '*.dylib' -o -name '*.so' \) -print0 |
while IFS= read -r -d '' lib; do
codesign --force --timestamp --options runtime \
--sign "$APPLE_SIGNING_IDENTITY" "$lib"
done
find "$APP/Contents/Frameworks" -maxdepth 1 -name '*.framework' -print0 2>/dev/null |
while IFS= read -r -d '' fw; do
codesign --force --timestamp --options runtime \
--sign "$APPLE_SIGNING_IDENTITY" "$fw"
done
# Hardened runtime is what notarization requires; entitlements come
# from the templated Release.entitlements written by app_config.sh.
codesign --force --timestamp --options runtime \
--entitlements macos/Runner/Release.entitlements \
--sign "$APPLE_SIGNING_IDENTITY" "$APP"
codesign --verify --deep --strict --verbose=2 "$APP"
# ---- Notarization ------------------------------------------------------
- name: Notarize and staple
id: notarize
if: steps.cert.outputs.imported == 'true'
env:
APPLE_API_KEY_P8_BASE64: ${{ secrets.APPLE_API_KEY_P8_BASE64 }}
APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }}
APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }}
run: |
set -euo pipefail
if [ -z "${APPLE_API_KEY_P8_BASE64:-}" ] || [ -z "${APPLE_API_KEY:-}" ] || [ -z "${APPLE_API_ISSUER:-}" ]; then
echo "no notarization credentials; the build stays signed but un-notarized"
echo "notarized=false" >> "$GITHUB_OUTPUT"
exit 0
fi
APP=$(ls -d build/macos/Build/Products/Release/*.app | head -1)
KEY="$RUNNER_TEMP/AuthKey.p8"
echo "$APPLE_API_KEY_P8_BASE64" | openssl base64 -d -A > "$KEY"
ZIP="$RUNNER_TEMP/notarize.zip"
# ditto, not zip: it preserves the bundle's symlinks and extended
# attributes, and a plain zip of a .app arrives at Apple as something
# it will not accept.
ditto -c -k --keepParent "$APP" "$ZIP"
xcrun notarytool submit "$ZIP" \
--key "$KEY" --key-id "$APPLE_API_KEY" --issuer "$APPLE_API_ISSUER" \
--wait --timeout 45m
rm -f "$KEY" "$ZIP"
# Stapling writes the ticket into the bundle so Gatekeeper clears it
# without a network round trip, which is what a user on a bad
# connection or behind a firewall actually needs.
xcrun stapler staple "$APP"
xcrun stapler validate "$APP"
echo "notarized=true" >> "$GITHUB_OUTPUT"
- name: Package the app
id: package
run: |
set -euo pipefail
APP=$(ls -d build/macos/Build/Products/Release/*.app | head -1)
VERSION=$(awk -F': ' '/^version:/ {print $2; exit}' pubspec_description.yaml | cut -d+ -f1)
OUT="HashBags-macos-${VERSION}.zip"
ditto -c -k --keepParent "$APP" "$OUT"
shasum -a 256 "$OUT" | tee "$OUT.sha256"
ls -la "$OUT"
echo "file=$OUT" >> "$GITHUB_OUTPUT"
- name: Upload artifact
uses: actions/upload-artifact@v3
with:
name: hash-wallet-macos-${{ github.sha }}
path: ${{ steps.package.outputs.file }}
retention-days: 14
- name: Resolve the release tag
id: reltag
run: |
set -euo pipefail
if [ "${GITHUB_REF:-}" != "${GITHUB_REF#refs/tags/}" ]; then
TAG="${GITHUB_REF#refs/tags/}"
else
TAG="${{ github.event.inputs.release_tag }}"
fi
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
if [ -n "$TAG" ]; then echo "publishing to $TAG"; else echo "no tag; build only"; fi
- name: Publish to the GitHub release
if: steps.reltag.outputs.tag != ''
env:
GH_TOKEN: ${{ secrets.GH_RELEASE_TOKEN }}
TAG: ${{ steps.reltag.outputs.tag }}
FILE: ${{ steps.package.outputs.file }}
run: |
set -euo pipefail
if [ -z "${GH_TOKEN:-}" ]; then
echo "GH_RELEASE_TOKEN not set; skipping the GitHub upload"
exit 0
fi
REPO="Such-Software/hash-wallet"
API="https://api.github.com/repos/$REPO"
AUTH="Authorization: Bearer $GH_TOKEN"
NAME="$(basename "$FILE")"
code=$(curl -sS -o rel-gh.json -w '%{http_code}' -H "$AUTH" "$API/releases/tags/$TAG" || true)
if [ "$code" != "200" ]; then
echo "no GitHub release for $TAG (HTTP $code); skipping"
exit 0
fi
ID=$(python3 -c 'import json;print(json.load(open("rel-gh.json"))["id"])')
for oid in $(python3 -c 'import json,sys;d=json.load(open("rel-gh.json"));print(" ".join(str(a["id"]) for a in d.get("assets",[]) if a["name"]==sys.argv[1]))' "$NAME"); do
curl -sS -o /dev/null -X DELETE -H "$AUTH" "$API/releases/assets/$oid" || true
done
curl -sS --fail-with-body -o /dev/null -X POST -H "$AUTH" \
-H "Content-Type: application/octet-stream" \
--data-binary "@$FILE" \
"https://uploads.github.com/repos/$REPO/releases/$ID/assets?name=$NAME"
echo "uploaded $NAME to the GitHub release $TAG"
- name: Clean up the keychain
if: always() && steps.cert.outputs.imported == 'true'
run: security delete-keychain "${{ steps.cert.outputs.keychain }}" || true
+69 -4
View File
@@ -9,11 +9,24 @@ name: Hash Bags Windows build
# → Hash Bags.
on:
# Manual-only. Trigger via Actions → "Hash Bags Windows build" → Run workflow.
# workflow_dispatch runs as the triggering user, so secrets are always
# available (PR triggers from forks would strip them — not relevant here
# since the runner is self-hosted and listening on a self-hosted Gitea).
# Manual, or a release tag. workflow_dispatch runs as the triggering user, so
# secrets are always available (PR triggers from forks would strip them — not
# relevant here since the runner is self-hosted and listening on a
# self-hosted Gitea). A tag builds automatically because a tag is the thing a
# release is cut from, and the signed artifact should come from it rather
# than from whatever happened to be dispatched by hand.
push:
tags:
- 'hash-bags-*'
workflow_dispatch:
inputs:
release_tag:
description: >-
Existing release tag to publish this build to (e.g. hash-bags-1.0.3).
Leave empty to build without publishing. The release must already
exist; this never creates one.
required: false
default: ''
# Cancel in-flight runs when a newer commit lands on the same branch.
concurrency:
@@ -584,3 +597,55 @@ jobs:
name: hash-bags-windows-${{ github.sha }}
path: HashBags-windows-${{ github.sha }}.zip
retention-days: 14
# ---- Publish to the GitHub release ------------------------------------
# PowerShell rather than the bash used elsewhere in this file: this step
# parses JSON, and git-bash here has neither python nor node guaranteed,
# while ConvertFrom-Json is native. Skips quietly without a token.
- name: Resolve the release tag
id: reltag
run: |
set -euo pipefail
if [ "${GITHUB_REF:-}" != "${GITHUB_REF#refs/tags/}" ]; then
TAG="${GITHUB_REF#refs/tags/}"
else
TAG="${{ github.event.inputs.release_tag }}"
fi
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
if [ -n "$TAG" ]; then echo "publishing to $TAG"; else echo "no tag; build only"; fi
- name: Publish the signed zip to the GitHub release
if: steps.reltag.outputs.tag != ''
shell: powershell
env:
GH_TOKEN: ${{ secrets.GH_RELEASE_TOKEN }}
TAG: ${{ steps.reltag.outputs.tag }}
run: |
$ErrorActionPreference = 'Stop'
if (-not $env:GH_TOKEN) {
Write-Host 'GH_RELEASE_TOKEN not set; skipping the GitHub upload'
exit 0
}
$repo = 'Such-Software/hash-wallet'
$api = "https://api.github.com/repos/$repo"
$hdr = @{ Authorization = "Bearer $env:GH_TOKEN"; 'User-Agent' = 'hash-bags-ci' }
$src = Get-ChildItem -Path . -Filter 'HashBags-windows-*.zip' | Select-Object -First 1
if (-not $src) { throw 'no Windows zip to upload' }
# Named for the tag, not the commit: a release asset called
# HashBags-windows-<40 hex>.zip tells a downloader nothing.
$name = "HashBags-windows-$env:TAG.zip"
try {
$rel = Invoke-RestMethod -Headers $hdr -Uri "$api/releases/tags/$env:TAG"
} catch {
Write-Host "no GitHub release for $env:TAG; skipping"
exit 0
}
# Replace a same-named asset from an earlier attempt, so a re-run does
# not leave two that differ only by which build produced them.
foreach ($a in @($rel.assets | Where-Object { $_.name -eq $name })) {
Invoke-RestMethod -Headers $hdr -Method Delete -Uri "$api/releases/assets/$($a.id)" | Out-Null
}
$up = "https://uploads.github.com/repos/$repo/releases/$($rel.id)/assets?name=$name"
Invoke-RestMethod -Headers $hdr -Method Post -ContentType 'application/octet-stream' `
-InFile $src.FullName -Uri $up | Out-Null
Write-Host "uploaded $name to the GitHub release $env:TAG"
+11 -1
View File
@@ -1,4 +1,14 @@
lib*_wallet2_api_c.so
# embedded Wownero node daemon (provisioned at build time, like the wallet libs)
# Embedded Wownero node daemon. NOT provisioned by any build step: no script
# here and no CI job stages it, so a CI-built APK does not contain it. It is
# built by hand and kept out of the tree entirely, at
# ~/Build/hash-wallet/embedded-node/<abi>/libwownerod.so.
#
# Binaries do not belong in src even when ignored. Everything else in this
# directory is staging output that build_monero_all.sh or the Android workflow
# regenerates; this one had no generator, which is why it sat here for weeks.
# When the UI toggle and foreground service land, it needs a provisioning step
# beside the monero_c one, and shipping it before then would put 20 MB of
# unreachable executable in front of a store reviewer for no reason.
libwownerod.so
+99
View File
@@ -0,0 +1,99 @@
#!/usr/bin/env bash
# sign-release.sh: detach-sign every shipped desktop artifact, and verify.
#
# Same shape as smirk's, deliberately: one person verifies downloads from both
# products and should not have to learn two procedures.
#
# windows : HashBags-windows-<tag>.zip
# macos : HashBags-macos-<version>.zip
# linux : Hash_Bags-x86_64.AppImage
#
# Each artifact gets a detached ASCII signature beside it (<file>.asc), and
# SHA256SUMS is written and signed too. Signing the sums file is the one that
# matters most: it is what lets someone verify a download that reached them
# from a mirror or a store page, where the .asc never travelled with the file.
#
# Usage:
# scripts/release/sign-release.sh 1.0.3 --dir ~/release-1.0.3
# scripts/release/sign-release.sh 1.0.3 --dir ~/release-1.0.3 --verify
# HASHBAGS_SIGNING_KEY=<keyid!> scripts/release/sign-release.sh 1.0.3 --dir ...
#
# HASHBAGS_SIGNING_KEY should name the RELEASE SIGNING SUBKEY, not the primary,
# and should carry a trailing exclamation mark so gpg uses exactly that subkey
# rather than choosing one itself. The primary never needs to be present on a
# build machine, and is not needed here.
set -euo pipefail
fail() { echo "sign-release: $1" >&2; exit 1; }
VERSION="${1:-}"
if [ -z "$VERSION" ] || [ "$VERSION" = "-h" ] || [ "$VERSION" = "--help" ]; then
sed -n '2,27p' "$0" | sed 's/^# \{0,1\}//'
exit 2
fi
shift
DIR=""
VERIFY_ONLY=0
while [ $# -gt 0 ]; do
case "$1" in
--dir) DIR="${2:-}"; shift 2 ;;
--verify) VERIFY_ONLY=1; shift ;;
*) fail "unknown argument: $1" ;;
esac
done
[ -n "$DIR" ] || fail "no --dir. Where are the downloaded artifacts?"
[ -d "$DIR" ] || fail "no such directory: $DIR"
# The company release key. One key covers Hash Bags and Smirk, which is a
# deliberate choice: the fingerprint a user is asked to trust belongs to Such
# Software rather than to a product, so it does not have to be re-learned per
# app. The cost is that rotating it affects everything, which is why the
# primary stays offline and only this subkey signs.
KEY="${HASHBAGS_SIGNING_KEY:-96F6836D5110C2CB!}"
command -v gpg >/dev/null || fail "gpg is not installed"
cd "$DIR"
shopt -s nullglob
FILES=( HashBags-windows-*.zip HashBags-macos-*.zip Hash_Bags-*.AppImage *.dmg *.deb )
[ ${#FILES[@]} -gt 0 ] || fail "no release artifacts found in $DIR"
echo "artifacts in $DIR:"
printf ' %s\n' "${FILES[@]}"
echo
if [ "$VERIFY_ONLY" -eq 0 ]; then
# SHA256SUMS first, so the signature covers the final set rather than a set
# that grew afterwards.
: > SHA256SUMS
for f in "${FILES[@]}"; do
if command -v sha256sum >/dev/null; then sha256sum "$f" >> SHA256SUMS
else shasum -a 256 "$f" >> SHA256SUMS; fi
done
cat SHA256SUMS
echo
for f in "${FILES[@]}" SHA256SUMS; do
rm -f "$f.asc"
gpg --local-user "$KEY" --armor --detach-sign --output "$f.asc" "$f"
echo "signed $f"
done
echo
fi
# Verify unconditionally, including right after signing. A signature that was
# written but cannot be checked is worth less than none, because it invites
# trust it has not earned.
rc=0
for f in "${FILES[@]}" SHA256SUMS; do
[ -f "$f.asc" ] || { echo "MISSING SIGNATURE: $f.asc"; rc=1; continue; }
if gpg --verify "$f.asc" "$f" 2>/dev/null; then
echo "verified $f"
else
echo "VERIFY FAILED: $f"; rc=1
fi
done
[ "$rc" -eq 0 ] || fail "one or more artifacts did not verify"
echo
echo "all artifacts signed and verified for $VERSION"