#!/bin/sh
# SPDX-License-Identifier: MIT OR Apache-2.0
#
# mkit installer — downloads the signed release archive matching the
# host OS + architecture from a public GitHub Release, verifies its
# cosign signature, verifies its SHA256, and installs the `mkit` binary
# atomically into $MKIT_INSTALL_DIR (default: ~/.local/bin).
#
# This script targets Darwin and Linux (including WSL, which runs the real
# Linux binary). Windows is not a supported target (MKIT-6; see
# docs/INVARIANTS.md) — Windows users should run mkit under WSL.
#
# Usage:
# curl mkit.sh | sh
# curl -sSfL https://mkit.sh/install.sh | sh
# curl -sSfL https://mkit.sh/install.sh | sh -s -- --version v0.5.0
# # The raw GitHub URL serves the same script as a fallback:
# # curl -sSfL https://raw.githubusercontent.com/officialunofficial/mkit/main/install.sh | sh
#
# Trust model:
# * cosign keyless verification is REQUIRED by default. The script
# fetches the per-archive `.cosign.bundle`, validates that it was
# signed by this repo's release.yml workflow at a strict-semver tag,
# and only then installs. The published .sha256 file is fetched
# from the same origin as the archive and proves NOTHING about
# authenticity on its own — it's a defense-in-depth integrity check.
# * If `cosign` is not in PATH on a release-track install (i.e. you
# have not passed --insecure-skip-cosign), the script exits non-zero
# with installation instructions. Use --insecure-skip-cosign to
# proceed without signature verification at your own risk.
#
# Version pinning:
# * MKIT_VERSION (or --version) pins the install to a specific tag.
# RECOMMENDED for production use — it's the only way to get a
# repeatable install.
# * Without a version pin the script resolves the GitHub "latest"
# release. The resolved tag is recorded at
# ~/.local/state/mkit/installed-tag; subsequent unpinned installs
# compare against this file and warn loudly on a silent downgrade.
#
# Environment overrides:
# MKIT_VERSION explicit tag (e.g. v0.5.0). Default: 'latest'.
# MKIT_INSTALL_DIR install prefix. Default: ~/.local/bin.
# MKIT_STATE_DIR state dir for installed-tag bookkeeping.
# Default: ~/.local/state/mkit.
# MKIT_SKIP_COSIGN set to '1' to skip cosign verification
# (equivalent to --insecure-skip-cosign).
#
# Flags:
# --version <tag> pin to a specific release tag.
# --prefix <dir> install prefix.
# --insecure-skip-cosign skip cosign signature verification (DANGEROUS).
# --cosign deprecated; cosign is on by default. No-op.
# --help, -h show this help text.
#
# For private-repo installs, use `gh release download` instead — it
# handles auth natively:
# gh release download v0.5.0 --repo officialunofficial/mkit \
# --pattern 'mkit-[0-9]*-<target>.tar.gz' --dir .
# (`mkit-[0-9]*`, not `mkit-*`, which would also match unrelated assets.)
#
# POSIX sh — runs under dash/ash/bash/zsh without bashisms.
set -eu
owner="officialunofficial"
repo="mkit"
api="https://api.github.com/repos/${owner}/${repo}"
dl="https://github.com/${owner}/${repo}/releases/download"
log() { printf '==> %s\n' "$*"; }
warn() { printf 'warning: %s\n' "$*" >&2; }
die() { printf 'error: %s\n' "$*" >&2; exit 1; }
# Print the embedded usage block. Reading from $0 breaks under
# `curl … | sh` because $0 there is `sh`. Instead we embed the help
# text here.
print_help() {
cat <<'EOF'
mkit installer
Usage:
curl mkit.sh | sh
curl -sSfL https://mkit.sh/install.sh | sh
sh install.sh [--version <tag>] [--prefix <dir>] [--insecure-skip-cosign]
Flags:
--version <tag> Pin to a specific release tag (e.g. v0.5.0).
RECOMMENDED for production / reproducible installs.
--prefix <dir> Install prefix. Default: ~/.local/bin.
--insecure-skip-cosign Skip cosign signature verification. DANGEROUS — use
only when cosign is unavailable and you have
verified the binary by other means.
--cosign Deprecated alias. cosign verification is now ON by
default; this flag is a no-op kept for back-compat.
--help, -h Show this help.
Environment:
MKIT_VERSION Same as --version.
MKIT_INSTALL_DIR Same as --prefix.
MKIT_STATE_DIR State dir for downgrade bookkeeping.
Default: ~/.local/state/mkit.
MKIT_SKIP_COSIGN=1 Same as --insecure-skip-cosign.
Trust model:
cosign keyless signature verification is REQUIRED by default. Install cosign
from https://docs.sigstore.dev/cosign/installation/ before running, or pass
--insecure-skip-cosign at your own risk.
EOF
}
version="${MKIT_VERSION:-}"
install_dir="${MKIT_INSTALL_DIR:-$HOME/.local/bin}"
state_dir="${MKIT_STATE_DIR:-$HOME/.local/state/mkit}"
skip_cosign="${MKIT_SKIP_COSIGN:-}"
while [ $# -gt 0 ]; do
case "$1" in
--version) [ $# -ge 2 ] || die "--version requires a tag (e.g. --version v0.5.0)"; version="$2"; shift 2 ;;
--prefix) [ $# -ge 2 ] || die "--prefix requires a directory (e.g. --prefix /usr/local/bin)"; install_dir="$2"; shift 2 ;;
--insecure-skip-cosign) skip_cosign="1"; shift ;;
--cosign) shift ;; # deprecated; cosign is default-on
--help|-h) print_help; exit 0 ;;
*) die "unknown argument: $1 (try --help)" ;;
esac
done
fetch() {
url="$1"; out="$2"
if command -v curl >/dev/null 2>&1; then
curl -sSfL "$url" -o "$out"
elif command -v wget >/dev/null 2>&1; then
wget -qO "$out" "$url"
else
die "need curl or wget to fetch $url"
fi
}
fetch_stdout() {
url="$1"
if command -v curl >/dev/null 2>&1; then
curl -sSfL "$url"
elif command -v wget >/dev/null 2>&1; then
wget -qO- "$url"
else
die "need curl or wget"
fi
}
# ---- host detection ----
uname_os="$(uname -s 2>/dev/null || echo unknown)"
uname_arch="$(uname -m 2>/dev/null || echo unknown)"
case "$uname_os" in
Darwin) os_part="apple-darwin" ;;
Linux) os_part="unknown-linux-gnu" ;; # Also matches WSL, which runs the real Linux binary.
MINGW*|MSYS*|CYGWIN*)
die "Windows is not supported ($uname_os) — use WSL, which runs the Linux binary"
;;
*) die "unsupported OS: $uname_os (only Darwin and Linux have prebuilt binaries; Windows is not supported — use WSL)" ;;
esac
case "$uname_arch" in
x86_64|amd64) arch_part="x86_64" ;;
arm64|aarch64) arch_part="aarch64" ;;
*) die "unsupported arch: $uname_arch" ;;
esac
target="${arch_part}-${os_part}"
# ---- version resolution ----
resolved_from_latest=0
if [ -z "$version" ]; then
log "resolving latest release tag (no --version pinned)"
warn "for reproducible installs, pin a tag with --version vX.Y.Z or MKIT_VERSION=vX.Y.Z"
version=$(fetch_stdout "${api}/releases/latest" \
| sed -n 's/.*"tag_name": *"\([^"]*\)".*/\1/p' \
| head -n1)
[ -n "$version" ] || die "could not parse latest release tag"
resolved_from_latest=1
fi
# Normalise (accept either 'v0.1.0' or '0.1.0').
case "$version" in
v*) tag="$version"; bare="${version#v}" ;;
*) tag="v$version"; bare="$version" ;;
esac
# ---- silent-downgrade guard ----
#
# We compare the previously-installed tag against the tag we're about
# to install. POSIX sh has no built-in semver compare, so we use sort
# -V (GNU/BSD coreutils both ship it). If sort -V is missing we just
# warn and continue.
#
# Two records are consulted:
# 1. $state_dir/installed-tag — global per-user record, survives
# install_dir rotation.
# 2. $install_dir/.mkit-installed-tag — local-to-binary record,
# survives state-dir clearance and catches the case where a user
# runs the installer with a different MKIT_STATE_DIR.
# Both are written on every install; on a downgrade attempt, EITHER
# file disagreeing causes the guard to fire.
state_file="${state_dir}/installed-tag"
bin_state_file="${install_dir}/.mkit-installed-tag"
read_state_file() {
if [ -f "$1" ]; then
cat "$1" 2>/dev/null || true
fi
}
prev_tag_global="$(read_state_file "$state_file")"
prev_tag_local="$(read_state_file "$bin_state_file")"
# Pick the higher of the two so a missing/empty file never lets a
# downgrade slip through. sort -V handles the comparison; if either is
# empty we just use the non-empty one.
if [ -n "$prev_tag_global" ] && [ -n "$prev_tag_local" ]; then
prev_g_bare="${prev_tag_global#v}"
prev_l_bare="${prev_tag_local#v}"
if command -v sort >/dev/null 2>&1; then
prev_higher="$(printf '%s\n%s\n' "$prev_g_bare" "$prev_l_bare" | sort -V | tail -n1)"
if [ "$prev_higher" = "$prev_l_bare" ]; then
prev_tag="$prev_tag_local"
else
prev_tag="$prev_tag_global"
fi
else
prev_tag="$prev_tag_global"
fi
elif [ -n "$prev_tag_global" ]; then
prev_tag="$prev_tag_global"
else
prev_tag="$prev_tag_local"
fi
if [ -n "$prev_tag" ] && [ "$prev_tag" != "$tag" ]; then
if command -v sort >/dev/null 2>&1; then
# Strip leading 'v' for sort -V.
prev_bare="${prev_tag#v}"
new_bare="${bare}"
higher="$(printf '%s\n%s\n' "$prev_bare" "$new_bare" | sort -V | tail -n1)"
if [ "$higher" = "$prev_bare" ] && [ "$prev_bare" != "$new_bare" ]; then
warn "you are installing $tag but $prev_tag is already recorded — this is a DOWNGRADE."
if [ "$resolved_from_latest" = "1" ]; then
die "refusing to silently downgrade from $prev_tag to $tag via 'latest'. Pin --version $prev_tag or newer, or delete $state_file and $bin_state_file."
else
warn "proceeding because --version was passed explicitly."
fi
fi
fi
fi
# Independent check: the two state files MUST agree when both exist.
# A mismatch means one was tampered with (or a parallel installer
# misbehaved). Refuse rather than picking a winner silently.
if [ -n "$prev_tag_global" ] && [ -n "$prev_tag_local" ] \
&& [ "$prev_tag_global" != "$prev_tag_local" ]; then
die "installed-tag mismatch: $state_file says '$prev_tag_global' but $bin_state_file says '$prev_tag_local'. Refusing to install. Resolve manually."
fi
archive="mkit-${bare}-${target}.tar.gz"
archive_url="${dl}/${tag}/${archive}"
sha_url="${archive_url}.sha256"
bundle_url="${archive_url}.cosign.bundle"
log "installing mkit $tag ($target) into $install_dir"
tmp=$(mktemp -d -t mkit-install.XXXXXXXXXX)
trap 'rm -rf "$tmp"' EXIT INT TERM
# ---- download ----
log "fetching $archive_url"
fetch "$archive_url" "$tmp/$archive" \
|| die "download failed — the release may not include a build for $target"
log "fetching ${archive}.sha256"
fetch "$sha_url" "$tmp/${archive}.sha256"
# ---- cosign verification (default-on) ----
#
# This is the AUTHENTICATION step. The .sha256 check below is integrity
# only — it shows nothing about provenance because the file is served
# from the same origin as the archive. Cosign verifies that this archive
# was produced by this repo's release.yml workflow at a strict-semver tag.
if [ "$skip_cosign" = "1" ]; then
warn "cosign verification SKIPPED (--insecure-skip-cosign / MKIT_SKIP_COSIGN=1)"
warn "the only authenticity check disabled. SHA256 alone proves nothing."
else
if ! command -v cosign >/dev/null 2>&1; then
cat >&2 <<EOF
error: cosign is not in PATH.
cosign keyless signature verification is required by default — without it
the .sha256 file you just downloaded proves nothing about authenticity
(it comes from the same origin as the archive). Install cosign from:
https://docs.sigstore.dev/cosign/installation/
Or, if you have verified the binary by other means and accept the risk,
re-run with --insecure-skip-cosign (or MKIT_SKIP_COSIGN=1).
EOF
exit 1
fi
log "fetching cosign bundle"
fetch "$bundle_url" "$tmp/${archive}.cosign.bundle" \
|| die "cosign bundle missing for $tag — refusing to install. Pass --insecure-skip-cosign to override."
log "cosign verify-blob"
# Strict-semver regex on the tag, anchored to the exact workflow path.
# Accepts e.g. v0.1.0 and v1.2.3-rc.1 but rejects v.* glob matches,
# branch refs, and rogue workflows in the same repo.
cosign verify-blob \
--certificate-identity-regexp '^https://github\.com/officialunofficial/mkit/\.github/workflows/release\.yml@refs/tags/v[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.]+)?$' \
--certificate-oidc-issuer "https://token.actions.githubusercontent.com" \
--bundle "$tmp/${archive}.cosign.bundle" \
"$tmp/$archive" \
|| die "cosign verification failed — refusing to install"
fi
# ---- integrity (SHA256) ----
#
# Defense-in-depth integrity check. Comes AFTER cosign because cosign
# is the authenticity check; sha256 only catches transport corruption.
log "verifying SHA256"
cd "$tmp"
if command -v sha256sum >/dev/null 2>&1; then
sha256sum -c "${archive}.sha256" >/dev/null
elif command -v shasum >/dev/null 2>&1; then
shasum -a 256 -c "${archive}.sha256" >/dev/null
else
die "need sha256sum or shasum to verify the download"
fi
# ---- extract + install (atomic) ----
log "extracting"
tar -xzf "$archive"
stage_dir="mkit-${bare}-${target}"
bin_src="$tmp/$stage_dir/mkit"
[ -x "$bin_src" ] || die "extracted archive missing expected binary at $stage_dir/mkit"
mkdir -p "$install_dir"
install_path="$install_dir/mkit"
# ---- install_dir permission check ----
#
# A group- or world-writable install directory lets anyone on the host
# race a malicious binary into $install_path between our final mv(1)
# and the user's first invocation. Refuse rather than win the race
# unintentionally. Mode is read with BSD stat on Darwin and GNU stat
# on Linux — the rest of the script already keys on $uname_os.
case "$uname_os" in
Darwin) dir_mode="$(stat -f '%Lp' "$install_dir" 2>/dev/null || echo '')" ;;
Linux) dir_mode="$(stat -c '%a' "$install_dir" 2>/dev/null || echo '')" ;;
*) dir_mode="" ;;
esac
if [ -n "$dir_mode" ]; then
# Normalise to three digits so the substring tests below align.
case "$dir_mode" in
[0-9]) dir_mode="00${dir_mode}" ;;
[0-9][0-9]) dir_mode="0${dir_mode}" ;;
esac
# Group bit = middle digit, other bit = last digit. Either even-2
# (write bit set) is the failure case.
group_digit="$(printf '%s' "$dir_mode" | cut -c2)"
other_digit="$(printf '%s' "$dir_mode" | cut -c3)"
case "$group_digit" in
[2367]) die "install dir $install_dir is group-writable (mode $dir_mode); refusing to install — a malicious local user could race a replacement binary into place between mv and first execution. Tighten permissions: chmod g-w $install_dir" ;;
esac
case "$other_digit" in
[2367]) die "install dir $install_dir is world-writable (mode $dir_mode); refusing to install — anyone on this host could race a replacement binary into place. Tighten permissions: chmod o-w $install_dir" ;;
esac
fi
# Atomic install: stage the new binary in the SAME directory as the
# final install path (so rename(2) stays within one filesystem),
# chmod +x BEFORE the rename so the final inode is never executable-
# without-content, then rename on top of the old binary. mv on POSIX
# uses rename(2) when src and dst are on the same fs, which is atomic.
staged="$(mktemp "${install_path}.XXXXXX")" \
|| die "could not create temp file in ${install_dir}"
# mktemp creates with restrictive perms; copy contents then mark exec.
cp "$bin_src" "$staged"
chmod 0755 "$staged"
mv "$staged" "$install_path"
# ---- record installed tag for downgrade guard ----
#
# Write BOTH copies. Future installer runs treat either as
# authoritative (the higher of the two wins); a mismatch trips the
# tamper check above. mv-from-staging makes the writes atomic.
mkdir -p "$state_dir"
printf '%s\n' "$tag" > "${state_file}.new"
mv "${state_file}.new" "$state_file"
printf '%s\n' "$tag" > "${bin_state_file}.new"
mv "${bin_state_file}.new" "$bin_state_file"
log "installed $install_path ($tag)"
# ---- PATH check ----
case ":$PATH:" in
*":$install_dir:"*)
"$install_path" version
;;
*)
warn "$install_dir is not in PATH"
cat <<EOF
Add it to your shell profile:
export PATH="$install_dir:\$PATH"
Then re-open the shell, or run directly:
$install_path version
EOF
;;
esac