Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
164 changes: 156 additions & 8 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,10 @@ jobs:
runs-on: windows-latest
permissions:
contents: write
# As in macos-dmg: a secret cannot be read in an `if:`, an
# environment variable can.
env:
SIGNPATH_API_TOKEN: ${{ secrets.SIGNPATH_API_TOKEN }}
strategy:
fail-fast: false
matrix:
Expand Down Expand Up @@ -211,6 +215,42 @@ jobs:
- name: Build the installer
if: github.ref_type == 'tag' && matrix.configuration == ''
run: make installer

# SignPath signs from an artifact of this run rather than from a
# key on the runner - the certificate lives in their hardware and
# never comes here - so the installer is uploaded first and the
# signed copy comes back to replace it.
- name: Keep the unsigned installer
id: installer
if: github.ref_type == 'tag' && matrix.configuration == ''
uses: actions/upload-artifact@v4
with:
name: avanor-windows-installer-unsigned
# [0-9] so this cannot pick up avanor-d.exe by accident
path: avanor-[0-9]*.exe
if-no-files-found: error

# Skipped when there is no token - a fork, a pull request, or an
# upstream that has not applied to SignPath yet - and the unsigned
# installer goes on the release instead, as it did before. See
# SIGNING.md.
- name: Sign the installer
if: github.ref_type == 'tag' && matrix.configuration == ''
&& env.SIGNPATH_API_TOKEN != ''
uses: signpath/github-action-submit-signing-request@v1
with:
api-token: ${{ secrets.SIGNPATH_API_TOKEN }}
organization-id: ${{ vars.SIGNPATH_ORGANIZATION_ID }}
project-slug: ${{ vars.SIGNPATH_PROJECT_SLUG }}
signing-policy-slug: ${{ vars.SIGNPATH_POLICY_SLUG }}
github-artifact-id: ${{ steps.installer.outputs.artifact-id }}
wait-for-completion: true
output-artifact-directory: signed
- name: Take the signed installer over the unsigned one
if: github.ref_type == 'tag' && matrix.configuration == ''
&& env.SIGNPATH_API_TOKEN != ''
run: mv -f signed/avanor-[0-9]*.exe .

- name: Add it to the release
if: github.ref_type == 'tag' && matrix.configuration == ''
uses: softprops/action-gh-release@v3
Expand All @@ -219,19 +259,20 @@ jobs:
files: avanor-[0-9]*.exe
fail_on_unmatched_files: true

# Compiles and tests every configuration, on whatever macOS is newest,
# so a break there is seen when it lands. The release images are built
# by macos-dmg below, on runners pinned for the reasons given there.
macos:
name: macos ${{ matrix.configuration || 'release' }}
runs-on: macos-latest
permissions:
contents: write
strategy:
fail-fast: false
matrix:
configuration: ["", "debug=1", "notcurses=1", "notcurses=1 debug=1"]
steps:
- uses: actions/checkout@v7
- name: Install dependencies
run: brew install argparse cereal coreutils create-dmg dylibbundler fmt luajit make notcurses pkgconf sol2 wget zstd
run: brew install argparse cereal coreutils fmt luajit make notcurses pkgconf sol2 wget zstd
# gmake, not make: macOS still ships GNU Make 3.81 from 2006, which
# reads a "#" inside $(shell ...) as the start of a comment and cuts
# the line in half. Homebrew's is the same 4.x every other platform
Expand All @@ -246,14 +287,121 @@ jobs:
CONFIGURATION: ${{ matrix.configuration }}
run: ./.github/run-tests.sh

