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
42 changes: 41 additions & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,19 @@ jobs:
- name: Build binary
run: bun run build:binary --target=${{ matrix.target }} --outfile=dist/${{ matrix.binary }}

- name: Sign, notarize, and verify macOS binary
if: runner.os == 'macOS'
shell: bash
env:
BINARY_NAME: ${{ matrix.binary }}
MACOS_CERTIFICATE_BASE64: ${{ secrets.MACOS_CERTIFICATE_BASE64 }}
MACOS_CERTIFICATE_PASSWORD: ${{ secrets.MACOS_CERTIFICATE_PASSWORD }}
MACOS_SIGN_IDENTITY: ${{ secrets.MACOS_SIGN_IDENTITY }}
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
run: bash scripts/sign-macos-release.sh "./dist/$BINARY_NAME"

- name: Smoke test binary
run: bun run smoke:binary dist/${{ matrix.binary }}

Expand Down Expand Up @@ -137,8 +150,35 @@ jobs:
path: dist/${{ matrix.artifact }}
if-no-files-found: error

verify-macos:
# Fresh machines: no signing keychain or notarization cache from the build.
needs: [resolve, build]
strategy:
matrix:
include:
- os: macos-15-intel
artifact: linearctl-darwin-x64
- os: macos-latest
artifact: linearctl-darwin-arm64
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
with:
ref: ${{ needs.resolve.outputs.commit }}

- uses: actions/download-artifact@v4
with:
name: ${{ matrix.artifact }}
path: dist

- name: Verify quarantined macOS release on a clean runner
shell: bash
env:
BINARY_NAME: ${{ matrix.artifact }}
run: bash scripts/verify-macos-release.sh "./dist/$BINARY_NAME"

release:
needs: [resolve, validate, build]
needs: [resolve, validate, build, verify-macos]
runs-on: ubuntu-latest
permissions:
contents: write
Expand Down
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

## [0.8.11] - 2026-09-05

### Changed

- Require Developer ID signing and Apple notarization for both macOS release binaries, with fresh native-runner verification before publication. First launch requires online ticket retrieval; standalone binaries cannot carry stapled tickets.
- Share the pinned, verified build pipeline between CI and releases, and gate publication on validation of the exact tagged commit.

### Fixed

- Preserve completed upload/project resources, structured errors, and meaningful exit codes when composite workflows fail; report skipped steps and provide recovery guidance in human and both JSON modes.
- Verify staged Unix installer downloads before atomically replacing an existing installation, and preserve macOS quarantine instead of bypassing Gatekeeper.
- Stream file transfers with cancellation and atomic downloads.
- Preserve partial pagination results and resume context when transport requests fail.
- Honor JSON envelopes in remaining top-level CLI error paths.

## [0.8.10] - 2026-09-02

Expand Down
6 changes: 6 additions & 0 deletions INSTALL.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,12 @@ curl -fsSL https://raw.githubusercontent.com/qwrobins/linearctl/main/install.sh

This detects your OS and architecture and installs to `~/.local/bin/linearctl`. On Debian/Ubuntu it uses a `.deb` package automatically.

macOS releases are Developer ID signed and notarized. First launch needs Internet
access so Gatekeeper can retrieve Apple's ticket; standalone binaries cannot be
stapled for offline first launch. The installer does not remove quarantine. See
[macOS signing](docs/macos-signing.md) for details and the separate repair path
for locally built binaries.

If `~/.local/bin` is not in your PATH, add it:

```bash
Expand Down
10 changes: 9 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ curl -fsSL https://raw.githubusercontent.com/qwrobins/linearctl/main/install.sh
Or install a specific version:

```bash
LINEAR_VERSION=v0.8.10 curl -fsSL https://raw.githubusercontent.com/qwrobins/linearctl/main/install.sh | sh
LINEAR_VERSION=v0.8.11 curl -fsSL https://raw.githubusercontent.com/qwrobins/linearctl/main/install.sh | sh
```

On Debian/Ubuntu, the installer automatically uses the `.deb` package. To skip deb and install the raw binary instead:
Expand All @@ -24,6 +24,11 @@ On Debian/Ubuntu, the installer automatically uses the `.deb` package. To skip d
LINEAR_NO_DEB=1 curl -fsSL https://raw.githubusercontent.com/qwrobins/linearctl/main/install.sh | sh
```

macOS releases use Developer ID signing and notarization. First launch requires
Internet access for Gatekeeper's ticket lookup; the standalone binaries cannot
carry stapled tickets. See [macOS signing](docs/macos-signing.md) for the release
policy and required maintainer secrets.

