From 10154bb78c4041d69c08d21833881dd00201b328 Mon Sep 17 00:00:00 2001 From: criseda Date: Thu, 15 Oct 2026 13:50:15 +0100 Subject: [PATCH] Publish the app as a release zip for a Homebrew cask Without a paid developer account the app cannot be notarized, so the official homebrew/cask tap is out; Criseda/homebrew-tap serves it instead. A v tag now builds the ad-hoc signed Release app, zips it with ditto and publishes it with that version's CHANGELOG section and the zip's SHA-256 for the cask. --- .github/workflows/release.yml | 70 +++++++++++++++++++++++++++++++++++ AGENTS.md | 19 ++++++++-- CHANGELOG.md | 3 ++ README.md | 17 +++++++++ Scripts/package-release.sh | 47 +++++++++++++++++++++++ 5 files changed, 153 insertions(+), 3 deletions(-) create mode 100644 .github/workflows/release.yml create mode 100755 Scripts/package-release.sh diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..33a2361 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,70 @@ +# Publishes a GitHub release when a version tag (v0.1.0, ...) is pushed: +# the ad-hoc signed app as NetScanner-.zip, with that version's +# CHANGELOG section as the notes. The Homebrew cask in +# Criseda/homebrew-tap points at this zip; bump its version and sha256 +# (printed in the job log and the release notes) after each release. +name: Release + +on: + push: + tags: ["v*"] + +permissions: + contents: write + +jobs: + release: + name: release + runs-on: macos-26 + steps: + - name: Check out the repo + uses: actions/checkout@v7 + + # The tag must name the version the app reports, and the CHANGELOG + # must have a section for it, or the release would mislead. + - name: Check the tag against the version and CHANGELOG + id: version + run: | + version="$(sed -n 's/^[[:space:]]*MARKETING_VERSION[[:space:]]*=[[:space:]]*\([^[:space:]]*\).*/\1/p' Config/Version.xcconfig)" + if [[ "$GITHUB_REF_NAME" != "v$version" ]]; then + echo "::error::Tag $GITHUB_REF_NAME does not match MARKETING_VERSION $version" + exit 1 + fi + awk -v heading="## $version" ' + $0 == heading { found = 1; next } + found && /^## / { exit } + found { print } + ' CHANGELOG.md > release-notes.md + if [[ ! -s release-notes.md ]]; then + echo "::error::CHANGELOG.md has no \"## $version\" section" + exit 1 + fi + echo "version=$version" >> "$GITHUB_OUTPUT" + + - name: Run NetScannerKit tests + run: swift test --package-path Packages/NetScannerKit + + - name: Fetch the scan engine + run: Scripts/fetch-engine.sh + + - name: Build and package the app + id: package + run: | + output="$(Scripts/package-release.sh)" + echo "$output" + echo "zip=$(echo "$output" | tail -n 2 | head -n 1)" >> "$GITHUB_OUTPUT" + echo "sha256=$(echo "$output" | tail -n 1)" >> "$GITHUB_OUTPUT" + + - name: Publish the release + env: + GH_TOKEN: ${{ github.token }} + ZIP: ${{ steps.package.outputs.zip }} + SHA256: ${{ steps.package.outputs.sha256 }} + run: | + { + echo + echo "SHA-256 of \`$(basename "$ZIP")\`: \`$SHA256\`" + } >> release-notes.md + gh release create "$GITHUB_REF_NAME" "$ZIP" \ + --title "NetScanner for macOS ${{ steps.version.outputs.version }}" \ + --notes-file release-notes.md diff --git a/AGENTS.md b/AGENTS.md index 6a88548..067283f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -142,6 +142,7 @@ and the app target stays thin views plus `@Observable` models. Scripts/fetch-engine.sh # download the pinned ns (universal), checksum-verified swift test --package-path Packages/NetScannerKit xcodebuild -project NetScanner.xcodeproj -scheme NetScanner -configuration Debug build +Scripts/package-release.sh # Release build zipped for GitHub Releases; prints its SHA-256 ``` `fetch-engine.sh --local ../NetScanner/zig-out/bin/ns` installs a locally @@ -226,9 +227,21 @@ Like the engine repo, every user-facing change gets an entry under ## Distribution -- **Homebrew cask** (first target): Developer ID signed, hardened - runtime, notarized and stapled `.dmg` or `.zip`. Homebrew requires - signed and notarized apps for new casks. +- **Homebrew** (now): the `netscanner-app` cask in + [Criseda/homebrew-tap](https://github.com/Criseda/homebrew-tap), next + to the `netscanner` formula for the `ns` CLI. Pushing a `v` + tag runs `.github/workflows/release.yml`, which checks the tag + against `MARKETING_VERSION` and a `## ` CHANGELOG section, + builds with `Scripts/package-release.sh` and publishes + `NetScanner-.zip`; then bump the cask's `version` and + `sha256`. The app is ad-hoc signed, not notarized (no paid developer + account yet), so the official `homebrew/cask` tap will not take it + and users allow the first launch in Privacy & Security. +- **Developer ID** (later, needs a paid developer account): sign, + notarize and staple in the release workflow, which removes the + Gatekeeper prompt and keeps the Local Network grant across updates. + The official `homebrew/cask` tap also needs notability (90 forks, 90 + watchers or 225 stars for a self-submission). - **Mac App Store** (later, needs a paid developer account): requires App Sandbox. Open questions to verify before committing to it: whether a sandboxed child can read the ARP table via `sysctl`, open diff --git a/CHANGELOG.md b/CHANGELOG.md index f94542b..1141d0e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,3 +22,6 @@ First version of the native macOS app: engine but not from the app, so the app reads it and hands it to the engine (`--neighbor-table=parent`). - Bundles NetScanner v1.7.0 as a universal binary. +- Released as `NetScanner-.zip` on GitHub and as the + `netscanner-app` cask in `criseda/tap` for Homebrew. Not notarized + yet, so macOS asks to allow the first launch. diff --git a/README.md b/README.md index 1471d2a..5ec9c00 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,23 @@ shows its results in a SwiftUI interface built for macOS 26 and later [NetScannerDesktop](https://github.com/Criseda/NetScannerDesktop) for Windows. +## Install + +With [Homebrew](https://brew.sh): + +```sh +brew install --cask criseda/tap/netscanner-app +``` + +Or download `NetScanner-.zip` from +[Releases](https://github.com/Criseda/NetScanner-MacOS/releases). The +app is not notarized by Apple yet, so macOS blocks the first launch: +open it once, then choose **Open Anyway** in System Settings ▸ Privacy +& Security. + +The `ns` command-line scanner is in the same tap: +`brew install criseda/tap/netscanner`. + ## Features - **Discovery**: TCP, mDNS, SSDP and ARP discovery of a subnet (or ICMP diff --git a/Scripts/package-release.sh b/Scripts/package-release.sh new file mode 100755 index 0000000..33ad862 --- /dev/null +++ b/Scripts/package-release.sh @@ -0,0 +1,47 @@ +#!/bin/bash +# Builds the Release app and packs it into the zip that GitHub Releases +# and the Homebrew cask (Criseda/homebrew-tap) serve. +# +# Usage: +# Scripts/package-release.sh # writes build/release/NetScanner-.zip +# +# Run Scripts/fetch-engine.sh first. The app is ad-hoc signed ("Sign to +# Run Locally"), not Developer ID signed or notarized, so Gatekeeper +# asks the user to allow it on first launch. The cask's caveats say how. +# +# Prints the zip's path and SHA-256 last, for the cask. + +set -euo pipefail + +repo_root="$(cd "$(dirname "$0")/.." && pwd)" +config="$repo_root/Config/Version.xcconfig" +build_dir="$repo_root/build/release" + +version="$(sed -n 's/^[[:space:]]*MARKETING_VERSION[[:space:]]*=[[:space:]]*\([^[:space:]]*\).*/\1/p' "$config")" +[[ -n "$version" ]] || { echo "error: MARKETING_VERSION is not set in $config" >&2; exit 1; } + +[[ -x "$repo_root/Engine/ns" ]] || { echo "error: Engine/ns is missing; run Scripts/fetch-engine.sh" >&2; exit 1; } + +rm -rf "$build_dir" +xcodebuild build \ + -project "$repo_root/NetScanner.xcodeproj" \ + -scheme NetScanner \ + -configuration Release \ + -derivedDataPath "$build_dir/DerivedData" \ + -quiet + +app="$build_dir/DerivedData/Build/Products/Release/NetScanner.app" +[[ -d "$app" ]] || { echo "error: the build produced no $app" >&2; exit 1; } + +# Both the app and the embedded engine must carry a valid signature, or +# Apple silicon refuses to run them and macOS hides the ARP table. +codesign --verify --deep --strict "$app" +codesign --verify --strict "$app/Contents/Helpers/ns" + +# ditto keeps the bundle's symlinks, extended attributes and signature +# intact, which zip(1) does not. +zip="$build_dir/NetScanner-$version.zip" +ditto -c -k --sequesterRsrc --keepParent "$app" "$zip" + +echo "$zip" +shasum -a 256 "$zip" | cut -d' ' -f1