# As on Windows: only on a tag, and only from the release build of
# the default backend, since all four would write the same file.
# The disk images, one per architecture, on a tag only.
#
# A Mach-O built here holds one architecture and the Homebrew libraries
# bundled beside it hold the same one, so an image made on an Apple
# silicon runner does not run on an Intel Mac at all - Rosetta goes from
# x86_64 to arm64, not back. Hence a runner of each kind rather than the
# `macos-latest` the job above uses.
#
# The OS is pinned for the same reason it is named: every Homebrew
# bottle is built for the OS of the machine that installed it, so the
# runner image decides the lowest macOS the release can run on, and
# `macos-latest` quietly moves that floor up with every new release of
# macOS. macos-15-intel is served until August 2027, after which the
# x86_64 half of this matrix goes away with it.
macos-dmg:
name: macos disk image (${{ matrix.arch }})
runs-on: ${{ matrix.runner }}
if: github.ref_type == 'tag'
permissions:
contents: write
strategy:
fail-fast: false
matrix:
include:
- runner: macos-15
arch: arm64
- runner: macos-15-intel
arch: x86_64
# A secret cannot be read in an `if:`, only an environment variable
# can, so the one that decides whether there is anything to sign with
# is lifted up here for the step below to test.
env:
MACOS_CERTIFICATE: ${{ secrets.MACOS_CERTIFICATE }}
steps:
- uses: actions/checkout@v7
- name: Install dependencies
run: brew install argparse cereal create-dmg dylibbundler fmt luajit make pkgconf sol2 wget zstd
- name: Build
run: gmake -j$(sysctl -n hw.ncpu)
- name: Check the binary starts
run: ./avanor --version

# The runner labels decide which architecture each half of the
# matrix builds, and a label that quietly changes what it points at
# would otherwise put the same slice in both images under two
# different names. Say out loud which one was expected.
- name: Check it is the architecture this job is for
run: |
built=$(lipo -archs ./avanor)
if [ "$built" != "${{ matrix.arch }}" ]; then
echo "::error::${{ matrix.runner }} built $built, expected ${{ matrix.arch }}"
exit 1
fi

# Everything the signing needs, put where the Makefile will find it.
# Skipped when the certificate is not there - a fork has no secrets
# - and the build below then produces the unsigned image instead of
# failing. See SIGNING.md for where these five secrets come from.
- name: Set up the signing key
if: env.MACOS_CERTIFICATE != ''
env:
MACOS_CERTIFICATE_PASSWORD: ${{ secrets.MACOS_CERTIFICATE_PASSWORD }}
NOTARY_KEY: ${{ secrets.NOTARY_KEY }}
NOTARY_KEY_ID: ${{ secrets.NOTARY_KEY_ID }}
NOTARY_ISSUER: ${{ secrets.NOTARY_ISSUER }}
run: |
set -eu

keychain="$RUNNER_TEMP/signing.keychain-db"
password=$(uuidgen)

printf '%s' "$MACOS_CERTIFICATE" | base64 --decode > "$RUNNER_TEMP/cert.p12"
printf '%s' "$NOTARY_KEY" | base64 --decode > "$RUNNER_TEMP/notary.p8"

security create-keychain -p "$password" "$keychain"
security set-keychain-settings -lut 21600 "$keychain"
security unlock-keychain -p "$password" "$keychain"
security import "$RUNNER_TEMP/cert.p12" -k "$keychain" \
-P "$MACOS_CERTIFICATE_PASSWORD" -T /usr/bin/codesign

# Without this codesign stops to ask permission to use the key,
# of a runner with nobody at it to answer.
security set-key-partition-list -S apple-tool:,apple:,codesign: \
-s -k "$password" "$keychain" > /dev/null

# The default keychain, not merely a searched one: notarytool
# reads the default when it is given a profile name.
security list-keychains -d user -s "$keychain"
security default-keychain -d user -s "$keychain"

xcrun notarytool store-credentials AVANOR_NOTARY \
--key "$RUNNER_TEMP/notary.p8" \
--key-id "$NOTARY_KEY_ID" \
--issuer "$NOTARY_ISSUER" \
--keychain "$keychain"

rm -f "$RUNNER_TEMP/cert.p12" "$RUNNER_TEMP/notary.p8"

