mirror of
https://github.com/Such-Software/hash-wallet.git
synced 2026-10-17 05:41:29 +02:00
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:
@@ -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"
|
||||
|
||||
@@ -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
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
Executable
+99
@@ -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"
|
||||
Reference in New Issue
Block a user