### Windows

From PowerShell:
Expand All @@ -47,6 +52,9 @@ cp dist/linearctl ~/.local/bin/linearctl
On Windows, `bun run build:binary` produces `dist\linearctl.exe`; copy it to a directory on your user `PATH`.

The compiled binary has no runtime dependencies. Building from source requires the [Bun](https://bun.sh) version pinned in `.bun-version`. See [build and release validation](docs/releasing.md) for toolchain updates, generated-artifact checks, and release gates.
Local macOS builds are not Developer ID signed or notarized. If Bun leaves an
invalid signature (verification fails or execution exits 137), follow the
[local signature verification and repair steps](docs/macos-signing.md#local-builds-and-signature-repair).

## Quick start

Expand Down
122 changes: 122 additions & 0 deletions docs/macos-signing.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
# macOS signing and notarization

## Supported release strategy

Both `linearctl-darwin-x64` and `linearctl-darwin-arm64` are standalone Mach-O
executables signed with a **Developer ID Application** certificate, hardened
runtime, and a secure timestamp. Each signed executable is submitted to Apple's
notary service in a temporary ZIP; only an explicit `Accepted` result permits the
build to proceed. The release publishes the exact signed binary submitted in that
ZIP, retaining the existing asset names and installer format.

**This is an online-notarization strategy, not a stapled distribution.** Neither
raw Mach-O executables nor ZIP archives support stapling a notarization ticket.
Gatekeeper must retrieve the ticket from Apple, so **the first launch requires
Internet access to Apple's services**. Offline first launch is not supported. A
future offline distribution would need a stapled container such as a signed DMG
or installer package; running `stapler` against these raw assets is not valid.
See Apple's [Testing a Notarised Product](https://developer.apple.com/forums/thread/130560).

There is no unsigned or ad-hoc fallback for public macOS releases. Missing secrets,
invalid identities, signing errors, rejected/pending/timed-out notarization,
verification failures, and execution failures all block publication of the entire
release. This policy applies to releases built with this workflow, not older assets.

The installer verifies release checksums and does **not** remove quarantine. Do
not re-sign downloaded releases or clear quarantine to work around a failed
Gatekeeper check: that would bypass the supported trust path. Check network
access, use a current release, and report a persistent failure instead.

## GitHub Actions secrets

Configure these repository (or accessible organization) Actions secrets before
triggering a release:

| Secret | Required value |
| --- | --- |
| `MACOS_CERTIFICATE_BASE64` | Base64-encoded password-protected `.p12` export containing the Developer ID Application certificate **and private key** |
| `MACOS_CERTIFICATE_PASSWORD` | Non-empty password for that `.p12` export |
| `MACOS_SIGN_IDENTITY` | Exact `Developer ID Application: Your Name (TEAMID)` identity name; required, not auto-selected |
| `APPLE_ID` | Apple ID email with access to the developer team |
| `APPLE_APP_SPECIFIC_PASSWORD` | App-specific password for notarization, not the Apple ID login password |
| `APPLE_TEAM_ID` | Ten-character Apple Developer Team ID matching the certificate |

Export the identity from Keychain Access on a trusted Mac, then copy its encoding:

```bash
base64 -i DeveloperIDApplication.p12 | pbcopy
security find-identity -v -p codesigning
```

Keep the `.p12`, passwords, and private key out of the repository and logs. Only
the macOS signing step receives secrets. `scripts/sign-macos-release.sh` imports
the certificate into a temporary password-protected keychain, restricts key access
to codesign, and resolves the exact valid identity to its fingerprint. It does not
change the default keychain or search list. An exit trap deletes the keychain and
temporary files on success, failure, and catchable signals; GitHub-hosted ephemeral
runners also bound credential lifetime if the process is forcibly terminated.

The signing script removes Bun's existing signature before signing. Hardened
runtime entitlements in `scripts/macos-entitlements.plist` allow JIT and unsigned
executable memory for Bun's JavaScriptCore engine. No debugger, DYLD environment,
or library-validation exemptions are enabled. See the
[Bun signing guide](https://bun.com/guides/runtime/codesign-macos-executable) for
background; this CLI does not need its broader native-library permissions.

## Release verification

For **each** architecture, CI:

1. Builds on a native macOS runner (Intel on `macos-15-intel`, arm64 on
`macos-latest`).
2. Repairs/signs, runs `codesign --verify --strict --verbose=4` with an Apple
Developer ID and expected-team requirement, and submits to notarization with
a bounded wait. The JSON submission ID/status is printed for diagnostics;
maintainers can retrieve Apple's failure report with `xcrun notarytool log`
using that submission ID and their notarization credentials.
3. Requires `Accepted`, verifies the online notarization ticket, and runs
`--version` and `--help` before uploading the build artifact.
4. Downloads the artifact on a **fresh native macOS runner**, without signing
credentials or the build machine's ticket cache. Restores executable mode,
sets `com.apple.quarantine`, and requires Gatekeeper to be enabled.
5. Runs strict signature verification and `codesign --check-notarization` with a
`notarized` requirement. This is Apple's prescribed assessment for non-app
code; `spctl --assess --type execute` and `syspolicy_check distribution` target
app bundles, not standalone command-line tools.
6. Runs the quarantined binary's `--version` and `--help` with an empty environment
except a fresh HOME/TMPDIR and system-only PATH, outside the checkout. It does
not remove quarantine, disable Gatekeeper, or re-sign the downloaded artifact.

Only after both clean-runner jobs succeed can the release job calculate checksums
and publish assets. These automated checks exercise online ticket retrieval and
native execution; they are not an offline or interactive Finder-install test.
The shell regression tests mock Apple tools and test failure handling; actual
Apple signing and Gatekeeper integration requires the macOS release jobs.

## Local builds and signature repair

`bun run build:binary` does not import a Developer ID identity or notarize. Bun may
supply an ad-hoc signature, but some Bun/macOS combinations leave it invalid,
causing an immediate kill (often exit 137). After building **your own trusted
source** on macOS, check it before copying it into your PATH:

```bash
bun run build:binary
codesign --verify --strict --verbose=4 dist/linearctl
./dist/linearctl --version
```

If verification fails or the locally compiled binary is killed, repair its
signature on that Mac:

```bash
codesign --remove-signature dist/linearctl
codesign --force --sign - dist/linearctl
codesign --verify --strict --verbose=4 dist/linearctl
./dist/linearctl --version
./dist/linearctl --help
```

Repeat after rebuilding if needed. This ad-hoc repair enables local development;
it does **not** establish a Developer ID or notarization trust chain and is not a
supported fix for quarantined downloads or a distribution signing strategy.
9 changes: 5 additions & 4 deletions docs/releasing.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,8 @@ is safe because release always runs its own blocking validation of the tag's SHA
manual dispatch has no bypass. To retry, dispatch `release.yml` with the existing
version tag; validation runs again before assets can be uploaded or overwritten.

macOS signing/notarization is tracked separately in #177. Signing belongs after
compilation and before final smoke tests/upload; checksums must cover the final
signed assets. Keep the shared build entry point and validation dependencies when
adding that step.
macOS binaries are Developer ID signed and notarized after compilation and before
final smoke tests/upload. Both architectures then pass quarantined execution and
online notarization checks on fresh native runners before publication. Checksums
cover the final signed assets. See [macOS signing](macos-signing.md) for required
secrets, the online-first-launch policy, and local signature repair.
5 changes: 0 additions & 5 deletions install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -80,11 +80,6 @@ main() {
# umask (omitting "who" in symbolic chmod preserves masked permissions).
chmod +rx "$STAGED_BINARY"

# Remove macOS quarantine attribute so Gatekeeper doesn't block unsigned binary
if [ "$os" = "darwin" ] && command -v xattr > /dev/null 2>&1; then
xattr -d com.apple.quarantine "$STAGED_BINARY" 2>/dev/null || true
fi

mv -f "$STAGED_BINARY" "${INSTALL_DIR}/${BINARY_NAME}"

echo "Installed ${BINARY_NAME} to ${INSTALL_DIR}/${BINARY_NAME}"
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "linearctl",
"version": "0.8.10",
"version": "0.8.11",
"private": true,
"type": "module",
"bin": {
Expand Down
10 changes: 10 additions & 0 deletions scripts/macos-entitlements.plist
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.cs.allow-jit</key>
<true/>
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
<true/>
</dict>
</plist>
96 changes: 96 additions & 0 deletions scripts/sign-macos-release.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
#!/bin/bash
# Release-only: no unsigned/ad-hoc fallback. See docs/macos-signing.md.
set -euo pipefail
umask 077

binary="${1:?Usage: bash scripts/sign-macos-release.sh <binary>}"
script_dir="$(cd "$(dirname "$0")" && pwd)"

for name in MACOS_CERTIFICATE_BASE64 MACOS_CERTIFICATE_PASSWORD MACOS_SIGN_IDENTITY APPLE_ID APPLE_APP_SPECIFIC_PASSWORD APPLE_TEAM_ID; do
if [[ -z "${!name:-}" ]]; then
echo "Error: required release secret $name is missing" >&2
exit 1
fi
done
if [[ ! "$APPLE_TEAM_ID" =~ ^[A-Z0-9]{10}$ ]] ||
[[ "$MACOS_SIGN_IDENTITY" != "Developer ID Application: "*" ($APPLE_TEAM_ID)" ]]; then
echo "Error: MACOS_SIGN_IDENTITY must be a Developer ID Application identity for APPLE_TEAM_ID" >&2
exit 1
fi
[[ -f "$binary" ]]

work_dir="$(mktemp -d "${RUNNER_TEMP:-${TMPDIR:-/tmp}}/linearctl-sign.XXXXXX")"
keychain="$work_dir/signing.keychain-db"
cleanup() {
local result=$? cleanup_result=0
if [[ -f "$keychain" ]]; then
security delete-keychain "$keychain" || cleanup_result=$?
fi
rm -rf "$work_dir" || cleanup_result=$?
if [[ "$result" -ne 0 ]]; then
exit "$result"
fi
exit "$cleanup_result"
}
trap cleanup EXIT
trap 'exit 1' HUP INT TERM

keychain_password="$(openssl rand -base64 32)"
if [[ "${GITHUB_ACTIONS:-}" == true ]]; then
echo "::add-mask::$keychain_password"
fi
printf '%s' "$MACOS_CERTIFICATE_BASE64" | base64 --decode > "$work_dir/certificate.p12"
security create-keychain -p "$keychain_password" "$keychain"
security set-keychain-settings -lut 21600 "$keychain"
security unlock-keychain -p "$keychain_password" "$keychain"
security import "$work_dir/certificate.p12" -P "$MACOS_CERTIFICATE_PASSWORD" \
-k "$keychain" -T /usr/bin/codesign
security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$keychain_password" "$keychain"
rm "$work_dir/certificate.p12"

# Match the exact identity, then sign by fingerprint. Do not alter the user's
# default keychain or search list; codesign uses this temporary keychain only.
identities="$(security find-identity -v -p codesigning "$keychain")"
fingerprint="$(printf '%s\n' "$identities" | awk -F '"' -v identity="$MACOS_SIGN_IDENTITY" \
'$2 == identity { split($1, fields, " "); print fields[2] }')"
if [[ ! "$fingerprint" =~ ^[[:xdigit:]]{40}$ ]]; then
echo "Error: expected exactly one valid matching Developer ID Application identity" >&2
exit 1
fi

# Bun can leave a malformed signature; remove it before applying a fresh one.
codesign --remove-signature "$binary"
codesign --force --sign "$fingerprint" --keychain "$keychain" \
--identifier com.github.qwrobins.linearctl --options runtime --timestamp \
--entitlements "$script_dir/macos-entitlements.plist" "$binary"
codesign --verify --strict --verbose=4 \
-R="anchor apple generic and certificate leaf[field.1.2.840.113635.100.6.1.13] exists and certificate leaf[subject.OU] = \"$APPLE_TEAM_ID\"" \
"$binary"

# Apple accepts ZIP submissions, not naked Mach-O files. Notarization records
# the binary's code hash; publish those exact signed bytes, not this ZIP.
ditto -c -k --keepParent "$binary" "$work_dir/notarization.zip"
notary_result=0
xcrun notarytool submit "$work_dir/notarization.zip" \
--apple-id "$APPLE_ID" --password "$APPLE_APP_SPECIFIC_PASSWORD" --team-id "$APPLE_TEAM_ID" \
--wait --timeout 20m --output-format json > "$work_dir/notarization.json" || notary_result=$?
# Print the submission ID/status for diagnostics and require Accepted explicitly:
# notarytool's process exit code alone is not a notarization verdict.
python3 - "$work_dir/notarization.json" <<'PY'
import json
import sys

with open(sys.argv[1]) as result_file:
result = json.load(result_file)
print(json.dumps(result, indent=2))
if result.get("status") != "Accepted":
sys.exit("Error: notarization was not Accepted; inspect the submission with notarytool log")
PY
if [[ "$notary_result" -ne 0 ]]; then
exit "$notary_result"
fi

# Raw executables cannot be stapled. Require an online ticket lookup instead.
codesign --verify --strict --verbose=4 -R=notarized --check-notarization "$binary"
"$binary" --version
"$binary" --help
Loading
Loading