From 0a24724559bcbeb0059d3dc2042d07d976525692 Mon Sep 17 00:00:00 2001 From: Mark S Date: Wed, 16 Sep 2026 12:50:20 -0400 Subject: [PATCH 1/8] style: add `shellcheck` annotation and set `pipefail` option --- src/github-cli/install.sh | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/github-cli/install.sh b/src/github-cli/install.sh index 162803620..dd2b92ff7 100755 --- a/src/github-cli/install.sh +++ b/src/github-cli/install.sh @@ -1,4 +1,6 @@ #!/usr/bin/env bash +# shellcheck shell=bash +# #------------------------------------------------------------------------------------------------------------- # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. See https://go.microsoft.com/fwlink/?linkid=2090316 for license information. @@ -13,7 +15,7 @@ EXTENSIONS=${EXTENSIONS:-""} GITHUB_CLI_ARCHIVE_GPG_KEY=7F38BBB59D064DBCB3D84D725612B36462313325 -set -e +set -euo pipefail # Clean up rm -rf /var/lib/apt/lists/* From f26b0c60d08f0f9f8efb20bdbc8adbc4b0f313ee Mon Sep 17 00:00:00 2001 From: Mark S Date: Wed, 16 Sep 2026 12:54:07 -0400 Subject: [PATCH 2/8] refactor: move to os-specific script directory --- src/github-cli/{ => scripts/debian}/install.sh | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename src/github-cli/{ => scripts/debian}/install.sh (100%) diff --git a/src/github-cli/install.sh b/src/github-cli/scripts/debian/install.sh similarity index 100% rename from src/github-cli/install.sh rename to src/github-cli/scripts/debian/install.sh From 39923802d289760f20d2c3383210e55e13b42fbd Mon Sep 17 00:00:00 2001 From: Mark S Date: Wed, 16 Sep 2026 17:34:47 -0400 Subject: [PATCH 3/8] style: fix shellcheck violations --- src/github-cli/scripts/install-extensions.sh | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/github-cli/scripts/install-extensions.sh b/src/github-cli/scripts/install-extensions.sh index 05285aa7b..db0232fb8 100644 --- a/src/github-cli/scripts/install-extensions.sh +++ b/src/github-cli/scripts/install-extensions.sh @@ -11,8 +11,8 @@ INSTALL_EXTENSIONS=${INSTALL_EXTENSIONS:-"true"} trim() { local value="$1" - value="${value#${value%%[![:space:]]*}}" - value="${value%${value##*[![:space:]]}}" + value="${value#"${value%%[![:space:]]*}"}" + value="${value%"${value##*[![:space:]]}"}" echo "${value}" } From a5340a99e9fe3dea701c46efa1e3b6cf274414fd Mon Sep 17 00:00:00 2001 From: Mark S Date: Wed, 16 Sep 2026 17:39:59 -0400 Subject: [PATCH 4/8] feat: factor common utilities to central sourcable script --- src/github-cli/scripts/common.sh | 159 +++++++++++ src/github-cli/scripts/debian/install.sh | 342 ++++++++--------------- 2 files changed, 277 insertions(+), 224 deletions(-) create mode 100644 src/github-cli/scripts/common.sh diff --git a/src/github-cli/scripts/common.sh b/src/github-cli/scripts/common.sh new file mode 100644 index 000000000..961db169b --- /dev/null +++ b/src/github-cli/scripts/common.sh @@ -0,0 +1,159 @@ +# shellcheck shell=sh +# +#------------------------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See https://go.microsoft.com/fwlink/?linkid=2090316 for license information. +#------------------------------------------------------------------------------------------------------------- +# +# Docs: https://github.com/devcontainers/features/tree/main/src/github-cli +# Maintainer: The VS Code and Codespaces Teams +# +# Distro-agnostic helpers shared by ../install.sh and the per-OS installers alongside this +# file. Sourced, never executed: `. "${COMMON_UTILS}"`. +# +# Everything here is POSIX sh so that it can be sourced just as happily from the `sh` +# orchestrator as from a `bash` installer. Helper-local variables are `_`-prefixed rather +# than declared `local`, which is not POSIX. + +# Cached by load_gh_versions so that repeated lookups do not re-hit the network. Only +# effective for calls made outside a `$(...)` subshell. +GH_VERSION_LIST="" + +# Refuse to run anywhere the installers cannot actually write +require_root() { + if [ "$(id -u)" -ne 0 ]; then + echo 'Script must be run as root. Use sudo, su, or add "USER root" to your Dockerfile before running this script.' >&2 + exit 1 + fi +} + +# POSIX sh has no `printf %q`; single-quote the value and escape any quotes within it +shell_quote() { + printf "'%s'" "$(printf '%s' "${1}" | sed "s/'/'\\\\''/g")" +} + +# Populate GH_VERSION_LIST with every released gh version, newest first. +# +# BusyBox `grep` has no `-P` and BusyBox `sort` no `-V`, so the tags are extracted with +# `sed` and the version parts sorted numerically field by field instead. Both forms behave +# identically under GNU coreutils, so this one implementation covers every base image. +load_gh_versions() { + if [ -z "${GH_VERSION_LIST}" ]; then + GH_VERSION_LIST="$( + git ls-remote --tags --refs https://github.com/cli/cli | + sed -n 's#.*refs/tags/v\([0-9][0-9.]*\)$#\1#p' | + sort -t. -k1,1nr -k2,2nr -k3,3nr + )" + fi +} + +# Resolve an alias ("latest") or a partial version ("2", "2.101") to a full release number, +# rewriting the named variable in place. Fails, listing the valid values, if nothing matches. +# +# Usage: find_version_from_git_tags CLI_VERSION +find_version_from_git_tags() { + _variable_name="${1}" + eval "_requested_version=\${${_variable_name}}" + + load_gh_versions + + # No `exit` in the awk program: it would SIGPIPE the upstream `printf` and so trip + # `pipefail` in the bash installers. Reading the whole list through costs nothing. + # shellcheck disable=SC2154 # _requested_version is assigned by the eval above + _resolved_version="$( + printf '%s\n' "${GH_VERSION_LIST}" | + awk -v want="${_requested_version}" ' + BEGIN { newest = (want == "latest" || want == "current" || want == "lts" || want == "stable") } + found { next } + # Comparing "${line}." against "${want}." anchors the match on a version-part + # boundary, so that "2.1" does not select "2.101.0". + newest || index($0 ".", want ".") == 1 { print; found = 1 } + ' + )" + + if [ -z "${_resolved_version}" ]; then + { + echo "Invalid ${_variable_name} value: ${_requested_version}" + echo "Valid values:" + printf '%s\n' "${GH_VERSION_LIST}" + } >&2 + return 1 + fi + + eval "${_variable_name}=\${_resolved_version}" + echo "${_variable_name}=${_resolved_version}" +} + +# Git tags can run ahead of what has actually been published as a downloadable release, so +# step the named variable back to the next-newest tag rather than guessing at a decremented +# version number that may never have existed. Fails if there is nothing older to fall to. +# +# Usage: find_prev_version_from_git_tags CLI_VERSION +find_prev_version_from_git_tags() { + _variable_name="${1}" + eval "_current_version=\${${_variable_name}}" + + load_gh_versions + + # shellcheck disable=SC2154 # _current_version is assigned by the eval above + _previous_version="$( + printf '%s\n' "${GH_VERSION_LIST}" | + awk -v current="${_current_version}" 'found == 1 { print; found = 2 } $0 == current { found = 1 }' + )" + + if [ -z "${_previous_version}" ]; then + echo "(!) No github-cli version older than ${_current_version} is available to fall back to." >&2 + return 1 + fi + + eval "${_variable_name}=\${_previous_version}" + echo "${_variable_name}=${_previous_version}" +} + +# Determine the appropriate non-root user, setting USERNAME +# (mirrors other features' "automatic" behavior) +resolve_username() { + USERNAME="${USERNAME:-"${_REMOTE_USER:-"automatic"}"}" + + if [ "${USERNAME}" = "auto" ] || [ "${USERNAME}" = "automatic" ]; then + USERNAME="" + for _candidate_user in vscode node codespace "$(awk -v val=1000 -F ":" '$3==val{print $1}' /etc/passwd)"; do + if [ -n "${_candidate_user}" ] && id -u "${_candidate_user}" >/dev/null 2>&1; then + USERNAME="${_candidate_user}" + break + fi + done + if [ -z "${USERNAME}" ]; then + USERNAME=root + fi + elif [ "${USERNAME}" = "none" ] || ! id -u "${USERNAME}" >/dev/null 2>&1; then + USERNAME=root + fi +} + +# Install the extensions listed in EXTENSIONS as USERNAME +install_gh_extensions() { + echo "Installing GitHub CLI extensions for ${USERNAME}..." + + if [ "${USERNAME}" = "root" ]; then + EXTENSIONS="${EXTENSIONS}" bash "${EXTENSIONS_SCRIPT}" + return + fi + + # BusyBox `su` has no `--whitelist-environment`, so forward the GitHub auth tokens + # explicitly - and only when they are set, so that an empty value is never mistaken for a + # real one. The `-l`/`-c` short forms used here are accepted by util-linux `su` as well. + _token_env="" + for _token_var in GH_TOKEN GITHUB_TOKEN; do + eval "_token_value=\${${_token_var}:-}" + if [ -n "${_token_value}" ]; then + _token_env="${_token_env}${_token_var}=$(shell_quote "${_token_value}") " + fi + done + + su -l "${USERNAME}" -c "${_token_env}EXTENSIONS=$(shell_quote "${EXTENSIONS}") USERNAME=$(shell_quote "${USERNAME}") INSTALL_EXTENSIONS=true bash $(shell_quote "${EXTENSIONS_SCRIPT}")" + + # Re-run as root solely to install the `gh extension list` shim, which has to live in a + # system directory. + INSTALL_EXTENSIONS=false bash "${EXTENSIONS_SCRIPT}" +} diff --git a/src/github-cli/scripts/debian/install.sh b/src/github-cli/scripts/debian/install.sh index dd2b92ff7..ca401db4f 100755 --- a/src/github-cli/scripts/debian/install.sh +++ b/src/github-cli/scripts/debian/install.sh @@ -9,278 +9,172 @@ # Docs: https://github.com/microsoft/vscode-dev-containers/blob/main/script-library/docs/github.md # Maintainer: The VS Code and Codespaces Teams -CLI_VERSION=${VERSION:-"latest"} -INSTALL_DIRECTLY_FROM_GITHUB_RELEASE=${INSTALLDIRECTLYFROMGITHUBRELEASE:-"true"} -EXTENSIONS=${EXTENSIONS:-""} - +# shellcheck disable=SC2034 GITHUB_CLI_ARCHIVE_GPG_KEY=7F38BBB59D064DBCB3D84D725612B36462313325 set -euo pipefail +# shellcheck source-path=SCRIPTDIR source=../common.sh +. "${COMMON_UTILS}" + # Clean up rm -rf /var/lib/apt/lists/* -if [ "$(id -u)" -ne 0 ]; then - echo -e 'Script must be run as root. Use sudo, su, or add "USER root" to your Dockerfile before running this script.' - exit 1 -fi - # Get the list of GPG key servers that are reachable get_gpg_key_servers() { - declare -A keyservers_curl_map=( - ["hkp://keyserver.ubuntu.com"]="http://keyserver.ubuntu.com:11371" - ["hkp://keyserver.ubuntu.com:80"]="http://keyserver.ubuntu.com" - ["hkps://keys.openpgp.org"]="https://keys.openpgp.org" - ["hkp://keyserver.pgp.com"]="http://keyserver.pgp.com:11371" - ) - - local curl_args="" - local keyserver_reachable=false # Flag to indicate if any keyserver is reachable - - if [ ! -z "${KEYSERVER_PROXY}" ]; then - curl_args="--proxy ${KEYSERVER_PROXY}" + declare -A keyservers_curl_map=( + ["hkp://keyserver.ubuntu.com"]="http://keyserver.ubuntu.com:11371" + ["hkp://keyserver.ubuntu.com:80"]="http://keyserver.ubuntu.com" + ["hkps://keys.openpgp.org"]="https://keys.openpgp.org" + ["hkp://keyserver.pgp.com"]="http://keyserver.pgp.com:11371" + ) + + # Assemble curl args in an array (rather than a string) so the arguments + # reach curl as separate words. Expanding a quoted string would pass it + # as one argument (or a blank one when empty that curl rejects outright). + local curl_args=() + local keyserver_reachable=false # Flag to indicate if any keyserver is reachable + + if [ -n "${KEYSERVER_PROXY:-}" ]; then + curl_args=(--proxy "${KEYSERVER_PROXY}") + fi + + for keyserver in "${!keyservers_curl_map[@]}"; do + local keyserver_curl_url="${keyservers_curl_map[${keyserver}]}" + if curl -s "${curl_args[@]}" --max-time 5 "${keyserver_curl_url}" >/dev/null; then + echo "keyserver ${keyserver}" + keyserver_reachable=true + else + echo "(*) Keyserver ${keyserver} is not reachable." >&2 fi + done - for keyserver in "${!keyservers_curl_map[@]}"; do - local keyserver_curl_url="${keyservers_curl_map[${keyserver}]}" - if curl -s ${curl_args} --max-time 5 ${keyserver_curl_url} > /dev/null; then - echo "keyserver ${keyserver}" - keyserver_reachable=true - else - echo "(*) Keyserver ${keyserver} is not reachable." >&2 - fi - done - - if ! $keyserver_reachable; then - echo "(!) No keyserver is reachable." >&2 - exit 1 - fi + if ! $keyserver_reachable; then + echo "(!) No keyserver is reachable." >&2 + exit 1 + fi } # Import the specified key in a variable name passed in as receive_gpg_keys() { - local keys=${!1} - local keyring_path=$2 - mkdir -p "$(dirname "${keyring_path}")" - - # Install curl - if ! type curl > /dev/null 2>&1; then - check_packages curl - fi - - # Use a temporary location for gpg keys to avoid polluting image - export GNUPGHOME="/tmp/tmp-gnupg" - mkdir -p ${GNUPGHOME} - chmod 700 ${GNUPGHOME} - echo -e "disable-ipv6\n$(get_gpg_key_servers)" > ${GNUPGHOME}/dirmngr.conf - # GPG key download sometimes fails for some reason and retrying fixes it. - local retry_count=0 - local gpg_ok="false" - set +e - until [ "${gpg_ok}" = "true" ] || [ "${retry_count}" -eq "5" ]; - do - echo "(*) Downloading GPG key..." - ( echo "${keys}" | xargs -n 1 gpg -q --recv-keys) 2>&1 \ - && gpg --export ${keys} | gpg --dearmor --yes -o "${keyring_path}" \ - && gpg_ok="true" - if [ "${gpg_ok}" != "true" ]; then - echo "(*) Failed getting key, retrying in 10s..." - (( retry_count++ )) - sleep 10s - fi - done - set -e - if [ "${gpg_ok}" = "false" ]; then - echo "(!) Failed to get gpg key." - exit 1 + local keys=${!1} + local keyring_path=$2 + # `keys` may hold several space-separated key IDs, so split them + # into an array to be sure each one reaches gpg as a single argument + local keys_array=() + read -r -a keys_array <<<"${keys}" + mkdir -p "$(dirname "${keyring_path}")" + + # Install curl + if ! type curl >/dev/null 2>&1; then + check_packages curl + fi + + # Use a temporary location for gpg keys to avoid polluting image + export GNUPGHOME="/tmp/tmp-gnupg" + mkdir -p ${GNUPGHOME} + chmod 700 ${GNUPGHOME} + echo -e "disable-ipv6\n$(get_gpg_key_servers)" >${GNUPGHOME}/dirmngr.conf + # GPG key download sometimes fails for some reason and retrying fixes it. + local retry_count=0 + local gpg_ok="false" + set +e + until [ "${gpg_ok}" = "true" ] || [ "${retry_count}" -eq "5" ]; do + echo "(*) Downloading GPG key..." + (printf '%s\n' "${keys_array[@]}" | xargs -n 1 gpg -q --recv-keys) 2>&1 && + gpg --export "${keys_array[@]}" | gpg --dearmor --yes -o "${keyring_path}" && + gpg_ok="true" + if [ "${gpg_ok}" != "true" ]; then + echo "(*) Failed getting key, retrying in 10s..." + ((retry_count++)) + sleep 10s fi + done + set -e + if [ "${gpg_ok}" = "false" ]; then + echo "(!) Failed to get gpg key." + exit 1 + fi } -apt_get_update() -{ - if [ "$(find /var/lib/apt/lists/* | wc -l)" = "0" ]; then - echo "Running apt-get update..." - apt-get update -y - fi +apt_get_update() { + if [ "$(find /var/lib/apt/lists/* | wc -l)" = "0" ]; then + echo "Running apt-get update..." + apt-get update -y + fi } # Checks if packages are installed and installs them if not check_packages() { - if ! dpkg -s "$@" > /dev/null 2>&1; then - apt_get_update - apt-get -y install --no-install-recommends "$@" - fi + if ! dpkg -s "$@" >/dev/null 2>&1; then + apt_get_update + apt-get -y install --no-install-recommends "$@" + fi } -# Figure out correct version of a three part version number is not passed -find_version_from_git_tags() { - local variable_name=$1 - local requested_version=${!variable_name} - if [ "${requested_version}" = "none" ]; then return; fi - local repository=$2 - local prefix=${3:-"tags/v"} - local separator=${4:-"."} - local last_part_optional=${5:-"false"} - if [ "$(echo "${requested_version}" | grep -o "." | wc -l)" != "2" ]; then - local escaped_separator=${separator//./\\.} - local last_part - if [ "${last_part_optional}" = "true" ]; then - last_part="(${escaped_separator}[0-9]+)?" - else - last_part="${escaped_separator}[0-9]+" - fi - local regex="${prefix}\\K[0-9]+${escaped_separator}[0-9]+${last_part}$" - local version_list="$(git ls-remote --tags ${repository} | grep -oP "${regex}" | tr -d ' ' | tr "${separator}" "." | sort -rV)" - if [ "${requested_version}" = "latest" ] || [ "${requested_version}" = "current" ] || [ "${requested_version}" = "lts" ]; then - declare -g ${variable_name}="$(echo "${version_list}" | head -n 1)" - else - set +e - declare -g ${variable_name}="$(echo "${version_list}" | grep -E -m 1 "^${requested_version//./\\.}([\\.\\s]|$)")" - set -e - fi - fi - if [ -z "${!variable_name}" ] || ! echo "${version_list}" | grep "^${!variable_name//./\\.}$" > /dev/null 2>&1; then - echo -e "Invalid ${variable_name} value: ${requested_version}\nValid values:\n${version_list}" >&2 - exit 1 - fi - echo "${variable_name}=${!variable_name}" -} - -# Use semver logic to decrement a version number then look for the closest match -find_prev_version_from_git_tags() { - local variable_name=$1 - local current_version=${!variable_name} - local repository=$2 - # Normally a "v" is used before the version number, but support alternate cases - local prefix=${3:-"tags/v"} - # Some repositories use "_" instead of "." for version number part separation, support that - local separator=${4:-"."} - # Some tools release versions that omit the last digit (e.g. go) - local last_part_optional=${5:-"false"} - # Some repositories may have tags that include a suffix (e.g. actions/node-versions) - local version_suffix_regex=$6 - # Try one break fix version number less if we get a failure. Use "set +e" since "set -e" can cause failures in valid scenarios. - set +e - major="$(echo "${current_version}" | grep -oE '^[0-9]+' || echo '')" - minor="$(echo "${current_version}" | grep -oP '^[0-9]+\.\K[0-9]+' || echo '')" - breakfix="$(echo "${current_version}" | grep -oP '^[0-9]+\.[0-9]+\.\K[0-9]+' 2>/dev/null || echo '')" - - if [ "${minor}" = "0" ] && [ "${breakfix}" = "0" ]; then - ((major=major-1)) - declare -g ${variable_name}="${major}" - # Look for latest version from previous major release - find_version_from_git_tags "${variable_name}" "${repository}" "${prefix}" "${separator}" "${last_part_optional}" - # Handle situations like Go's odd version pattern where "0" releases omit the last part - elif [ "${breakfix}" = "" ] || [ "${breakfix}" = "0" ]; then - ((minor=minor-1)) - declare -g ${variable_name}="${major}.${minor}" - # Look for latest version from previous minor release - find_version_from_git_tags "${variable_name}" "${repository}" "${prefix}" "${separator}" "${last_part_optional}" - else - ((breakfix=breakfix-1)) - if [ "${breakfix}" = "0" ] && [ "${last_part_optional}" = "true" ]; then - declare -g ${variable_name}="${major}.${minor}" - else - declare -g ${variable_name}="${major}.${minor}.${breakfix}" - fi - fi - set -e -} +# note: `find_version_from_git_tags` and `find_prev_version_from_git_tags` are provided by +# `../common.sh`, which the Alpine installer shares # Fall back on direct download if no apt package exists # Fetches .deb file to be installed with dpkg install_deb_using_github() { - check_packages wget - arch=$(dpkg --print-architecture) - - find_version_from_git_tags CLI_VERSION https://github.com/cli/cli - cli_filename="gh_${CLI_VERSION}_linux_${arch}.deb" - - mkdir -p /tmp/ghcli - pushd /tmp/ghcli - wget -q --show-progress --progress=dot:giga https://github.com/cli/cli/releases/download/v${CLI_VERSION}/${cli_filename} - exit_code=$? - set -e - if [ "$exit_code" != "0" ]; then - # Handle situation where git tags are ahead of what was is available to actually download - echo "(!) github-cli version ${CLI_VERSION} failed to download. Attempting to fall back one version to retry..." - find_prev_version_from_git_tags CLI_VERSION https://github.com/cli/cli - wget -q --show-progress --progress=dot:giga https://github.com/cli/cli/releases/download/v${CLI_VERSION}/${cli_filename} - fi - - dpkg -i /tmp/ghcli/${cli_filename} - popd - rm -rf /tmp/ghcli + check_packages wget + arch=$(dpkg --print-architecture) + + find_version_from_git_tags CLI_VERSION + cli_filename="gh_${CLI_VERSION}_linux_${arch}.deb" + + mkdir -p /tmp/ghcli + pushd /tmp/ghcli + wget -q --show-progress --progress=dot:giga "https://github.com/cli/cli/releases/download/v${CLI_VERSION}/${cli_filename}" + exit_code=$? + set -e + if [ "$exit_code" != "0" ]; then + # Handle situation where git tags are ahead of what was is available to actually download + echo "(!) github-cli version ${CLI_VERSION} failed to download. Attempting to fall back one version to retry..." + find_prev_version_from_git_tags CLI_VERSION + wget -q --show-progress --progress=dot:giga "https://github.com/cli/cli/releases/download/v${CLI_VERSION}/${cli_filename}" + fi + + dpkg -i "/tmp/ghcli/${cli_filename}" + popd + rm -rf /tmp/ghcli } export DEBIAN_FRONTEND=noninteractive # Install curl, apt-transport-https, curl, gpg, or dirmngr, git if missing check_packages curl ca-certificates apt-transport-https dirmngr gnupg2 -if ! type git > /dev/null 2>&1; then - check_packages git +if ! type git >/dev/null 2>&1; then + check_packages git fi # Soft version matching if [ "${CLI_VERSION}" != "latest" ] && [ "${CLI_VERSION}" != "lts" ] && [ "${CLI_VERSION}" != "stable" ]; then - find_version_from_git_tags CLI_VERSION "https://github.com/cli/cli" - version_suffix="=${CLI_VERSION}" + find_version_from_git_tags CLI_VERSION + version_suffix="=${CLI_VERSION}" else - version_suffix="" + version_suffix="" fi # Install the GitHub CLI echo "Downloading github CLI..." if [ "${INSTALL_DIRECTLY_FROM_GITHUB_RELEASE}" = "true" ]; then - install_deb_using_github + install_deb_using_github else - # Import key safely (new method rather than deprecated apt-key approach) and install - . /etc/os-release - receive_gpg_keys GITHUB_CLI_ARCHIVE_GPG_KEY /usr/share/keyrings/githubcli-archive-keyring.gpg - echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" > /etc/apt/sources.list.d/github-cli.list - apt-get update - apt-get -y install "gh${version_suffix}" - rm -rf "/tmp/gh/gnupg" - echo "Done!" + # Import key safely (new method rather than deprecated apt-key approach) and install + . /etc/os-release + receive_gpg_keys GITHUB_CLI_ARCHIVE_GPG_KEY /usr/share/keyrings/githubcli-archive-keyring.gpg + echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" >/etc/apt/sources.list.d/github-cli.list + apt-get update + apt-get -y install "gh${version_suffix}" + rm -rf "/tmp/gh/gnupg" + echo "Done!" fi -# Install requested GitHub CLI extensions (if any) -if [ -n "${EXTENSIONS}" ]; then - SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" - EXTENSIONS_SCRIPT="${SCRIPT_DIR}/scripts/install-extensions.sh" - - # Determine the appropriate non-root user (mirrors other features' "automatic" behavior) - USERNAME="${USERNAME:-"${_REMOTE_USER:-"automatic"}"}" - if [ "${USERNAME}" = "auto" ] || [ "${USERNAME}" = "automatic" ]; then - USERNAME="" - POSSIBLE_USERS=("vscode" "node" "codespace" "$(awk -v val=1000 -F ":" '$3==val{print $1}' /etc/passwd)") - for CURRENT_USER in "${POSSIBLE_USERS[@]}"; do - if [ -n "${CURRENT_USER}" ] && id -u "${CURRENT_USER}" > /dev/null 2>&1; then - USERNAME="${CURRENT_USER}" - break - fi - done - if [ -z "${USERNAME}" ]; then - USERNAME=root - fi - elif [ "${USERNAME}" = "none" ] || ! id -u "${USERNAME}" > /dev/null 2>&1; then - USERNAME=root - fi - - if [ "${USERNAME}" = "root" ]; then - EXTENSIONS="${EXTENSIONS}" bash "${EXTENSIONS_SCRIPT}" - else - EXTENSIONS_ESCAPED="$(printf '%q' "${EXTENSIONS}")" - USERNAME_ESCAPED="$(printf '%q' "${USERNAME}")" - su \ - --login \ - --whitelist-environment=GH_TOKEN,GITHUB_TOKEN \ - --command "EXTENSIONS=${EXTENSIONS_ESCAPED} USERNAME=${USERNAME_ESCAPED} INSTALL_EXTENSIONS=true bash '${EXTENSIONS_SCRIPT}'" \ - "${USERNAME}" - INSTALL_EXTENSIONS=false bash "${EXTENSIONS_SCRIPT}" - fi -fi +# note: requested GitHub CLI extensions are installed by `../../install.sh` once this +# script returns, since none of that work is Debian-specific # Clean up rm -rf /var/lib/apt/lists/* From 356b0cb3777eb1f1f24a68f1c27df05bd4a0fe5c Mon Sep 17 00:00:00 2001 From: Mark S Date: Wed, 16 Sep 2026 17:41:53 -0400 Subject: [PATCH 5/8] feat: support installation on alpine base images Tecnically also adds generic, extensible support for *any* linux-flavor base image. --- src/github-cli/install.sh | 162 +++++++++++++++++++++++ src/github-cli/scripts/alpine/install.sh | 115 ++++++++++++++++ 2 files changed, 277 insertions(+) create mode 100755 src/github-cli/install.sh create mode 100755 src/github-cli/scripts/alpine/install.sh diff --git a/src/github-cli/install.sh b/src/github-cli/install.sh new file mode 100755 index 000000000..c1b81ea9b --- /dev/null +++ b/src/github-cli/install.sh @@ -0,0 +1,162 @@ +#!/usr/bin/env sh +# shellcheck shell=sh +# +#------------------------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See https://go.microsoft.com/fwlink/?linkid=2090316 for license information. +#------------------------------------------------------------------------------------------------------------- +# +# Docs: https://github.com/devcontainers/features/tree/main/src/github-cli +# Maintainer: The VS Code and Codespaces Teams + +set -eu + +EXTENSIONS="${EXTENSIONS:-}" +CLI_VERSION="${VERSION:-latest}" +INSTALL_DIRECTLY_FROM_GITHUB_RELEASE="${INSTALLDIRECTLYFROMGITHUBRELEASE:-true}" + +SCRIPTS_DIR="$(readlink -f "$(dirname "$(readlink -f "${0}")")/scripts")" +EXTENSIONS_SCRIPT="$(readlink -f "${SCRIPTS_DIR}/install-extensions.sh")" +COMMON_UTILS="$(readlink -f "${SCRIPTS_DIR}/common.sh")" + +export EXTENSIONS CLI_VERSION SCRIPTS_DIR COMMON_UTILS EXTENSIONS_SCRIPT INSTALL_DIRECTLY_FROM_GITHUB_RELEASE + +# shellcheck source-path=SCRIPTDIR source=scripts/common.sh +. "${COMMON_UTILS}" + +require_root + +# determine what kind of base image we're running in + +# The base image's OS identifiers, one per line, most specific first: ID, then each entry of +# ID_LIKE - which is a whitespace-separated list, e.g. ID_LIKE="rhel centos fedora". +# +# Values may be bare, double-quoted or single-quoted, and the file may carry comments, other +# keys whose values contain "=", or CRLF line endings, so parse defensively. +os_ids() { + if [ ! -f /etc/os-release ]; then + echo '(!) Base image has no /etc/os-release file!' >&2 + + # return nothing to indicate 'unknown' + return + fi + + detected_ids="$( + awk ' + function clean(value) { + sub(/\r$/, "", value) + sub(/^[ \t]+/, "", value); sub(/[ \t]+$/, "", value) + sub(/^["\047]/, "", value); sub(/["\047]$/, "", value) + return value + } + /^[ \t]*ID=/ { id = clean(substr($0, index($0, "=") + 1)) } + /^[ \t]*ID_LIKE=/ { like = clean(substr($0, index($0, "=") + 1)) } + END { + count = split(id " " like, candidate, /[ \t]+/) + for (i = 1; i <= count; i++) { + if (candidate[i] != "" && !(candidate[i] in seen)) { + seen[candidate[i]] = 1 + print candidate[i] + } + } + } + ' /etc/os-release + )" + + if [ -z "${detected_ids}" ]; then + echo '(!) Base image /etc/os-release sets neither ID nor ID_LIKE!' >&2 + fi + + echo "${detected_ids}" +} + +os_pkg_manager() { + case "${1}" in + debian) + echo "dpkg" + ;; + alpine) + echo "apk" + ;; + arch) + echo "pacman" + ;; + nixos) + echo "nix" + ;; + fedora | rhel | suse) + echo "rpm" + ;; + *) + # Only ever reached if an installer script was added under scripts/ without a matching + # entry above, so say so plainly rather than blaming the base image + echo "(!) No package manager is mapped for OS '${1}', which has an installer script." >&2 + + # return an empty string to indicate "no predetermined OS ⟺ package manager pair" + echo "" + ;; + esac +} + +base_os_name() { + candidates="$(os_ids)" + + if [ -z "${candidates}" ]; then + exit 1 + fi + + # Waterfall from most specific to least: the first candidate we actually ship an installer + # for wins, so an Ubuntu derivative falls through `pop` and `ubuntu` to land on `debian`. + matched_os="" + for candidate in ${candidates}; do + if [ -f "${SCRIPTS_DIR}/${candidate}/install.sh" ]; then + matched_os="${candidate}" + break + fi + done + + if [ -z "${matched_os}" ]; then + # Name the base image by its own ID rather than by whatever it claims to be like + primary_id="$(printf '%s\n' "${candidates}" | head -n 1)" + { + # shellcheck disable=SC2016 + printf '`github-cli` feature for %s-based devcontainers not yet implemented! ' "${primary_id}" + printf 'Contributions are welcome; please implement %s support ' "${primary_id}" + printf 'and open a pull request to https://github.com/devcontainers/features.git\n' + } >&2 + + exit 1 + fi + + manager_cmd="$(os_pkg_manager "${matched_os}")" + + if [ -z "${manager_cmd}" ]; then + exit 1 + fi + + if ! command -v "${manager_cmd}" >/dev/null 2>&1; then + { + printf "(!) Base image reports OS '%s', but its expected package manager " "${matched_os}" + printf "('%s') is not installed. Declining to install in uncertain environment.\\n" "${manager_cmd}" + } >&2 + + exit 1 + fi + + echo "${matched_os}" +} + +# base_os_name only ever returns an OS we ship a usable installer for +base_os="$(base_os_name)" +installer="${SCRIPTS_DIR}/${base_os}/install.sh" + +# The per-OS installers are only responsible getting `gh` onto the PATH (and, +# where the base image lacks it, bash for the extensions script). Everything +# that follows is the same on every distro, so it is orchestrated here rather +# than duplicated in each installer. +"${installer}" + +if [ -n "${EXTENSIONS}" ]; then + resolve_username + install_gh_extensions +fi diff --git a/src/github-cli/scripts/alpine/install.sh b/src/github-cli/scripts/alpine/install.sh new file mode 100755 index 000000000..754c5c699 --- /dev/null +++ b/src/github-cli/scripts/alpine/install.sh @@ -0,0 +1,115 @@ +#!/usr/bin/env sh +# shellcheck shell=sh +# +#------------------------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See https://go.microsoft.com/fwlink/?linkid=2090316 for license information. +#------------------------------------------------------------------------------------------------------------- +# +# Docs: https://github.com/devcontainers/features/tree/main/src/github-cli +# Maintainer: The VS Code and Codespaces Teams + +set -eu + +# shellcheck source-path=SCRIPTDIR source=../common.sh +. "${COMMON_UTILS}" + +# Map Alpine's architecture names onto the ones used by the gh release assets +release_arch() { + case "$(apk --print-arch)" in + x86_64) echo "amd64" ;; + aarch64) echo "arm64" ;; + x86) echo "386" ;; + armhf | armv7) echo "armv6" ;; + *) echo "" ;; + esac +} + +download_release() { + destination_dir="${1}" + cli_filename="${2}" + + echo "Downloading ${cli_filename}..." + wget -q -O "${destination_dir}/${cli_filename}" \ + "https://github.com/cli/cli/releases/download/v${CLI_VERSION}/${cli_filename}" +} + +# Alpine has no equivalent of the `.deb` the Debian installer fetches, so use the +# statically linked tarball that the same release publishes +install_tarball_using_github() { + arch="$(release_arch)" + if [ -z "${arch}" ]; then + { + echo "(!) github-cli publishes no release asset for architecture $(apk --print-arch)." + echo "(!) Set the 'installDirectlyFromGitHubRelease' option to false to install from Alpine's community repository instead." + } >&2 + return 1 + fi + + find_version_from_git_tags CLI_VERSION + + tmp_dir="$(mktemp -d)" + # shellcheck disable=SC2064 # expand now so the trap still knows the path on exit + trap "rm -rf '${tmp_dir}'" EXIT + + cli_filename="gh_${CLI_VERSION}_linux_${arch}.tar.gz" + if ! download_release "${tmp_dir}" "${cli_filename}"; then + # Handle the situation where git tags are ahead of what is available to download + echo "(!) github-cli version ${CLI_VERSION} failed to download. Attempting to fall back one version to retry..." >&2 + find_prev_version_from_git_tags CLI_VERSION + cli_filename="gh_${CLI_VERSION}_linux_${arch}.tar.gz" + download_release "${tmp_dir}" "${cli_filename}" + fi + + tar -xzf "${tmp_dir}/${cli_filename}" -C "${tmp_dir}" + extracted_dir="${tmp_dir}/gh_${CLI_VERSION}_linux_${arch}" + + # Install into /usr/bin rather than /usr/local/bin to match where the Debian package + # lands: the `gh extension list` shim that install-extensions.sh may drop into + # /usr/local/bin/gh execs /usr/bin/gh, and would otherwise recurse into itself. + install -D -m 0755 "${extracted_dir}/bin/gh" /usr/bin/gh + + for manpage in "${extracted_dir}"/share/man/man1/*.1; do + [ -f "${manpage}" ] || continue + install -D -m 0644 "${manpage}" "/usr/share/man/man1/$(basename "${manpage}")" + done +} + +# The community repository is signed with the Alpine keys the base image already trusts, +# so none of the third-party keyring handling the Debian installer needs applies here +install_using_alpine_repository() { + case "${CLI_VERSION}" in + latest | current | lts | stable) ;; + *) + { + echo "(*) Alpine's community repository carries a single github-cli version, so the requested version '${CLI_VERSION}' cannot be honoured and will be ignored." + echo "(*) Leave the 'installDirectlyFromGitHubRelease' option at its default of true to pin a specific version." + } >&2 + ;; + esac + + apk add --update --no-cache --repository='http://dl-cdn.alpinelinux.org/alpine/edge/community' github-cli +} + +# `apk add` is idempotent and skips anything already present, so there is no need for the +# "is it installed?" checks the dpkg-based installer has to make. gh shells out to git for +# most of what it does, so install it here as the Debian installer does. +apk add --no-cache ca-certificates git + +# ../install-extensions.sh is a bash script, and bash is not part of the Alpine base image +if [ -n "${EXTENSIONS}" ]; then + apk add --no-cache bash +fi + +# Install the GitHub CLI +echo "Downloading github CLI..." + +if [ "${INSTALL_DIRECTLY_FROM_GITHUB_RELEASE}" = "true" ]; then + install_tarball_using_github +else + install_using_alpine_repository +fi + +echo "Done!" + +# No clean up needed: `--no-cache` means apk never writes an index or package cache to disk. From 39511747dbb909d8c2e88778963221be866fcbf8 Mon Sep 17 00:00:00 2001 From: Mark S Date: Wed, 16 Sep 2026 17:42:16 -0400 Subject: [PATCH 6/8] tests: add test scenarios for alpine base images --- test/github-cli/install_extensions_alpine.sh | 19 ++++++++++ .../install_extensions_alpine_bare.sh | 18 +++++++++ test/github-cli/install_gh_cli_alpine.sh | 21 ++++++++++ .../install_gh_cli_alpine_from_apk.sh | 17 +++++++++ .../install_gh_cli_alpine_pinned_version.sh | 15 ++++++++ test/github-cli/scenarios.json | 38 +++++++++++++++++++ 6 files changed, 128 insertions(+) create mode 100644 test/github-cli/install_extensions_alpine.sh create mode 100644 test/github-cli/install_extensions_alpine_bare.sh create mode 100644 test/github-cli/install_gh_cli_alpine.sh create mode 100644 test/github-cli/install_gh_cli_alpine_from_apk.sh create mode 100644 test/github-cli/install_gh_cli_alpine_pinned_version.sh diff --git a/test/github-cli/install_extensions_alpine.sh b/test/github-cli/install_extensions_alpine.sh new file mode 100644 index 000000000..af82d9ff1 --- /dev/null +++ b/test/github-cli/install_extensions_alpine.sh @@ -0,0 +1,19 @@ +#!/bin/bash + +set -e + +# Optional: Import test library +source dev-container-features-test-lib + +check "gh-version" gh --version + +# Extensions are installed for the non-root user on this image (uid 1000), by way of +# BusyBox `su`, which does not accept the util-linux options the Debian installer uses +check "gh-extension-installed" bash -c "gh extension list | grep -q 'dlvhdr/gh-dash'" +check "gh-extension-installed-2" bash -c "gh extension list | grep -q 'github/gh-copilot'" + +# bash is not in the Alpine base image and is pulled in only to run the extensions script +check "bash-available" bash -c "command -v bash" + +# Report result +reportResults diff --git a/test/github-cli/install_extensions_alpine_bare.sh b/test/github-cli/install_extensions_alpine_bare.sh new file mode 100644 index 000000000..2b249a2ff --- /dev/null +++ b/test/github-cli/install_extensions_alpine_bare.sh @@ -0,0 +1,18 @@ +#!/bin/bash + +set -e + +# Optional: Import test library +source dev-container-features-test-lib + +# Unlike the devcontainers base image, a bare Alpine image ships neither git nor bash nor a +# non-root user, so this exercises the installer's own dependency handling and its fallback +# to installing extensions for root +check "gh-version" gh --version +check "git-installed" bash -c "command -v git" +check "bash-installed" bash -c "command -v bash" + +check "gh-extension-installed" bash -c "gh extension list | grep -q 'dlvhdr/gh-dash'" + +# Report result +reportResults diff --git a/test/github-cli/install_gh_cli_alpine.sh b/test/github-cli/install_gh_cli_alpine.sh new file mode 100644 index 000000000..828b0970d --- /dev/null +++ b/test/github-cli/install_gh_cli_alpine.sh @@ -0,0 +1,21 @@ +#!/bin/bash + +set -e + +# Optional: Import test library +source dev-container-features-test-lib + +check "version" gh --version + +# The Alpine installer unpacks the release tarball rather than using a package, so `gh` +# should be on the PATH at the same location the Debian package would have put it +check "installed-to-usr-bin" bash -c "[ -x /usr/bin/gh ]" + +# Nothing should have come from the community repository on this path +check "not-installed-by-apk" bash -c "! apk info --installed github-cli" + +# Manual pages ship alongside the binary in the release tarball +check "man-page-installed" bash -c "[ -f /usr/share/man/man1/gh.1 ]" + +# Report result +reportResults diff --git a/test/github-cli/install_gh_cli_alpine_from_apk.sh b/test/github-cli/install_gh_cli_alpine_from_apk.sh new file mode 100644 index 000000000..80f38b92b --- /dev/null +++ b/test/github-cli/install_gh_cli_alpine_from_apk.sh @@ -0,0 +1,17 @@ +#!/bin/bash + +set -e + +# Optional: Import test library +source dev-container-features-test-lib + +check "version" gh --version + +# This path installs from Alpine's community repository, so apk should own the package - +# which is what distinguishes it from the release-tarball path +check "installed-by-apk" bash -c "apk info --installed github-cli" + +check "owned-binary" bash -c "apk info --contents github-cli | grep -qE '(^|/)usr/bin/gh$'" + +# Report result +reportResults diff --git a/test/github-cli/install_gh_cli_alpine_pinned_version.sh b/test/github-cli/install_gh_cli_alpine_pinned_version.sh new file mode 100644 index 000000000..8291318e8 --- /dev/null +++ b/test/github-cli/install_gh_cli_alpine_pinned_version.sh @@ -0,0 +1,15 @@ +#!/bin/bash + +set -e + +# Optional: Import test library +source dev-container-features-test-lib + +check "version" gh --version + +# "2.99" is a partial version, so it should resolve to the newest 2.99.x release - and in +# particular must not match 2.9.x or the numerically larger 2.101.x +check "resolved-partial-version" bash -c "gh --version | grep -qE '^gh version 2\.99\.[0-9]+'" + +# Report result +reportResults diff --git a/test/github-cli/scenarios.json b/test/github-cli/scenarios.json index c6dc2350e..85e722759 100644 --- a/test/github-cli/scenarios.json +++ b/test/github-cli/scenarios.json @@ -34,5 +34,43 @@ "extensions": "dlvhdr/gh-dash,github/gh-copilot,github/gh-aw" } } + }, + "install_gh_cli_alpine": { + "image": "mcr.microsoft.com/devcontainers/base:alpine", + "features": { + "github-cli": {} + } + }, + "install_gh_cli_alpine_pinned_version": { + "image": "mcr.microsoft.com/devcontainers/base:alpine", + "features": { + "github-cli": { + "version": "2.99" + } + } + }, + "install_gh_cli_alpine_from_apk": { + "image": "mcr.microsoft.com/devcontainers/base:alpine", + "features": { + "github-cli": { + "installDirectlyFromGitHubRelease": "false" + } + } + }, + "install_extensions_alpine": { + "image": "mcr.microsoft.com/devcontainers/base:alpine", + "features": { + "github-cli": { + "extensions": "dlvhdr/gh-dash,github/gh-copilot" + } + } + }, + "install_extensions_alpine_bare": { + "image": "alpine:latest", + "features": { + "github-cli": { + "extensions": "dlvhdr/gh-dash" + } + } } } From 725cc4261e5b4eaee1ec158aaf57ca29925e22fb Mon Sep 17 00:00:00 2001 From: Mark S Date: Wed, 16 Sep 2026 18:19:20 -0400 Subject: [PATCH 7/8] docs: update docs to include Alpine support --- src/github-cli/NOTES.md | 34 +++++++++++++++++++++++---- src/github-cli/README.md | 50 +++++++++++++++++++++++++++++++++------- 2 files changed, 72 insertions(+), 12 deletions(-) diff --git a/src/github-cli/NOTES.md b/src/github-cli/NOTES.md index 53c5322dd..8cda5b70f 100644 --- a/src/github-cli/NOTES.md +++ b/src/github-cli/NOTES.md @@ -1,11 +1,37 @@ ## OS Support -This Feature should work on recent versions of Debian/Ubuntu-based distributions with the `apt` package manager installed. +This Feature supports Debian/Ubuntu-based distributions using the `apt` package manager, and Alpine Linux using `apk`. -`bash` is required to execute the `install.sh` script. +The base image is identified from `/etc/os-release`, using `ID` first and then each entry of `ID_LIKE`, so derivatives are covered by the +distribution they declare themselves to be like — Kali, Raspbian, Pop!\_OS and Zorin all resolve to the Debian installer. On a distribution +that is not yet supported, the Feature stops with a message naming the base image rather than failing part-way through an install. + +> [!NOTE] +> `bash` is required to execute the `install.sh` script on Debian-based distros. Debian-based images *generally* provide `bash` by default, +> but if you're using one that does not please note that you'll need to ensure it's installed *before* the `github-cli` feature installer +> runs. +> +> On Alpine-based distros, `bash` is only needed when `extensions` option is set and is installed automatically in that case. Please note +> that this does mean that `bash` will be present in the container if you use the `extensions` option on Alpine images. + +### Choosing an installation source + +`installDirectlyFromGitHubRelease` selects where the GitHub CLI is installed from: + +| | `true` (default) | `false` | +|---------------|--------------------------------------------------------------------|-------------------------------------------------------------------| +| Debian/Ubuntu | the `.deb` published with each GitHub release | GitHub's own apt repository at `cli.github.com` | +| Alpine | the statically linked `.tar.gz` published with each GitHub release | the `github-cli` package in Alpine's `community` repository [ref] | + +[ref]: https://github.com/cli/cli/blob/trunk/docs/install_linux.md#alpine-linux + +Alpine's `community` repository carries a single version of `github-cli`, so a specific `version` cannot be honored when +`installDirectlyFromGitHubRelease` is `false`; it is ignored with a warning. Leave the option at its default to pin a version on Alpine. ## Extensions -If you set the `extensions` option, the feature will install each comma-separated entry. Extensions are installed for the most appropriate non-root user (based on `USERNAME` / `_REMOTE_USER`), with a fallback to `root`. +If you set the `extensions` option, the feature will install each comma-separated entry. Extensions are installed for the most appropriate +non-root user (based on `USERNAME` / `_REMOTE_USER`), with a fallback to `root`. -Private extensions can be installed when `GH_TOKEN` or `GITHUB_TOKEN` is available during feature installation. The token is forwarded to the selected non-root user and used through the GitHub CLI Git credential helper. +Private extensions can be installed when `GH_TOKEN` or `GITHUB_TOKEN` is available during feature installation. The token is forwarded to +the selected non-root user and used through the GitHub CLI Git credential helper. diff --git a/src/github-cli/README.md b/src/github-cli/README.md index 0da722f69..d051921b0 100644 --- a/src/github-cli/README.md +++ b/src/github-cli/README.md @@ -1,3 +1,4 @@ + # GitHub CLI (github-cli) Installs the GitHub CLI. Auto-detects latest version and installs needed dependencies. @@ -12,18 +13,51 @@ Installs the GitHub CLI. Auto-detects latest version and installs needed depende ## Options -| Options Id | Description | Type | Default Value | -| -------------------------------- | --------------------------------------------------------------------------------------------------- | ------- | ------------- | -| version | Select version of the GitHub CLI, if not latest. | string | latest | -| installDirectlyFromGitHubRelease | - | boolean | true | -| extensions | Comma-separated list of GitHub CLI extensions to install (e.g. 'dlvhdr/gh-dash,github/gh-copilot'). | string | | +| Options Id | Description | Type | Default Value | +|-----|-----|-----|-----| +| version | Select version of the GitHub CLI, if not latest. | string | latest | +| installDirectlyFromGitHubRelease | Install from the binaries published with each GitHub release rather than a package repository. | boolean | true | +| extensions | Comma-separated list of GitHub CLI extensions to install (e.g. 'dlvhdr/gh-dash,github/gh-copilot'). | string | - | ## OS Support -This Feature should work on recent versions of Debian/Ubuntu-based distributions with the `apt` package manager installed. +This Feature supports Debian/Ubuntu-based distributions using the `apt` package manager, and Alpine Linux using `apk`. + +The base image is identified from `/etc/os-release`, using `ID` first and then each entry of `ID_LIKE`, so derivatives are covered by the +distribution they declare themselves to be like — Kali, Raspbian, Pop!\_OS and Zorin all resolve to the Debian installer. On a distribution +that is not yet supported, the Feature stops with a message naming the base image rather than failing part-way through an install. + +> [!NOTE] +> `bash` is required to execute the `install.sh` script on Debian-based distros. Debian-based images *generally* provide `bash` by default, +> but if you're using one that does not please note that you'll need to ensure it's installed *before* the `github-cli` feature installer +> runs. +> +> On Alpine-based distros, `bash` is only needed when `extensions` option is set and is installed automatically in that case. Please note +> that this does mean that `bash` will be present in the container if you use the `extensions` option on Alpine images. + +### Choosing an installation source + +`installDirectlyFromGitHubRelease` selects where the GitHub CLI is installed from: + +| | `true` (default) | `false` | +|---------------|--------------------------------------------------------------------|-------------------------------------------------------------------| +| Debian/Ubuntu | the `.deb` published with each GitHub release | GitHub's own apt repository at `cli.github.com` | +| Alpine | the statically linked `.tar.gz` published with each GitHub release | the `github-cli` package in Alpine's `community` repository [ref] | + +[ref]: https://github.com/cli/cli/blob/trunk/docs/install_linux.md#alpine-linux + +Alpine's `community` repository carries a single version of `github-cli`, so a specific `version` cannot be honored when +`installDirectlyFromGitHubRelease` is `false`; it is ignored with a warning. Leave the option at its default to pin a version on Alpine. + +## Extensions + +If you set the `extensions` option, the feature will install each comma-separated entry. Extensions are installed for the most appropriate +non-root user (based on `USERNAME` / `_REMOTE_USER`), with a fallback to `root`. + +Private extensions can be installed when `GH_TOKEN` or `GITHUB_TOKEN` is available during feature installation. The token is forwarded to +the selected non-root user and used through the GitHub CLI Git credential helper. -`bash` is required to execute the `install.sh` script. --- -_Note: This file was auto-generated from the [devcontainer-feature.json](https://github.com/devcontainers/features/blob/main/src/github-cli/devcontainer-feature.json). Add additional notes to a `NOTES.md`._ +_Note: This file was auto-generated from the [devcontainer-feature.json](https://github.com/devcontainers/features/blob/main/src/github-cli/devcontainer-feature.json). Add additional notes to a `NOTES.md`._ From 3e971fcbf6702d0c77d0544624c7ded4b0d5f0d3 Mon Sep 17 00:00:00 2001 From: Mark S Date: Wed, 16 Sep 2026 18:19:34 -0400 Subject: [PATCH 8/8] chore: semver version bump --- src/github-cli/devcontainer-feature.json | 77 ++++++++++++------------ 1 file changed, 39 insertions(+), 38 deletions(-) diff --git a/src/github-cli/devcontainer-feature.json b/src/github-cli/devcontainer-feature.json index 9b7f51e89..fef625b95 100644 --- a/src/github-cli/devcontainer-feature.json +++ b/src/github-cli/devcontainer-feature.json @@ -1,42 +1,43 @@ { - "id": "github-cli", - "version": "1.1.2", - "name": "GitHub CLI", - "documentationURL": "https://github.com/devcontainers/features/tree/main/src/github-cli", - "description": "Installs the GitHub CLI. Auto-detects latest version and installs needed dependencies.", - "options": { - "version": { - "type": "string", - "proposals": [ - "latest", - "none" - ], - "default": "latest", - "description": "Select version of the GitHub CLI, if not latest." - }, - "installDirectlyFromGitHubRelease": { - "type": "boolean", - "default": true - }, - "extensions": { - "type": "string", - "default": "", - "description": "Comma-separated list of GitHub CLI extensions to install (e.g. 'dlvhdr/gh-dash,github/gh-copilot')." - } + "id": "github-cli", + "version": "1.2.0", + "name": "GitHub CLI", + "documentationURL": "https://github.com/devcontainers/features/tree/main/src/github-cli", + "description": "Installs the GitHub CLI. Auto-detects latest version and installs needed dependencies.", + "options": { + "version": { + "type": "string", + "proposals": [ + "latest", + "none" + ], + "default": "latest", + "description": "Select version of the GitHub CLI, if not latest." }, - "customizations": { - "vscode": { - "settings": { - "github.copilot.chat.codeGeneration.instructions": [ - { - "text": "This dev container includes the GitHub CLI (`gh`), which is pre-installed and available on the `PATH`. IMPORTANT: `gh api -f` does not support object values, use multiple `-f` flags with hierarchical keys and string values instead. When using GitHub actions `actions/upload-artifact` or `actions/download-artifact` use v4 or later." - } - ] - } - } + "installDirectlyFromGitHubRelease": { + "type": "boolean", + "default": true, + "description": "Install from the binaries published with each GitHub release rather than a package repository." }, - "installsAfter": [ - "ghcr.io/devcontainers/features/common-utils", - "ghcr.io/devcontainers/features/git" - ] + "extensions": { + "type": "string", + "default": "", + "description": "Comma-separated list of GitHub CLI extensions to install (e.g. 'dlvhdr/gh-dash,github/gh-copilot')." + } + }, + "customizations": { + "vscode": { + "settings": { + "github.copilot.chat.codeGeneration.instructions": [ + { + "text": "This dev container includes the GitHub CLI (`gh`), which is pre-installed and available on the `PATH`. IMPORTANT: `gh api -f` does not support object values, use multiple `-f` flags with hierarchical keys and string values instead. When using GitHub actions `actions/upload-artifact` or `actions/download-artifact` use v4 or later." + } + ] + } + } + }, + "installsAfter": [ + "ghcr.io/devcontainers/features/common-utils", + "ghcr.io/devcontainers/features/git" + ] } \ No newline at end of file