identity=$(security find-identity -v -p codesigning "$keychain" \
| sed -n 's/.*"\(Developer ID Application:.*\)"/\1/p' | head -1)

if [ -z "$identity" ]; then
echo "::error::no Developer ID Application identity in the certificate"
exit 1
fi

echo "CODESIGN_ID=$identity" >> "$GITHUB_ENV"
echo "NOTARY_PROFILE=AVANOR_NOTARY" >> "$GITHUB_ENV"

# CODESIGN_ID and NOTARY_PROFILE reach make through the environment
# when the step above ran, and are simply unset when it did not.
- name: Build the disk image
if: github.ref_type == 'tag' && matrix.configuration == ''
run: gmake dmg
- name: Add it to the release
if: github.ref_type == 'tag' && matrix.configuration == ''
uses: softprops/action-gh-release@v3
with:
files: avanor-[0-9]*.dmg
files: avanor-[0-9]*-${{ matrix.arch }}.dmg
fail_on_unmatched_files: true
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@ datafiles.nsh
avanor-*.exe
avanor-*.dmg

# The zip the application is notarised from. Made and removed inside
# the dmg target, so it is only ever left behind by a failed run.
avanor-*-app.zip

# Generated by dpkg-buildpackage
debian/avanor/
debian/files
Expand Down
65 changes: 65 additions & 0 deletions Main.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,12 @@ Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA.
#include <typeinfo>
#include <unistd.h>

#ifdef __APPLE__
#include <climits>
#include <cstring>
#include <mach-o/dyld.h>
#endif

#include <argparse/argparse.hpp>
#include <cereal/archives/json.hpp>
#include <cereal/types/memory.hpp>
Expand Down Expand Up @@ -587,8 +593,67 @@ void ShowLogo()
XGame Game;


#ifdef __APPLE__
// Avanor draws on a terminal, and an application opened from Finder has
// none. What used to bridge that was a shell script beside the game in
// the bundle, with the script as the bundle's executable; the trouble is
// that this left the game a plain executable standing next to the thing
// macOS had approved rather than being it, and a plain executable can
// carry no notarisation ticket of its own - so it was assessed a second
// time, on its own, and that assessment can only be answered by asking
// Apple over the network. Approving Avanor and then opening it again
// with no network would fail.
//
// So the bundle's executable is the game, and the game is what hands
// itself to Terminal.
//
// LaunchServices sets __CFBundleIdentifier to the bundle it is starting,
// which is how this tells being opened from Finder from being run in a
// shell. Terminal sets the variable too - to its own identifier - so the
// copy it goes on to run does not bounce back here, and neither does
// `./avanor --version` in CI, which has no bundle and no terminal.
static void vRelaunchInTerminal()
{
// Must match CFBundleIdentifier in resources/Info.plist.
static const char* const BundleId = "com.avanor.avanor";

const char* const launched = getenv("__CFBundleIdentifier");

if (launched == nullptr || strcmp(launched, BundleId) != 0) {
return;
}

if (isatty(STDOUT_FILENO)) {
return;
}

char path[PATH_MAX];
uint32_t size = sizeof(path);

if (_NSGetExecutablePath(path, &size) != 0) {
return;
}

execl("/usr/bin/open", "open", "-a", "Terminal", path,
static_cast<char*>(nullptr));

// Only reached if open could not be started at all. Carrying on would
// draw the game where nobody can see it, so say why instead.
std::cerr << "avanor: cannot open Terminal to play in" << std::endl;

exit(1);
}
#endif


int main(int argc, char* argv[])
{
#ifdef __APPLE__
// Before the arguments are read: opened from Finder there are none
// worth reading, and this does not come back.
vRelaunchInTerminal();
#endif

argparse::ArgumentParser program("avanor", GAME_VERSION_FULL);
program.add_description("Avanor, the Land of Mystery - a roguelike.");

Expand Down
Loading