#!/bin/sh # Installs or updates breakpatch-ci, Breakpatch Team's command line for CI. # # curl -fsSL https://breakpatch.dev/install-ci | sh install or update # curl -fsSL https://breakpatch.dev/install-ci | BREAKPATCH_VERSION=1.2.3 sh a given version # curl -fsSL https://breakpatch.dev/install-ci | BREAKPATCH_CHANNEL=beta sh the newest, betas too # curl -fsSL https://breakpatch.dev/install-ci | sh -s -- --uninstall remove it # # What it does: # 1. Checks this is a Mac with Apple Silicon and macOS 14 or later, or 64-bit Linux on x86_64 or # arm64 (a Raspberry Pi 4 or 5, for example; a preview: it runs tests; record them on a Mac), # and finds Python 3.11 (BREAKPATCH_PYTHON, python3.11 or python3). When the machine has no # Python 3.11 (Raspberry Pi OS and Ubuntu 24.04 come with 3.12 or 3.13), it downloads a # pinned build of it from python-build-standalone (github.com/astral-sh/python-build-standalone), # checks it against the SHA-256 written in this script, and keeps it in ~/.breakpatch-ci/python. # On Windows, use install-ci.ps1 instead. # 2. Asks GitHub for the latest release of BreakPatch/breakpatch (or BREAKPATCH_VERSION, or with # BREAKPATCH_CHANNEL=beta the newest release, betas included). # 3. Downloads the release's breakpatch-ci-requirements-.txt and the two wheels it # names (the open engine, and the Team engine compiled to native code) from # BreakPatch/breakpatch's releases (https only, also after redirects, and only from that # address; with BREAKPATCH_GITHUB_TOKEN, from the repository's asset addresses on # api.github.com), and checks each against the release's SHA256SUMS. # 4. Makes a Python 3.11 environment in a new folder in ~/.breakpatch-ci (BREAKPATCH_CI_HOME) # and installs from that file with pip --require-hashes --only-binary=:all:, so every # package, from the release or from PyPI, must match its SHA-256 and nothing is built from # source. # 5. Installs Chromium, the version this engine uses, into ~/.breakpatch-ci/browsers, where # breakpatch-ci finds it without any variable set. # 6. Switches ~/.breakpatch-ci/current to the new environment only once all of that worked # (the one before stays until then), links breakpatch-ci into ~/.local/bin # (BREAKPATCH_CI_BIN), and checks it starts. In GitHub Actions it also adds that folder to # GITHUB_PATH for the next steps; anywhere else it says how when it isn't on PATH. # No sudo, nothing outside those two folders is changed. It runs as root too (CI containers). # # Everything happens in main(), called on the last line: if the download of this script is # cut short, nothing runs. # # Environment: # BREAKPATCH_VERSION install this version (1.2.3, v1.2.3 or 0.1.0-beta.1) instead of the latest # BREAKPATCH_CHANNEL stable (default): GitHub's latest release (releases/latest skips # prereleases; until the first stable release, release.yml makes the newest # beta the latest); beta: the newest release, betas included # BREAKPATCH_GITHUB_TOKEN not needed: BreakPatch/breakpatch is public. It was for the private # beta, while the repository was private: a GitHub token that can read it. # It's sent to https://api.github.com only (never after a redirect to another # host), never shown, and never put on a command line: curl reads the # header from a file only this account can read, deleted as soon as the # downloads are done. # BREAKPATCH_PYTHON the Python 3.11 to use (default python3.11, else python3 if it's 3.11, # else the pinned download) # BREAKPATCH_PYTHON_DOWNLOAD 0: never download Python; without a 3.11, say how to get one # BREAKPATCH_CI_HOME the folder to install into (default ~/.breakpatch-ci) # BREAKPATCH_CI_BIN the folder for the breakpatch-ci link (default ~/.local/bin) # BREAKPATCH_API the GitHub API address of the repository (for tests and mirrors) # BREAKPATCH_DOWNLOADS where the release files must come from (for tests and mirrors; https only) # BREAKPATCH_PYTHON_DOWNLOADS, BREAKPATCH_PYTHON_SHA256 where the Python download comes from # and its SHA-256, together (for tests and mirrors; https only) set -eu GITHUB_API=https://api.github.com/ API=${BREAKPATCH_API:-${GITHUB_API}repos/BreakPatch/breakpatch} RELEASES=https://github.com/BreakPatch/breakpatch/releases DOWNLOADS=${BREAKPATCH_DOWNLOADS:-$RELEASES/download/} DOCS=https://breakpatch.dev/docs/#from-ci-with-breakpatch-ci MARKER=breakpatch-ci.json # Python 3.11 from python-build-standalone, for machines without one: the release, the version, # and each build's SHA-256 (from that release's SHA256SUMS, checked against the files). PBS_RELEASE=20260924 PBS_VERSION=3.11.16 PBS_DOWNLOADS=https://github.com/astral-sh/python-build-standalone/releases/download/ pbs_sha256() { case "$1" in x86_64-unknown-linux-gnu) echo 68c6739376b65258dee5058ccf6777232fe38d31a578965ae8bda327ec7da3a8 ;; aarch64-unknown-linux-gnu) echo 763dae20c6abe982de86ceb7829cc050c5bda79a9824846d106cc1c86d57701b ;; aarch64-apple-darwin) echo e1d745b07b6acc0641dbb3237d3c5953deeeed182141bab2242684076fd86547 ;; esac } say() { printf '%s\n' "$*"; } fail() { printf '%s\n' "$*" >&2; exit 1; } usage() { cat <<'EOF' Installs or updates breakpatch-ci, Breakpatch Team's command line for CI. curl -fsSL https://breakpatch.dev/install-ci | sh curl -fsSL https://breakpatch.dev/install-ci | BREAKPATCH_VERSION=1.2.3 sh curl -fsSL https://breakpatch.dev/install-ci | BREAKPATCH_CHANNEL=beta sh curl -fsSL https://breakpatch.dev/install-ci | sh -s -- --uninstall Options: --uninstall remove breakpatch-ci and the browser it uses --help show this It needs a Mac with Apple Silicon, or 64-bit Linux on x86_64 or arm64 (a preview). It uses Python 3.11 if there is one, and otherwise downloads it (checked) into ~/.breakpatch-ci. It installs to ~/.breakpatch-ci and links breakpatch-ci into ~/.local/bin. On Windows, use PowerShell: irm https://breakpatch.dev/install-ci.ps1 | iex EOF } # A path with the home folder written as ~, for messages. # shellcheck disable=SC2088 # a ~ to show, not to expand pretty() { case "$1" in "$HOME") printf '~' ;; "$HOME"/*) printf '~/%s' "${1#"$HOME"/}" ;; *) printf '%s' "$1" ;; esac } # ---------------------------------------------------------------- checks # platform: macos-arm64, linux-x86_64 or linux-arm64, the name in the release's files. check_platform() { need="breakpatch-ci runs on a Mac with Apple Silicon (M1 or later) and macOS 14 or later, or on 64-bit Linux (x86_64 or arm64) as a preview." os=$(uname -s) arch=$(uname -m) case "$os-$arch" in Darwin-arm64) platform="macos-arm64" ;; Darwin-x86_64) # A Terminal running under Rosetta says x86_64 on Apple Silicon too. [ "$(sysctl -in sysctl.proc_translated 2>/dev/null || true)" = 1 ] \ || fail "$need This Mac has an Intel processor." platform="macos-arm64" ;; Linux-x86_64 | Linux-amd64) platform="linux-x86_64" ;; Linux-aarch64 | Linux-arm64) # A 64-bit kernel says aarch64 under a 32-bit system too (older Raspberry Pi OS images). [ "$(getconf LONG_BIT 2>/dev/null || echo 64)" = 64 ] \ || fail "$need This Raspberry Pi or arm computer runs a 32-bit system. Install a 64-bit one (Raspberry Pi OS 64-bit, or Ubuntu for arm64)." platform="linux-arm64" ;; MINGW* | MSYS* | CYGWIN*) fail "On Windows, install breakpatch-ci from PowerShell: irm https://breakpatch.dev/install-ci.ps1 | iex" ;; Darwin-*) fail "$need This Mac is $arch." ;; Linux-*) fail "$need This machine is Linux on $arch." ;; *) fail "$need This machine is $os on $arch." ;; esac if [ "$platform" = macos-arm64 ]; then macos=$(sw_vers -productVersion 2>/dev/null || true) major=${macos%%.*} case "$major" in '' | *[!0-9]*) fail "$need Couldn't tell which macOS this Mac has." ;; esac [ "$major" -ge 14 ] || fail "$need This Mac has macOS $macos." fi } check_tools() { for tool in curl awk sed; do command -v "$tool" >/dev/null 2>&1 || fail "The installer needs $tool, which isn't on this machine's PATH." done if command -v shasum >/dev/null 2>&1; then sha_tool=shasum elif command -v sha256sum >/dev/null 2>&1; then sha_tool=sha256sum else fail "The installer needs shasum or sha256sum to check the downloads, and this machine has neither." fi } sha256_of() { if [ "$sha_tool" = shasum ]; then shasum -a 256 "$1"; else sha256sum "$1"; fi | awk '{ print $1 }' | tr 'A-F' 'a-f' } # python: Python 3.11, with venv. The engine is built and tested for 3.11 only. BREAKPATCH_PYTHON # must be one; otherwise python3.11 or python3 when it's a usable 3.11, else pbs=1: the pinned # download (fetch_python, once the temporary folder is there), unless BREAKPATCH_PYTHON_DOWNLOAD=0. find_python() { how="Install Python 3.11 (on a Mac: brew install python@3.11; in GitHub Actions: actions/setup-python with python-version 3.11; on Ubuntu: apt-get install python3.11 python3.11-venv), then run this again. If it's installed somewhere else, set BREAKPATCH_PYTHON to it." python="" pbs=0 if [ -n "${BREAKPATCH_PYTHON:-}" ]; then command -v "$BREAKPATCH_PYTHON" >/dev/null 2>&1 || fail "BREAKPATCH_PYTHON is $BREAKPATCH_PYTHON, which isn't there." python=$BREAKPATCH_PYTHON usable_python strict return 0 fi first="" for candidate in python3.11 python3; do command -v "$candidate" >/dev/null 2>&1 || continue python=$candidate first=${first:-$candidate} if usable_python; then return 0; fi done if [ "${BREAKPATCH_PYTHON_DOWNLOAD:-1}" = 0 ]; then # As before there was a download: the first one found, and why it won't do. if [ -z "$first" ]; then fail "breakpatch-ci needs Python 3.11, and this machine has no python3. $how"; fi python=$first usable_python strict return 0 fi python="" pbs=1 } # usable_python [strict]: $python is a Python 3.11 for this machine that can make environments. # strict: say why not and stop; otherwise just answer. usable_python() { pyver=$("$python" -c 'import sys; print("%d.%d" % sys.version_info[:2])' 2>/dev/null || true) if [ -z "$pyver" ]; then [ -n "${1:-}" ] || return 1; fail "Couldn't run $python. $how"; fi if [ "$pyver" != 3.11 ]; then [ -n "${1:-}" ] || return 1; fail "breakpatch-ci needs Python 3.11, and $python is Python $pyver. $how"; fi pyarch=$("$python" -c 'import platform; print(platform.machine())' 2>/dev/null || true) case "$platform-$pyarch" in macos-arm64-arm64 | linux-x86_64-x86_64 | linux-x86_64-amd64 | linux-arm64-aarch64 | linux-arm64-arm64) ;; *) [ -n "${1:-}" ] || return 1 case "$platform" in macos-arm64) fail "$python is a Python for Intel Macs ($pyarch). breakpatch-ci needs a Python 3.11 for Apple Silicon (arm64). $how" ;; *) fail "$python is for $pyarch, not this machine. $how" ;; esac ;; esac if ! "$python" -c 'import venv, ensurepip' >/dev/null 2>&1; then [ -n "${1:-}" ] || return 1 fail "$python can't make Python environments (its venv module is missing). On Ubuntu: apt-get install python3.11-venv. Then run this again." fi return 0 } # The pinned python-build-standalone build for this machine, into $home/python/, checked # against its SHA-256 before it's unpacked. Kept for the next update; --uninstall removes it. fetch_python() { case "$platform" in linux-x86_64) triple=x86_64-unknown-linux-gnu ;; linux-arm64) triple=aarch64-unknown-linux-gnu ;; macos-arm64) triple=aarch64-apple-darwin ;; esac pbs_dir="$home/python/cpython-$PBS_VERSION+$PBS_RELEASE" python="$pbs_dir/bin/python3.11" if [ -x "$python" ] && usable_python; then say "Using Python $PBS_VERSION from $(pretty "$pbs_dir")." return 0 fi base=$PBS_DOWNLOADS expected=$(pbs_sha256 "$triple") if [ -n "${BREAKPATCH_PYTHON_DOWNLOADS:-}" ]; then case "$BREAKPATCH_PYTHON_DOWNLOADS" in https://*/) ;; *) fail "BREAKPATCH_PYTHON_DOWNLOADS must start with https:// and end with /." ;; esac base=$BREAKPATCH_PYTHON_DOWNLOADS expected=${BREAKPATCH_PYTHON_SHA256:-} fi case "$expected" in *[!0-9a-f]* | '') fail "There's no checksum for the Python download for this machine, so nothing was installed." ;; esac [ ${#expected} -eq 64 ] || fail "There's no checksum for the Python download for this machine, so nothing was installed." file="cpython-$PBS_VERSION+$PBS_RELEASE-$triple-install_only_stripped.tar.gz" say "This machine has no Python 3.11 that breakpatch-ci can use: downloading Python $PBS_VERSION (python-build-standalone $PBS_RELEASE)…" # The + is written %2B in the address, as GitHub names the file. curl -fsSL --proto =https --proto-redir =https -o "$tmp/$file" "$base$PBS_RELEASE/cpython-$PBS_VERSION%2B$PBS_RELEASE-$triple-install_only_stripped.tar.gz" 2>/dev/null \ || fail "Couldn't download Python 3.11. Check your connection and run this again, or install Python 3.11 and set BREAKPATCH_PYTHON." [ "$(sha256_of "$tmp/$file")" = "$expected" ] \ || fail "The download of Python 3.11 doesn't match its checksum, so nothing was installed. Run this again. If it keeps happening, tell us: https://github.com/BreakPatch/breakpatch/issues" mkdir -p "$home/python" || fail "Couldn't make the folder $(pretty "$home/python")." rm -rf "$pbs_dir.partial" mkdir "$pbs_dir.partial" || fail "Couldn't write to $(pretty "$home/python")." tar -xzf "$tmp/$file" -C "$pbs_dir.partial" 2>"$tmp/tar.log" || { rm -rf "$pbs_dir.partial"; fail "Couldn't unpack the Python download."; } [ -x "$pbs_dir.partial/python/bin/python3.11" ] || { rm -rf "$pbs_dir.partial"; fail "The Python download isn't what the installer expected, so nothing was installed."; } rm -rf "$pbs_dir" mv "$pbs_dir.partial/python" "$pbs_dir" || fail "Couldn't write to $(pretty "$home/python")." rm -rf "$pbs_dir.partial" usable_python strict say "Python $PBS_VERSION is in $(pretty "$pbs_dir")." } # After a switch: downloaded Pythons that no environment uses any more go. prune_pythons() { [ -d "$home/python" ] || return 0 keep="" if [ "$pbs" -eq 1 ]; then keep=$(basename "$pbs_dir"); fi for d in "$home/python"/cpython-*; do [ -e "$d" ] || continue [ "$(basename "$d")" = "$keep" ] || rm -rf "$d" done rmdir "$home/python" 2>/dev/null || true } # ---------------------------------------------------------------- the token (private beta) # With BREAKPATCH_GITHUB_TOKEN: the "Authorization: Bearer" header goes in a file in $tmp (a # mktemp -d folder only this account can open, written with umask 077), and curl reads it with # -H @file, so the token is never in an argument `ps` shows. printf is a shell builtin (sh, bash # and dash), so writing it runs no command either. Only for https://api.github.com/. # # Redirects: GitHub answers an asset download with a redirect to objects.githubusercontent.com. # curl follows it (-L) and doesn't send a custom Authorization header to a host other than the # one it was asked for (curl 7.58.0 and later, CVE-2018-1000007), nor to another port or scheme # on the same host (7.83.0 and later, CVE-2022-27776). There's no --location-trusted here, which # would send it on, and check_curl refuses an older curl. scripts/test-install.sh checks the # asset host never gets the header. setup_token() { auth="" [ -n "$token" ] || return 0 case "$token" in *[!A-Za-z0-9_]*) fail "BREAKPATCH_GITHUB_TOKEN isn't a GitHub token (they're letters, digits and _ only)." ;; esac case "$API" in "$GITHUB_API"*) ;; *) fail "BREAKPATCH_GITHUB_TOKEN is only ever sent to $GITHUB_API, and BREAKPATCH_API isn't there. Unset one of them." ;; esac check_curl auth="$tmp/auth-header" (umask 077 && printf 'Authorization: Bearer %s\n' "$token" >"$auth") 2>/dev/null \ || fail "Couldn't write to the temporary folder." say "Using BREAKPATCH_GITHUB_TOKEN (private beta)." } # curl 7.83.0 or later: see above. check_curl() { v=$(curl --version 2>/dev/null | sed -n '1s/^curl \([0-9][0-9]*\)\.\([0-9][0-9]*\).*/\1 \2/p') cmaj=${v% *} cmin=${v#* } case "$cmaj$cmin" in '' | *[!0-9]*) fail "Couldn't tell which curl this machine has." ;; esac if [ "$cmaj" -lt 7 ] || { [ "$cmaj" -eq 7 ] && [ "$cmin" -lt 83 ]; }; then fail "With BREAKPATCH_GITHUB_TOKEN the installer needs curl 7.83 or later (this machine has $cmaj.$cmin)." fi } # The token file goes as soon as nothing else is downloaded. drop_token() { if [ -n "${auth:-}" ]; then rm -f "$auth"; fi auth="" } # curl, with the token's header file when there is one. curl_gh() { if [ -n "${auth:-}" ]; then curl -H @"$auth" "$@"; else curl "$@"; fi } # ---------------------------------------------------------------- the release # Fetches a URL to a file; prints the HTTP status ("000" when there was no answer). https only, # redirects too. fetch() { curl_gh -sSL --proto =https --proto-redir =https -H 'Accept: application/vnd.github+json' \ -o "$2" -w '%{http_code}' "$1" 2>/dev/null || true } # A release file. octet-stream is what GitHub's asset addresses (…/releases/assets/ID) need to # send the file rather than its description; the download addresses ignore it. download() { curl_gh -fsSL --proto =https --proto-redir =https -H 'Accept: application/octet-stream' \ -o "$2" "$1" 2>/dev/null } bad_file() { fail "Breakpatch $version lists a file that isn't from Breakpatch's releases, so nothing was installed. Tell us: https://github.com/BreakPatch/breakpatch/issues" } # check_asset NAME URL: a release file's name is a plain file name, and its address one of # Breakpatch's own. Without a token: DOWNLOADS, then the tag and that name, nothing that could # lead elsewhere (no .., no query, no deeper path). With one: the repository's asset address on # api.github.com, API/releases/assets/ and a number. check_asset() { case "$1" in '' | .* | *[!A-Za-z0-9._-]*) bad_file ;; esac if [ -n "$auth" ]; then case "$2" in "$API/releases/assets/"*) ;; *) bad_file ;; esac case "${2#"$API/releases/assets/"}" in '' | *[!0-9]*) bad_file ;; esac return 0 fi case "$DOWNLOADS" in https://*) ;; *) fail "BREAKPATCH_DOWNLOADS must start with https://." ;; esac case "$2" in "$DOWNLOADS"*) rest=${2#"$DOWNLOADS"} ;; *) bad_file ;; esac case "$rest" in */*/* | *..* | *'?'* | *'#'* | *%* | *\\* | /* | */ | '') bad_file ;; esac [ "${2##*/}" = "$1" ] || bad_file } # GitHub's JSON as one line per value: its path, a tab, then the value as written (strings # without their quotes, escapes left in). Paths are like tag_name and assets.0.name, or # 0.tag_name in a list of releases. Just enough of a JSON reader for GitHub's answers, so the # installer needs no jq; strings are skipped whole, so text in a release's notes can't pass for # a value. json_flat() { LC_ALL=C awk ' function here() { return d == 0 ? "" : (t[d] == "{" ? p[d] k[d] : p[d] ix[d]) } { s = s $0 "\n" } END { n = length(s); d = 0; i = 1 while (i <= n) { c = substr(s, i, 1) if (c == "{" || c == "[") { pre = d == 0 ? "" : here() "." d++; t[d] = c; p[d] = pre; ix[d] = 0; k[d] = ""; key[d] = (c == "{"); i++ } else if (c == "}" || c == "]") { if (d > 0) d-- i++ } else if (c == ",") { if (d > 0) { if (t[d] == "[") ix[d]++; else key[d] = 1 } i++ } else if (c == ":") { key[d] = 0; i++ } else if (c == "\"") { j = i + 1 while (j <= n) { c = substr(s, j, 1); if (c == "\\") j += 2; else if (c == "\"") break; else j++ } v = substr(s, i + 1, j - i - 1); i = j + 1 if (d > 0 && t[d] == "{" && key[d]) { k[d] = v; key[d] = 0 } else print here() "\t" v } else if (index(" \t\r\n", c)) { i++ } else { j = i while (j <= n && !index(",}] \t\r\n", substr(s, j, 1))) j++ print here() "\t" substr(s, i, j - i); i = j } } }' "$1" } # The value at a path in the release ($r is its place in a list of releases, else empty). field() { LC_ALL=C awk -F '\t' -v k="$r$1" '$1 == k { print $2; exit }' "$tmp/release.txt" } # The release's files, as "index name" lines. asset_names() { LC_ALL=C awk -F '\t' -v p="${r}assets." ' index($1, p) == 1 { rest = substr($1, length(p) + 1); dot = index(rest, ".") if (substr(rest, dot + 1) == "name" && substr(rest, 1, dot - 1) ~ /^[0-9]+$/) print substr(rest, 1, dot - 1) " " $2 }' "$tmp/release.txt" } # The address to download file number $1 from: its asset address with a token, else its # download address. asset_url() { if [ -n "$auth" ]; then field "assets.$1.url"; else field "assets.$1.browser_download_url"; fi } find_release() { channel=${BREAKPATCH_CHANNEL:-stable} case "$channel" in stable | beta) ;; *) fail "BREAKPATCH_CHANNEL is stable or beta, not $channel." ;; esac wanted="" list=0 if [ -n "${BREAKPATCH_VERSION:-}" ]; then wanted=${BREAKPATCH_VERSION#v} case "$wanted" in [0-9]*) ;; *) fail "BREAKPATCH_VERSION is a version like 1.2.3 or 0.1.0-beta.1." ;; esac case "$wanted" in *[!0-9A-Za-z.-]*) fail "BREAKPATCH_VERSION is a version like 1.2.3 or 0.1.0-beta.1." ;; esac status=$(fetch "$API/releases/tags/v$wanted" "$tmp/release.json") elif [ "$channel" = beta ]; then # Newest first; releases/latest would skip prereleases. list=1 status=$(fetch "$API/releases?per_page=10" "$tmp/release.json") else status=$(fetch "$API/releases/latest" "$tmp/release.json") fi case "$status" in 200) ;; 401) fail "GitHub didn't accept BREAKPATCH_GITHUB_TOKEN. It may have expired: make a new one." ;; 404) if [ -n "$auth" ] && [ -n "$wanted" ]; then fail "There's no Breakpatch $wanted, or BREAKPATCH_GITHUB_TOKEN can't read BreakPatch/breakpatch."; fi if [ -n "$auth" ]; then fail "There's no Breakpatch release yet, or BREAKPATCH_GITHUB_TOKEN can't read BreakPatch/breakpatch."; fi if [ -n "$wanted" ]; then fail "There's no Breakpatch $wanted. The versions are at $RELEASES."; fi fail "There's no Breakpatch release yet. See $RELEASES." ;; 403 | 429) if [ -n "$auth" ] && [ "$status" = 403 ]; then fail "GitHub refused BREAKPATCH_GITHUB_TOKEN (HTTP 403). Check it can read BreakPatch/breakpatch (Contents: read-only), or try again in a few minutes."; fi fail "GitHub is limiting requests from this network right now. Try again in a few minutes." ;; 000) fail "Couldn't reach GitHub to find Breakpatch. Check your connection and try again." ;; *) fail "GitHub didn't answer as expected (HTTP $status). Try again in a few minutes." ;; esac json_flat "$tmp/release.json" >"$tmp/release.txt" r="" if [ "$list" -eq 1 ]; then # The first release in the list that isn't a draft. r=$(LC_ALL=C awk -F '\t' ' { i = $1; sub(/\..*/, "", i) } $1 ~ /^[0-9]+\.tag_name$/ && !(i in seen) { seen[i] = 1; order[++m] = i } $1 ~ /^[0-9]+\.draft$/ && $2 == "true" { draft[i] = 1 } END { for (x = 1; x <= m; x++) if (!(order[x] in draft)) { print order[x] "."; exit } }' "$tmp/release.txt") [ -n "$r" ] || fail "There's no Breakpatch release yet. See $RELEASES." fi tag=$(field tag_name) case "$tag" in v[0-9]*) ;; *) fail "Couldn't read the release from GitHub. Try again in a few minutes." ;; esac case "$tag" in *[!0-9A-Za-z.-]*) fail "Couldn't read the release from GitHub. Try again in a few minutes." ;; esac version=${tag#v} if [ -n "$wanted" ] && [ "$version" != "$wanted" ]; then fail "GitHub answered with Breakpatch $version, not $wanted. Try again in a few minutes." fi asset_names >"$tmp/assets.txt" reqs="breakpatch-ci-requirements-$platform.txt" reqs_i=$(awk -v f="$reqs" '$2 == f { print $1; exit }' "$tmp/assets.txt") sums_i=$(awk '$2 == "SHA256SUMS" { print $1; exit }' "$tmp/assets.txt") if [ -z "$reqs_i" ] || [ -z "$sums_i" ]; then fail "Breakpatch $version has no breakpatch-ci for $(platform_name). Try an older version with BREAKPATCH_VERSION, or write to support@breakpatch.dev." fi reqs_url=$(asset_url "$reqs_i") sums_url=$(asset_url "$sums_i") check_asset "$reqs" "$reqs_url" check_asset SHA256SUMS "$sums_url" } platform_name() { case "$platform" in macos-arm64) printf 'Apple Silicon Macs' ;; linux-arm64) printf 'Linux arm64' ;; *) printf 'Linux x86_64' ;; esac } # A downloaded file must be in SHA256SUMS, with the same SHA-256. check_sum() { expected=$(awk -v f="$1" '$2 == f || $2 == "*" f { print $1; exit }' "$tmp/SHA256SUMS" | tr 'A-F' 'a-f') [ -n "$expected" ] || fail "Breakpatch $version has no checksum for $1, so nothing was installed." [ "$(sha256_of "$tmp/files/$1")" = "$expected" ] \ || fail "The download of $1 doesn't match its checksum, so nothing was installed. Run this again. If it keeps happening, tell us: https://github.com/BreakPatch/breakpatch/issues" } # The requirements file, then each wheel it names: the release's own files, checked against # SHA256SUMS (pip checks them against the hashes in the file as well). download_files() { mkdir "$tmp/files" download "$sums_url" "$tmp/SHA256SUMS" \ || fail "Couldn't download the checksums. Check your connection and run this again." download "$reqs_url" "$tmp/files/$reqs" \ || fail "The download stopped. Check your connection and run this again." check_sum "$reqs" # Only lines like "./name.whl --hash=sha256:<64 hex>" name files; the rest come from PyPI. LC_ALL=C sed -n 's#^\./\([A-Za-z0-9][A-Za-z0-9._-]*\.whl\) --hash=sha256:[0-9a-f]\{64\}$#\1#p' "$tmp/files/$reqs" >"$tmp/wheels.txt" # Exactly the two wheels, and no other file or address: every other line is a package on PyPI. local_lines=$(LC_ALL=C grep -c '^[^#A-Za-z0-9 ]' "$tmp/files/$reqs" || true) if ! grep -q '^breakpatch_engine-' "$tmp/wheels.txt" || ! grep -q '^breakpatch_team_engine-' "$tmp/wheels.txt" \ || [ "$(wc -l <"$tmp/wheels.txt" | tr -d ' ')" -ne 2 ] || [ "$local_lines" -ne 2 ] \ || LC_ALL=C grep -v '^[[:space:]]*#' "$tmp/files/$reqs" | LC_ALL=C grep -q '://\|@ '; then fail "Breakpatch $version's breakpatch-ci files can't be read, so nothing was installed. Write to support@breakpatch.dev." fi while IFS= read -r wheel; do wheel_i=$(awk -v f="$wheel" '$2 == f { print $1; exit }' "$tmp/assets.txt") [ -n "$wheel_i" ] || fail "Breakpatch $version's breakpatch-ci is missing $wheel, so nothing was installed. Write to support@breakpatch.dev." wheel_url=$(asset_url "$wheel_i") check_asset "$wheel" "$wheel_url" download "$wheel_url" "$tmp/files/$wheel" \ || fail "The download stopped. Check your connection and run this again." check_sum "$wheel" done <"$tmp/wheels.txt" } # ---------------------------------------------------------------- this machine # The version in an installed environment's breakpatch-ci.json. installed_version() { sed -n 's/.*"version": *"\([^"]*\)".*/\1/p' "$1/$MARKER" 2>/dev/null | head -n 1 } # Runs on exit: removes the download, and a half-made environment. cleanup() { if [ -n "${staged:-}" ]; then rm -rf "$staged"; fi if [ -n "${tmp:-}" ]; then rm -rf "$tmp"; fi } # Makes the environment in a new folder next to the one in use (a Python environment can't be # moved once made), installs into it and adds its breakpatch-ci.json. make_env() { mkdir -p "$home" || fail "Couldn't make the folder $(pretty "$home")." staged=$(mktemp -d "$home/env-$version.XXXXXXXX" 2>/dev/null) || { staged=""; fail "Couldn't write to $(pretty "$home")."; } "$python" -m venv "$staged" >"$tmp/venv.log" 2>&1 || { tail -5 "$tmp/venv.log" >&2; fail "$python couldn't make a Python environment in $(pretty "$home")."; } say "Installing the Python packages (checked against their hashes)…" (cd "$tmp/files" && PIP_DISABLE_PIP_VERSION_CHECK=1 PIP_NO_INPUT=1 "$staged/bin/python" -m pip install -q \ --require-hashes --only-binary=:all: -r "$reqs") >"$tmp/pip.log" 2>&1 || { tail -8 "$tmp/pip.log" >&2 fail "Couldn't install breakpatch-ci's Python packages. Check this machine can reach pypi.org (or your PIP_INDEX_URL), and run this again."; } printf '{"version": "%s", "browsers": "../browsers"}\n' "$version" >"$staged/$MARKER" \ || fail "Couldn't write to $(pretty "$home")." } # Chromium, into the folder breakpatch-ci looks in (its breakpatch-ci.json says ../browsers). install_browser() { say "Installing Chromium into $(pretty "$home/browsers")…" mkdir -p "$home/browsers" || fail "Couldn't make the folder $(pretty "$home/browsers")." PLAYWRIGHT_BROWSERS_PATH="$home/browsers" "$staged/bin/python" -m playwright install chromium --no-shell >"$tmp/browser.log" 2>&1 || { tail -5 "$tmp/browser.log" >&2 fail "Couldn't install Chromium. Check your connection and run this again."; } } # current -> the new environment, then the command link. `ln -sfn` replaces a link without # following it, on macOS and Linux alike. switch_to() { old=$(readlink "$home/current" 2>/dev/null || true) if [ -e "$home/current" ] && [ ! -L "$home/current" ]; then fail "$(pretty "$home/current") isn't this installer's. Move it away and run this again." fi ln -sfn "$(basename "$staged")" "$home/current" || fail "Couldn't switch $(pretty "$home") to the new version." staged="" case "$old" in env-*) [ "$old" = "$(readlink "$home/current")" ] || rm -rf "${home:?}/$old" ;; esac mkdir -p "$bin" || fail "Couldn't make the folder $(pretty "$bin")." if [ -e "$bin/breakpatch-ci" ] && [ ! -L "$bin/breakpatch-ci" ]; then fail "$(pretty "$bin/breakpatch-ci") is there already and isn't this installer's. Move it away, or set BREAKPATCH_CI_BIN, and run this again." fi ln -sfn "$home/current/bin/breakpatch-ci" "$bin/breakpatch-ci" || fail "Couldn't link breakpatch-ci into $(pretty "$bin")." } on_path() { case ":$PATH:" in *":$1:"*) return 0 ;; esac return 1 } # ---------------------------------------------------------------- install and uninstall do_install() { check_platform check_tools find_python home=${BREAKPATCH_CI_HOME:-$HOME/.breakpatch-ci} bin=${BREAKPATCH_CI_BIN:-$HOME/.local/bin} tmp=$(mktemp -d "${TMPDIR:-/tmp}/breakpatch-ci-install.XXXXXX") || fail "Couldn't make a temporary folder." staged="" trap cleanup EXIT trap 'exit 130' INT trap 'exit 143' TERM setup_token find_release current=$(installed_version "$home/current") if [ ! -e "$home/current" ]; then say "Installing breakpatch-ci $version…" elif [ "$current" = "$version" ]; then say "Reinstalling breakpatch-ci $version…" elif [ -n "$current" ]; then say "Updating breakpatch-ci $current to $version…" else say "Updating breakpatch-ci to $version…" fi case "$platform" in linux-*) say "Linux is a preview: breakpatch-ci runs tests there, and they're recorded on a Mac." ;; esac download_files drop_token if [ "$pbs" -eq 1 ]; then fetch_python; fi make_env install_browser got=$("$staged/bin/breakpatch-ci" --version 2>/dev/null || true) [ "$got" = "$version" ] || fail "The new breakpatch-ci doesn't start (it says '$got'), so the one before is still in use. Write to support@breakpatch.dev." switch_to prune_pythons say "breakpatch-ci $version is in $(pretty "$home")." case "$platform" in linux-*) say "If Chromium can't start, install the libraries it needs: sudo $(pretty "$home")/current/bin/python -m playwright install-deps chromium" ;; esac if on_path "$bin"; then say "Run it with: breakpatch-ci run --test path/to/test.json" elif [ -n "${GITHUB_PATH:-}" ] && printf '%s\n' "$bin" >>"$GITHUB_PATH" 2>/dev/null; then say "Added $(pretty "$bin") to GITHUB_PATH: the next steps of this job can run breakpatch-ci." else say "$(pretty "$bin") isn't on your PATH. Add it, for example: export PATH=\"$bin:\$PATH\"" say "Or run it as $bin/breakpatch-ci" fi say "How to use it: $DOCS" } do_uninstall() { home=${BREAKPATCH_CI_HOME:-$HOME/.breakpatch-ci} bin=${BREAKPATCH_CI_BIN:-$HOME/.local/bin} removed=0 if [ -L "$bin/breakpatch-ci" ]; then case "$(readlink "$bin/breakpatch-ci")" in "$home"/*) rm -f "$bin/breakpatch-ci" && removed=1 ;; esac fi if [ -e "$home/current" ] || [ -e "$home/browsers" ]; then rm -rf "${home:?}" || fail "Couldn't remove $(pretty "$home")." removed=1 fi if [ "$removed" -eq 0 ]; then say "breakpatch-ci isn't in $(pretty "$home"). Nothing to remove." return 0 fi say "Removed breakpatch-ci and its browser from $(pretty "$home")." say "The machine licence is still in Breakpatch's data folder. Run breakpatch-ci licence release first to give the seat back, or free it in the back office." } main() { # The token stays in this shell only: nothing the installer runs gets it in its environment. token=${BREAKPATCH_GITHUB_TOKEN:-} unset BREAKPATCH_GITHUB_TOKEN action=do_install while [ $# -gt 0 ]; do case "$1" in --uninstall) action=do_uninstall ;; -h | --help) usage; return 0 ;; *) fail "Unknown option $1. Run with --help to see the options." ;; esac shift done "$action" } main "$@"