From ad92e754e3ec06f4aa6598a61b0c7ac91371ece6 Mon Sep 17 00:00:00 2001 From: Emilien Escalle Date: Mon, 22 Jun 2026 20:33:42 +0200 Subject: [PATCH 1/7] refactor(github-cli): add shared shell helpers --- src/github-cli/scripts/utils.sh | 56 +++++++++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) create mode 100644 src/github-cli/scripts/utils.sh diff --git a/src/github-cli/scripts/utils.sh b/src/github-cli/scripts/utils.sh new file mode 100644 index 000000000..de6ca145d --- /dev/null +++ b/src/github-cli/scripts/utils.sh @@ -0,0 +1,56 @@ +#!/usr/bin/env bash + +err() { + echo "(!) $*" >&2 +} + +die() { + err "$@" + exit 1 +} + +is_true() { + case "$(printf '%s' "$1" | tr '[:upper:]' '[:lower:]')" in + true|1|yes) + return 0 + ;; + *) + return 1 + ;; + esac +} + +trim() { + local value="$1" + value="${value#${value%%[![:space:]]*}}" + value="${value%${value##*[![:space:]]}}" + echo "${value}" +} + +resolve_target_username() { + local configured_username + local possible_users + local current_user + + configured_username="${USERNAME:-${_REMOTE_USER:-automatic}}" + + if [ "${configured_username}" = "auto" ] || [ "${configured_username}" = "automatic" ]; then + 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 + echo "${current_user}" + return + fi + done + + echo "root" + return + fi + + if [ "${configured_username}" = "none" ] || ! id -u "${configured_username}" > /dev/null 2>&1; then + echo "root" + return + fi + + echo "${configured_username}" +} \ No newline at end of file From c4eb81d14d5989f89838269835dffc16296f7b56 Mon Sep 17 00:00:00 2001 From: Emilien Escalle Date: Mon, 22 Jun 2026 20:33:51 +0200 Subject: [PATCH 2/7] feat(github-cli): add extension install fallbacks --- .../scripts/install-extension-from-gh.sh | 52 ++++++++ .../scripts/install-extension-from-git.sh | 58 ++++++++ src/github-cli/scripts/install-extensions.sh | 124 ++++++++++++------ 3 files changed, 197 insertions(+), 37 deletions(-) create mode 100755 src/github-cli/scripts/install-extension-from-gh.sh create mode 100755 src/github-cli/scripts/install-extension-from-git.sh mode change 100644 => 100755 src/github-cli/scripts/install-extensions.sh diff --git a/src/github-cli/scripts/install-extension-from-gh.sh b/src/github-cli/scripts/install-extension-from-gh.sh new file mode 100755 index 000000000..bab3aeb6e --- /dev/null +++ b/src/github-cli/scripts/install-extension-from-gh.sh @@ -0,0 +1,52 @@ +#!/usr/bin/env bash +#------------------------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See https://go.microsoft.com/fwlink/?linkid=2090316 for license information. +#------------------------------------------------------------------------------------------------------------- + +set -euo pipefail + +EXTENSION=${EXTENSION:-""} +readonly SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +# shellcheck source=./utils.sh +source "${SCRIPT_DIR}/utils.sh" + +extension_is_required() { + [ -n "${EXTENSION}" ] +} + +gh_is_available() { + command -v gh > /dev/null 2>&1 +} + +gh_is_authenticated() { + gh auth status > /dev/null 2>&1 +} + +extension_is_installed() { + gh extension list 2>/dev/null | awk '{print $1}' | grep -Fxq "${EXTENSION}" +} + +main() { + if ! extension_is_required; then + die "EXTENSION is required for GitHub CLI extension installation." + fi + + if ! gh_is_available; then + die "Cannot install extension '${EXTENSION}' with 'gh': GitHub CLI is unavailable." + fi + + if ! gh_is_authenticated; then + die "Cannot install extension '${EXTENSION}' with 'gh': GitHub CLI is not authenticated." + fi + + if extension_is_installed; then + echo "Extension '${EXTENSION}' is already installed. Skipping installation." + exit 0 + fi + + gh extension install "${EXTENSION}" +} + +main "$@" \ No newline at end of file diff --git a/src/github-cli/scripts/install-extension-from-git.sh b/src/github-cli/scripts/install-extension-from-git.sh new file mode 100755 index 000000000..f46d53bee --- /dev/null +++ b/src/github-cli/scripts/install-extension-from-git.sh @@ -0,0 +1,58 @@ +#!/usr/bin/env bash +#------------------------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See https://go.microsoft.com/fwlink/?linkid=2090316 for license information. +#------------------------------------------------------------------------------------------------------------- + +set -euo pipefail + +EXTENSION=${EXTENSION:-""} +readonly SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +# shellcheck source=./utils.sh +source "${SCRIPT_DIR}/utils.sh" + +get_extensions_root() { + echo "${XDG_DATA_HOME:-"${HOME}/.local/share"}/gh/extensions" +} + +extension_is_required() { + [ -n "${EXTENSION}" ] +} + +target_dir() { + echo "$(get_extensions_root)/${EXTENSION##*/}" +} + +extension_is_installed() { + [ -d "$(target_dir)" ] +} + +main() { + local extensions_root + local install_target + + if ! extension_is_required; then + die "EXTENSION is required for git-based GitHub CLI extension installation." + fi + + if ! command -v git > /dev/null 2>&1; then + die "Cannot install extension '${EXTENSION}' because git is unavailable." + fi + + if extension_is_installed; then + echo "Extension '${EXTENSION}' is already installed. Skipping installation." + exit 0 + fi + + extensions_root="$(get_extensions_root)" + install_target="$(target_dir)" + + mkdir -p "${extensions_root}" + git \ + -c credential.helper= \ + -c credential.helper='!gh auth git-credential' \ + clone --depth 1 "https://github.com/${EXTENSION}.git" "${install_target}" +} + +main "$@" \ No newline at end of file diff --git a/src/github-cli/scripts/install-extensions.sh b/src/github-cli/scripts/install-extensions.sh old mode 100644 new mode 100755 index 05285aa7b..7e228c522 --- a/src/github-cli/scripts/install-extensions.sh +++ b/src/github-cli/scripts/install-extensions.sh @@ -4,35 +4,48 @@ # Licensed under the MIT License. See https://go.microsoft.com/fwlink/?linkid=2090316 for license information. #------------------------------------------------------------------------------------------------------------- -set -e +set -euo pipefail EXTENSIONS=${EXTENSIONS:-""} INSTALL_EXTENSIONS=${INSTALL_EXTENSIONS:-"true"} +INSTALL_EXTENSIONS_FROM_GIT=${INSTALL_EXTENSIONS_FROM_GIT:-"false"} +readonly SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +readonly GH_EXTENSION_INSTALLER_SCRIPT="${SCRIPT_DIR}/install-extension-from-gh.sh" +readonly GIT_EXTENSION_INSTALLER_SCRIPT="${SCRIPT_DIR}/install-extension-from-git.sh" + +# shellcheck source=./utils.sh +source "${SCRIPT_DIR}/utils.sh" + +ensure_helper_scripts_exist() { + if [ ! -x "${GIT_EXTENSION_INSTALLER_SCRIPT}" ] || [ ! -x "${GH_EXTENSION_INSTALLER_SCRIPT}" ]; then + die "Missing GitHub CLI extension helper scripts in '${SCRIPT_DIR}'." + fi +} + +install_with_gh() { + local extension="$1" + EXTENSION="${extension}" bash "${GH_EXTENSION_INSTALLER_SCRIPT}" +} -trim() { - local value="$1" - value="${value#${value%%[![:space:]]*}}" - value="${value%${value##*[![:space:]]}}" - echo "${value}" +install_with_git() { + local extension="$1" + EXTENSION="${extension}" bash "${GIT_EXTENSION_INSTALLER_SCRIPT}" } install_extension() { - local extension="$1" - local extensions_root - local repo_name - - extensions_root="${XDG_DATA_HOME:-"${HOME}/.local/share"}/gh/extensions" - repo_name="${extension##*/}" - - mkdir -p "${extensions_root}" - if [ ! -d "${extensions_root}/${repo_name}" ]; then - if ! gh extension install "${extension}"; then - git \ - -c credential.helper= \ - -c credential.helper='!gh auth git-credential' \ - clone --depth 1 "https://github.com/${extension}.git" "${extensions_root}/${repo_name}" - fi - fi + local extension="$1" + + if is_true "${INSTALL_EXTENSIONS_FROM_GIT}"; then + install_with_git "${extension}" + return + fi + + if install_with_gh "${extension}"; then + return + fi + + err "'gh extension install ${extension}' is unavailable, falling back to git clone." + install_with_git "${extension}" } ensure_gh_extension_list_wrapper() { @@ -99,21 +112,58 @@ EOF chmod +x /usr/local/bin/gh } -if [ "${INSTALL_EXTENSIONS}" = "true" ]; then - if [ -z "${EXTENSIONS}" ]; then - exit 0 - fi - echo "Installing GitHub CLI extensions: ${EXTENSIONS}" - IFS=',' read -r -a extension_list <<< "${EXTENSIONS}" - for extension in "${extension_list[@]}"; do - extension="$(trim "${extension}")" - if [ -z "${extension}" ]; then - continue - fi +run_as_target_user_if_needed() { + local target_username + local extensions_escaped + local install_extensions_from_git_escaped + local username_escaped + local script_escaped + + if [ "$(id -u)" -ne 0 ]; then + return + fi + + target_username="$(resolve_target_username)" + if [ "${target_username}" = "root" ]; then + return + fi + + extensions_escaped="$(printf '%q' "${EXTENSIONS}")" + install_extensions_from_git_escaped="$(printf '%q' "${INSTALL_EXTENSIONS_FROM_GIT}")" + username_escaped="$(printf '%q' "${target_username}")" + script_escaped="$(printf '%q' "${BASH_SOURCE[0]}")" + su --login --whitelist-environment=GH_TOKEN,GITHUB_TOKEN "${target_username}" -c "EXTENSIONS=${extensions_escaped} INSTALL_EXTENSIONS_FROM_GIT=${install_extensions_from_git_escaped} USERNAME=${username_escaped} bash ${script_escaped}" + exit 0 +} - install_extension "${extension}" - done -fi +main() { + local extension + local extension_list + + if ! is_true "${INSTALL_EXTENSIONS}"; then + ensure_gh_extension_list_wrapper + return + fi + + if [ -z "${EXTENSIONS}" ]; then + exit 0 + fi + + ensure_helper_scripts_exist + ensure_gh_extension_list_wrapper + run_as_target_user_if_needed + + echo "Installing GitHub CLI extensions: ${EXTENSIONS}" + IFS=',' read -r -a extension_list <<< "${EXTENSIONS}" + for extension in "${extension_list[@]}"; do + extension="$(trim "${extension}")" + if [ -z "${extension}" ]; then + continue + fi + + install_extension "${extension}" + done +} -ensure_gh_extension_list_wrapper +main "$@" From 13e03446a6fc7a797840ab03ec94428fe28163f3 Mon Sep 17 00:00:00 2001 From: Emilien Escalle Date: Mon, 22 Jun 2026 20:34:03 +0200 Subject: [PATCH 3/7] feat(github-cli): add auth-on-setup infrastructure --- src/github-cli/scripts/auth-on-setup.sh | 92 +++++++++++++++++++ .../scripts/install-authentication.sh | 44 +++++++++ .../scripts/install-deferred-extensions.sh | 25 +++++ 3 files changed, 161 insertions(+) create mode 100755 src/github-cli/scripts/auth-on-setup.sh create mode 100755 src/github-cli/scripts/install-authentication.sh create mode 100755 src/github-cli/scripts/install-deferred-extensions.sh diff --git a/src/github-cli/scripts/auth-on-setup.sh b/src/github-cli/scripts/auth-on-setup.sh new file mode 100755 index 000000000..294f0587d --- /dev/null +++ b/src/github-cli/scripts/auth-on-setup.sh @@ -0,0 +1,92 @@ +#!/usr/bin/env bash + +set -euo pipefail + +MARKER_FILE="${HOME}/.config/vscode-dev-containers/github-cli-auth-already-ran" +RESOLVED_AUTH_TOKEN="${GH_TOKEN:-${GITHUB_TOKEN:-}}" +DEFERRED_EXTENSIONS_ENV=/usr/local/share/github-cli/extensions.env +DEFERRED_EXTENSIONS_SCRIPT=/usr/local/share/github-cli/install-extensions.sh + +is_auth_setup_complete() { + [ -f "${MARKER_FILE}" ] +} + +mark_auth_setup_complete() { + mkdir -p "$(dirname "${MARKER_FILE}")" + touch "${MARKER_FILE}" +} + +is_gh_available() { + command -v gh > /dev/null 2>&1 +} + +is_authenticated() { + gh auth status > /dev/null 2>&1 +} + +has_deferred_extensions() { + [ -f "${DEFERRED_EXTENSIONS_ENV}" ] && [ -x "${DEFERRED_EXTENSIONS_SCRIPT}" ] +} + +clear_deferred_extensions_config() { + rm -f "${DEFERRED_EXTENSIONS_ENV}" >/dev/null 2>&1 || true +} + +install_deferred_extensions() { + if ! has_deferred_extensions; then + return 0 + fi + + # shellcheck disable=SC1090 + . "${DEFERRED_EXTENSIONS_ENV}" + + if [ -z "${EXTENSIONS:-}" ]; then + return 0 + fi + + EXTENSIONS="${EXTENSIONS}" INSTALL_EXTENSIONS_FROM_GIT="${INSTALL_EXTENSIONS_FROM_GIT:-false}" bash "${DEFERRED_EXTENSIONS_SCRIPT}" +} + +run_deferred_extensions_if_present() { + if install_deferred_extensions; then + clear_deferred_extensions_config + return 0 + fi + + return 1 +} + +attempt_login() { + if [ -n "${RESOLVED_AUTH_TOKEN}" ]; then + printf '%s' "${RESOLVED_AUTH_TOKEN}" | gh auth login --with-token > /dev/null 2>&1 || true + return + fi + + if [ -t 0 ] && [ -t 1 ]; then + gh auth login || true + fi +} + +main() { + if is_auth_setup_complete || ! is_gh_available; then + exit 0 + fi + + if is_authenticated; then + if ! run_deferred_extensions_if_present; then + exit 0 + fi + mark_auth_setup_complete + exit 0 + fi + + attempt_login + + if is_authenticated && run_deferred_extensions_if_present; then + mark_auth_setup_complete + fi +} + +main + +exit 0 \ No newline at end of file diff --git a/src/github-cli/scripts/install-authentication.sh b/src/github-cli/scripts/install-authentication.sh new file mode 100755 index 000000000..c30220b25 --- /dev/null +++ b/src/github-cli/scripts/install-authentication.sh @@ -0,0 +1,44 @@ +#!/usr/bin/env bash +#------------------------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See https://go.microsoft.com/fwlink/?linkid=2090316 for license information. +#------------------------------------------------------------------------------------------------------------- + +set -euo pipefail + +AUTH_ON_SETUP=${AUTHONSETUP:-"false"} +DEFER_EXTENSIONS_UNTIL_AUTH=${DEFER_EXTENSIONS_UNTIL_AUTH:-"false"} +EXTENSIONS=${EXTENSIONS:-""} +INSTALL_EXTENSIONS_FROM_GIT=${INSTALL_EXTENSIONS_FROM_GIT:-${INSTALLEXTENSIONSFROMGIT:-"false"}} +readonly SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +readonly AUTH_ON_SETUP_SCRIPT="${SCRIPT_DIR}/auth-on-setup.sh" +readonly DEFERRED_EXTENSION_INSTALLER_SCRIPT="${SCRIPT_DIR}/install-deferred-extensions.sh" +readonly INSTALLED_AUTH_ON_SETUP_SCRIPT="/usr/local/share/github-cli-auth-on-setup.sh" + +updaterc() { + local rc_content="$1" + local rc_marker="${2:-$1}" + + if [ -f /etc/bash.bashrc ] && ! grep -Fq "${rc_marker}" /etc/bash.bashrc; then + echo -e "${rc_content}" >> /etc/bash.bashrc + fi + + if [ -f /etc/zsh/zshrc ] && ! grep -Fq "${rc_marker}" /etc/zsh/zshrc; then + echo -e "${rc_content}" >> /etc/zsh/zshrc + fi +} + +main() { + if [ "${AUTH_ON_SETUP}" != "true" ]; then + return + fi + + install -d -m 0755 /usr/local/share/github-cli + install -m 0755 "${AUTH_ON_SETUP_SCRIPT}" "${INSTALLED_AUTH_ON_SETUP_SCRIPT}" + if [ "${DEFER_EXTENSIONS_UNTIL_AUTH}" = "true" ]; then + EXTENSIONS="${EXTENSIONS}" INSTALL_EXTENSIONS_FROM_GIT="${INSTALL_EXTENSIONS_FROM_GIT}" bash "${DEFERRED_EXTENSION_INSTALLER_SCRIPT}" + fi + updaterc $'# github-cli authOnSetup\nbash /usr/local/share/github-cli-auth-on-setup.sh' '# github-cli authOnSetup' +} + +main "$@" diff --git a/src/github-cli/scripts/install-deferred-extensions.sh b/src/github-cli/scripts/install-deferred-extensions.sh new file mode 100755 index 000000000..a02452bf0 --- /dev/null +++ b/src/github-cli/scripts/install-deferred-extensions.sh @@ -0,0 +1,25 @@ +#!/usr/bin/env bash +#------------------------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See https://go.microsoft.com/fwlink/?linkid=2090316 for license information. +#------------------------------------------------------------------------------------------------------------- + +set -euo pipefail + +EXTENSIONS=${EXTENSIONS:-""} +INSTALL_EXTENSIONS_FROM_GIT=${INSTALL_EXTENSIONS_FROM_GIT:-${INSTALLEXTENSIONSFROMGIT:-"false"}} +readonly SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +readonly DEFERRED_EXTENSIONS_DIR="/usr/local/share/github-cli" + +main() { + install -d -m 0755 "${DEFERRED_EXTENSIONS_DIR}" + install -m 0755 "${SCRIPT_DIR}/install-extensions.sh" "${DEFERRED_EXTENSIONS_DIR}/install-extensions.sh" + install -m 0755 "${SCRIPT_DIR}/install-extension-from-gh.sh" "${DEFERRED_EXTENSIONS_DIR}/install-extension-from-gh.sh" + install -m 0755 "${SCRIPT_DIR}/install-extension-from-git.sh" "${DEFERRED_EXTENSIONS_DIR}/install-extension-from-git.sh" + install -m 0755 "${SCRIPT_DIR}/utils.sh" "${DEFERRED_EXTENSIONS_DIR}/utils.sh" + printf 'EXTENSIONS=%q\n' "${EXTENSIONS}" > "${DEFERRED_EXTENSIONS_DIR}/extensions.env" + printf 'INSTALL_EXTENSIONS_FROM_GIT=%q\n' "${INSTALL_EXTENSIONS_FROM_GIT}" >> "${DEFERRED_EXTENSIONS_DIR}/extensions.env" + chmod 0644 "${DEFERRED_EXTENSIONS_DIR}/extensions.env" +} + +main "$@" \ No newline at end of file From 48cabc5799d3ba7f36371f1fccc8e6904631ff63 Mon Sep 17 00:00:00 2001 From: Emilien Escalle Date: Mon, 22 Jun 2026 20:34:11 +0200 Subject: [PATCH 4/7] feat(github-cli): wire auth-aware install scopes --- src/github-cli/devcontainer-feature.json | 14 +- src/github-cli/install.sh | 291 +++---------------- src/github-cli/scripts/install-github-cli.sh | 238 +++++++++++++++ 3 files changed, 291 insertions(+), 252 deletions(-) create mode 100755 src/github-cli/scripts/install-github-cli.sh diff --git a/src/github-cli/devcontainer-feature.json b/src/github-cli/devcontainer-feature.json index d4d8dbb7c..b06cfc05b 100644 --- a/src/github-cli/devcontainer-feature.json +++ b/src/github-cli/devcontainer-feature.json @@ -1,6 +1,6 @@ { "id": "github-cli", - "version": "1.1.3", + "version": "1.3.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.", @@ -21,7 +21,17 @@ "extensions": { "type": "string", "default": "", - "description": "Comma-separated list of GitHub CLI extensions to install (e.g. 'dlvhdr/gh-dash,github/gh-aw')." + "description": "Comma-separated list of GitHub CLI extensions to install (e.g. 'dlvhdr/gh-dash,github/gh-aw'). Requires either `authOnSetup` or `installExtensionsFromGit` to be true." + }, + "installExtensionsFromGit": { + "type": "boolean", + "default": false, + "description": "Install `extensions` by cloning their repositories directly instead of using `gh extension install`. Set this to true when `authOnSetup` is false and `extensions` are configured. When used together with `authOnSetup`, git-based installs happen during feature setup instead of waiting for login." + }, + "authOnSetup": { + "type": "boolean", + "default": false, + "description": "Automatically authenticate with `gh auth login --with-token` when `GH_TOKEN` or `GITHUB_TOKEN` is available, otherwise start `gh auth login` on the first interactive shell if you are not already authenticated. If `extensions` are configured, this must be true unless `installExtensionsFromGit` is true." } }, "customizations": { diff --git a/src/github-cli/install.sh b/src/github-cli/install.sh index 162803620..209ad003c 100755 --- a/src/github-cli/install.sh +++ b/src/github-cli/install.sh @@ -7,278 +7,69 @@ # 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:-""} - -GITHUB_CLI_ARCHIVE_GPG_KEY=7F38BBB59D064DBCB3D84D725612B36462313325 - -set -e +set -euo pipefail -# 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}" - fi +EXTENSIONS=${EXTENSIONS:-""} +INSTALL_EXTENSIONS_FROM_GIT=${INSTALL_EXTENSIONS_FROM_GIT:-${INSTALLEXTENSIONSFROMGIT:-"false"}} +AUTH_ON_SETUP=${AUTHONSETUP:-"false"} +DEFER_EXTENSIONS_UNTIL_AUTH=false +readonly SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +readonly SCRIPTS_DIR="${SCRIPT_DIR}/scripts" - 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 +# shellcheck source=./scripts/utils.sh +source "${SCRIPTS_DIR}/utils.sh" - if ! $keyserver_reachable; then - echo "(!) No keyserver is reachable." >&2 - exit 1 - fi +has_unsupported_extensions_configuration() { + [ -n "${EXTENSIONS}" ] && ! is_true "${AUTH_ON_SETUP}" && ! is_true "${INSTALL_EXTENSIONS_FROM_GIT}" } -# 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 +validate_configuration() { + if has_unsupported_extensions_configuration; then + die "Unsupported extensions configuration. When 'extensions' is set, enable 'authOnSetup' to install after authentication or set 'installExtensionsFromGit' to true to clone extensions during feature setup." fi } -apt_get_update() -{ - if [ "$(find /var/lib/apt/lists/* | wc -l)" = "0" ]; then - echo "Running apt-get update..." - apt-get update -y - fi +should_defer_extensions_until_auth() { + [ -n "${EXTENSIONS}" ] && is_true "${AUTH_ON_SETUP}" && ! is_true "${INSTALL_EXTENSIONS_FROM_GIT}" } -# 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 -} +run_scope() { + local scope="$1" + local scope_script="${SCRIPTS_DIR}/install-${scope}.sh" -# 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 + if [ ! -f "${scope_script}" ]; then + die "Missing scope installer: ${scope_script}" 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 + DEFER_EXTENSIONS_UNTIL_AUTH="${DEFER_EXTENSIONS_UNTIL_AUTH}" bash "${scope_script}" } -# 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 +cleanup_apt_lists() { + rm -rf /var/lib/apt/lists/* } -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 -fi +main() { + if [ "$(id -u)" -ne 0 ]; then + die 'Script must be run as root. Use sudo, su, or add "USER root" to your Dockerfile before running this script.' + 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}" -else - version_suffix="" -fi + cleanup_apt_lists + trap cleanup_apt_lists EXIT -# Install the GitHub CLI -echo "Downloading github CLI..." + validate_configuration -if [ "${INSTALL_DIRECTLY_FROM_GITHUB_RELEASE}" = "true" ]; then - 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!" -fi + if should_defer_extensions_until_auth; then + DEFER_EXTENSIONS_UNTIL_AUTH=true + 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" + run_scope github-cli + run_scope authentication - # 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 + if [ "${DEFER_EXTENSIONS_UNTIL_AUTH}" != "true" ]; then + run_scope extensions 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 +if [ "${BASH_SOURCE[0]}" = "$0" ]; then + main "$@" fi - -# Clean up -rm -rf /var/lib/apt/lists/* diff --git a/src/github-cli/scripts/install-github-cli.sh b/src/github-cli/scripts/install-github-cli.sh new file mode 100755 index 000000000..73c92b381 --- /dev/null +++ b/src/github-cli/scripts/install-github-cli.sh @@ -0,0 +1,238 @@ +#!/usr/bin/env bash +#------------------------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See https://go.microsoft.com/fwlink/?linkid=2090316 for license information. +#------------------------------------------------------------------------------------------------------------- + +set -euo pipefail + +CLI_VERSION=${VERSION:-"latest"} +INSTALL_DIRECTLY_FROM_GITHUB_RELEASE=${INSTALLDIRECTLYFROMGITHUBRELEASE:-"true"} + +GITHUB_CLI_ARCHIVE_GPG_KEY=7F38BBB59D064DBCB3D84D725612B36462313325 +readonly SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +# shellcheck source=./utils.sh +source "${SCRIPT_DIR}/utils.sh" + +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 + local keyserver_curl_url + local keyserver_reachable=false + + if [ -n "${KEYSERVER_PROXY:-}" ]; then + curl_args="--proxy ${KEYSERVER_PROXY}" + fi + + for keyserver in "${!keyservers_curl_map[@]}"; do + 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}" != "true" ]; then + die "No keyserver is reachable." + fi +} + +receive_gpg_keys() { + local keys=${!1} + local keyring_path=$2 + local retry_count=0 + local gpg_ok="false" + + mkdir -p "$(dirname "${keyring_path}")" + + if ! command -v curl > /dev/null 2>&1; then + check_packages curl + fi + + export GNUPGHOME="/tmp/tmp-gnupg" + mkdir -p "${GNUPGHOME}" + chmod 700 "${GNUPGHOME}" + echo -e "disable-ipv6\n$(get_gpg_key_servers)" > "${GNUPGHOME}/dirmngr.conf" + + 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=$((retry_count + 1)) + sleep 10s + fi + done + set -e + + if [ "${gpg_ok}" = "false" ]; then + die "Failed to get gpg key." + fi +} + +apt_get_update() { + if [ "$(find /var/lib/apt/lists/* | wc -l)" = "0" ]; then + echo "Running apt-get update..." + apt-get update -y + fi +} + +check_packages() { + if ! dpkg -s "$@" > /dev/null 2>&1; then + apt_get_update + apt-get -y install --no-install-recommends "$@" + fi +} + +find_version_from_git_tags() { + local variable_name=$1 + local requested_version=${!variable_name} + local repository=$2 + local prefix=${3:-"tags/v"} + local separator=${4:-"."} + local last_part_optional=${5:-"false"} + local escaped_separator + local last_part + local regex + local version_list="" + + if [ "${requested_version}" = "none" ]; then + return + fi + + if [ "$(echo "${requested_version}" | grep -o "." | wc -l)" != "2" ]; then + escaped_separator=${separator//./\\.} + if [ "${last_part_optional}" = "true" ]; then + last_part="(${escaped_separator}[0-9]+)?" + else + last_part="${escaped_separator}[0-9]+" + fi + regex="${prefix}\\K[0-9]+${escaped_separator}[0-9]+${last_part}$" + 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 + else + version_list="${requested_version}" + fi + + if [ -z "${!variable_name}" ] || ! echo "${version_list}" | grep -q "^${!variable_name//./\\.}$"; then + err "Invalid ${variable_name} value: ${requested_version}" + err "Valid values:" + printf '%s\n' "${version_list}" >&2 + exit 1 + fi +} + +find_prev_version_from_git_tags() { + local variable_name=$1 + local current_version=${!variable_name} + local repository=$2 + local prefix=${3:-"tags/v"} + local separator=${4:-"."} + local last_part_optional=${5:-"false"} + local major + local minor + local breakfix + + 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}" + find_version_from_git_tags "${variable_name}" "${repository}" "${prefix}" "${separator}" "${last_part_optional}" + elif [ -z "${breakfix}" ] || [ "${breakfix}" = "0" ]; then + minor=$((minor - 1)) + declare -g "${variable_name}=${major}.${minor}" + 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 +} + +install_deb_using_github() { + local arch + local cli_filename + local exit_code + + 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 > /dev/null + set +e + 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 + err "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 + cli_filename="gh_${CLI_VERSION}_linux_${arch}.deb" + 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 > /dev/null + rm -rf /tmp/ghcli +} + +main() { + local version_suffix="" + + export DEBIAN_FRONTEND=noninteractive + + check_packages curl ca-certificates apt-transport-https dirmngr gnupg2 + if ! command -v git > /dev/null 2>&1; then + check_packages git + fi + + 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}" + fi + + echo "Downloading GitHub CLI..." + + if [ "${INSTALL_DIRECTLY_FROM_GITHUB_RELEASE}" = "true" ]; then + install_deb_using_github + return + fi + + 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!" +} + +main "$@" From 1e9b0b1715b3bc0fd874f04fc8c0ede2c7cf1fa8 Mon Sep 17 00:00:00 2001 From: Emilien Escalle Date: Mon, 22 Jun 2026 20:34:25 +0200 Subject: [PATCH 5/7] docs(github-cli): document auth and extension modes --- src/github-cli/NOTES.md | 36 +++++++++++++++++- src/github-cli/README.md | 82 +++++++++++++++++++++++++++++++++++++--- 2 files changed, 111 insertions(+), 7 deletions(-) diff --git a/src/github-cli/NOTES.md b/src/github-cli/NOTES.md index 53c5322dd..726340fb5 100644 --- a/src/github-cli/NOTES.md +++ b/src/github-cli/NOTES.md @@ -4,8 +4,40 @@ This Feature should work on recent versions of Debian/Ubuntu-based distributions `bash` is required to execute the `install.sh` script. +## Authentication + +If you set the `authOnSetup` option, the feature installs a one-time shell startup hook script. When `gh auth status` shows you are not already authenticated, the hook first tries `gh auth login --with-token` if `GH_TOKEN` or `GITHUB_TOKEN` is available in the environment, otherwise it falls back to `gh auth login` in an interactive shell. This happens after the dev container starts, so it can use values injected through runtime environment settings like `remoteEnv`. + +Example: + +```json +{ + "features": { + "ghcr.io/devcontainers/features/github-cli:1": { + "authOnSetup": true, + "extensions": "dlvhdr/gh-dash,github/gh-aw" + } + }, + "remoteEnv": { + "GITHUB_TOKEN": "${localEnv:GITHUB_TOKEN}" + } +} +``` + +If you already authenticate on the host with GitHub CLI, export a token before opening the dev container: + +```bash +export GITHUB_TOKEN="$(gh auth token)" +``` + +Then the `remoteEnv` example above will pass that token into the container's post-start auth flow. + ## 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 requires either `authOnSetup=true` or `installExtensionsFromGit=true`. The unsupported combination `authOnSetup=false` and `installExtensionsFromGit=false` fails during feature setup with a clear error. + +When `authOnSetup=true`, extension installation is deferred until that post-start auth flow runs so runtime tokens can be used for authenticated installs, unless `installExtensionsFromGit=true`. Extensions are installed for the most appropriate non-root user (based on `USERNAME` / `_REMOTE_USER`), with a fallback to `root`. + +Set `installExtensionsFromGit` to `true` if you want to skip `gh extension install` and always clone extension repositories directly. In that mode, extensions install during feature setup even when `authOnSetup` is enabled. -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 with `installExtensionsFromGit=true`. The token is forwarded to the selected non-root user and used through the GitHub CLI Git credential helper. With `authOnSetup=true`, runtime tokens can be used for deferred installs instead. diff --git a/src/github-cli/README.md b/src/github-cli/README.md index fa2a38819..fb57706b6 100644 --- a/src/github-cli/README.md +++ b/src/github-cli/README.md @@ -4,19 +4,91 @@ Installs the GitHub CLI. Auto-detects latest version and installs needed depende ## Example Usage +### Basic usage + ```json "features": { "ghcr.io/devcontainers/features/github-cli:1": {} } ``` +### Authenticate to GitHub + +On the first interactive shell after the dev container starts, if you are not already authenticated, the feature tries `gh auth login --with-token` if `GH_TOKEN` or `GITHUB_TOKEN` is available. If those environment variables are not set, it falls back to starting `gh auth login` in an interactive shell. + +**Option 1: interactive authentication on setup** + +```json +{ + "features": { + "ghcr.io/devcontainers/features/github-cli:1": { + "authOnSetup": true + } + } +} +``` + +**Option 2: pass an authentication token from the host** + +If you already authenticate on the host with GitHub CLI, export a token before opening the dev container: + +```bash +export GITHUB_TOKEN="$(gh auth token)" +``` + +Then the `remoteEnv` example above will pass that token into the container's post-start auth flow. + +```json +{ + "features": { + "ghcr.io/devcontainers/features/github-cli:1": { + "authOnSetup": true + } + }, + "remoteEnv": { + "GITHUB_TOKEN": "${localEnv:GITHUB_TOKEN}" + } +} +``` + +### Install GitHub CLI extensions + +```json +{ + "features": { + "ghcr.io/devcontainers/features/github-cli:1": { + "extensions": "dlvhdr/gh-dash,github/gh-aw" + } + } +} +``` + +When `extensions` are configured together with `authOnSetup`, extension installation is deferred to the same post-start auth flow so that runtime tokens can be used for authenticated installs. The feature prefers `gh extension install` for each entry when `gh auth status` indicates an authenticated session. If GitHub CLI is not authenticated, or if the native install path fails, the feature falls back to cloning the extension repository into the GitHub CLI extensions directory. + +If `extensions` are configured, at least one of `authOnSetup` or `installExtensionsFromGit` must be `true`. The unsupported combination `authOnSetup=false` and `installExtensionsFromGit=false` fails fast during feature setup. + +If you want to skip `gh extension install` entirely, set `installExtensionsFromGit` to `true`. In that mode, extensions are installed during feature setup even if `authOnSetup` is also enabled. + +```json +{ + "features": { + "ghcr.io/devcontainers/features/github-cli:1": { + "extensions": "dlvhdr/gh-dash,github/gh-aw", + "installExtensionsFromGit": true + } + } +} +``` + ## 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-aw'). | string | | +| Options Id | Description | Type | Default Value | +| -------------------------------- | ---------------------------------------------------------------------------------------------------------------- | ------- | ------------- | +| version | Select version of the GitHub CLI, if not latest. | string | latest | +| installDirectlyFromGitHubRelease | - | boolean | true | +| authOnSetup | Automatically authenticate with `gh auth login --with-token` when `GH_TOKEN` or `GITHUB_TOKEN` is available, otherwise start `gh auth login` on the first interactive shell if you are not already authenticated. If `extensions` are configured, this must be true unless `installExtensionsFromGit` is true. | boolean | false | +| extensions | Comma-separated list of GitHub CLI extensions to install (e.g. 'dlvhdr/gh-dash,github/gh-aw'). Requires either `authOnSetup` or `installExtensionsFromGit` to be true. | string | | +| installExtensionsFromGit | Install `extensions` by cloning their repositories directly instead of using `gh extension install`. Set this to true when `authOnSetup` is false and `extensions` are configured. When `authOnSetup` is enabled, git-based installs happen during feature setup instead of waiting for login. | boolean | false | ## OS Support From 12ec9b605f6f56937e4700d242cbf8a301499ec4 Mon Sep 17 00:00:00 2001 From: Emilien Escalle Date: Mon, 22 Jun 2026 20:34:45 +0200 Subject: [PATCH 6/7] test(github-cli): cover auth and extension install flows --- test/github-cli/auto_auth_on_setup.sh | 14 +++ test/github-cli/install_extensions.sh | 15 --- .../install_extensions_after_auth.sh | 14 +++ .../github-cli/install_extensions_from_git.sh | 18 +++ test/github-cli/scenarios.json | 24 +++- test/github-cli/test.sh | 2 + .../validate_extensions_configuration.sh | 42 +++++++ test/github-cli/verify_auth_with_token.sh | 43 +++++++ .../verify_extensions_install_after_auth.sh | 108 ++++++++++++++++++ 9 files changed, 264 insertions(+), 16 deletions(-) create mode 100644 test/github-cli/auto_auth_on_setup.sh delete mode 100644 test/github-cli/install_extensions.sh create mode 100644 test/github-cli/install_extensions_after_auth.sh create mode 100644 test/github-cli/install_extensions_from_git.sh create mode 100644 test/github-cli/validate_extensions_configuration.sh create mode 100755 test/github-cli/verify_auth_with_token.sh create mode 100644 test/github-cli/verify_extensions_install_after_auth.sh diff --git a/test/github-cli/auto_auth_on_setup.sh b/test/github-cli/auto_auth_on_setup.sh new file mode 100644 index 000000000..d187324e2 --- /dev/null +++ b/test/github-cli/auto_auth_on_setup.sh @@ -0,0 +1,14 @@ +#!/bin/bash + +set -e + +# Optional: Import test library +source dev-container-features-test-lib + +check "gh-version" gh --version +check "github-cli-auth-script-installed" test -x /usr/local/share/github-cli-auth-on-setup.sh +check "github-cli-auth-hook-installed" bash -lc "grep -Fq 'bash /usr/local/share/github-cli-auth-on-setup.sh' /etc/bash.bashrc" +check "github-cli-auth-with-token" /bin/bash "$(dirname "$0")/verify_auth_with_token.sh" + +# Report result +reportResults diff --git a/test/github-cli/install_extensions.sh b/test/github-cli/install_extensions.sh deleted file mode 100644 index d5b302afd..000000000 --- a/test/github-cli/install_extensions.sh +++ /dev/null @@ -1,15 +0,0 @@ -#!/bin/bash - -set -e - -# Optional: Import test library -source dev-container-features-test-lib - -check "gh-version" gh --version - -check "gh-extension-installed" gh extension list | grep -q 'dlvhdr/gh-dash' -check "gh-extension-installed-2" gh extension list | grep -q 'github/gh-aw' -check "gh-aw-runs" gh aw version - -# Report result -reportResults diff --git a/test/github-cli/install_extensions_after_auth.sh b/test/github-cli/install_extensions_after_auth.sh new file mode 100644 index 000000000..6392b61cb --- /dev/null +++ b/test/github-cli/install_extensions_after_auth.sh @@ -0,0 +1,14 @@ +#!/bin/bash + +set -e + +# Optional: Import test library +source dev-container-features-test-lib + +check "gh-version" gh --version +check "github-cli-deferred-extension-config-installed" test -f /usr/local/share/github-cli/extensions.env +check "github-cli-deferred-extension-installer-installed" test -x /usr/local/share/github-cli/install-extensions.sh +check "github-cli-installs-extensions-with-gh-after-auth" /bin/bash "$(dirname "$0")/verify_extensions_install_after_auth.sh" + +# Report result +reportResults \ No newline at end of file diff --git a/test/github-cli/install_extensions_from_git.sh b/test/github-cli/install_extensions_from_git.sh new file mode 100644 index 000000000..38648fbc9 --- /dev/null +++ b/test/github-cli/install_extensions_from_git.sh @@ -0,0 +1,18 @@ +#!/bin/bash + +set -e + +# Optional: Import test library +source dev-container-features-test-lib + +extensions_root="${XDG_DATA_HOME:-"${HOME}/.local/share"}/gh/extensions" + +check "gh-version" gh --version + +check "gh-extension-installed" test -d "${extensions_root}/gh-dash" +check "gh-extension-git-clone-installed" test -f "${extensions_root}/gh-dash/.git/config" +check "gh-extension-installed-2" test -d "${extensions_root}/gh-aw" +check "gh-extension-git-clone-installed-2" test -f "${extensions_root}/gh-aw/.git/config" + +# Report result +reportResults diff --git a/test/github-cli/scenarios.json b/test/github-cli/scenarios.json index dd7766ddb..c315d9664 100644 --- a/test/github-cli/scenarios.json +++ b/test/github-cli/scenarios.json @@ -26,13 +26,35 @@ } } }, - "install_extensions": { + "auto_auth_on_setup": { "image": "ubuntu:noble", "features": { "github-cli": { "version": "latest", + "installDirectlyFromGitHubRelease": "false", + "authOnSetup": "true" + } + } + }, + "install_extensions_after_auth": { + "image": "ubuntu:noble", + "features": { + "github-cli": { + "version": "latest", + "installDirectlyFromGitHubRelease": "false", + "authOnSetup": "true", "extensions": "dlvhdr/gh-dash,github/gh-aw" } } + }, + "install_extensions_from_git": { + "image": "ubuntu:noble", + "features": { + "github-cli": { + "version": "latest", + "extensions": "dlvhdr/gh-dash,github/gh-aw", + "installExtensionsFromGit": "true" + } + } } } diff --git a/test/github-cli/test.sh b/test/github-cli/test.sh index 58df559d7..eee047ccb 100755 --- a/test/github-cli/test.sh +++ b/test/github-cli/test.sh @@ -96,5 +96,7 @@ CLI_VERSION="2.0.0" find_prev_version_from_git_tags CLI_VERSION https://github.com/cli/cli check "pre-version-to-2.0.0" bash -c "echo ${CLI_VERSION} | grep '1.14.0'" +check "extensions-config-requires-auth-or-git" /bin/bash "$(dirname "$0")/validate_extensions_configuration.sh" + # Report result reportResults \ No newline at end of file diff --git a/test/github-cli/validate_extensions_configuration.sh b/test/github-cli/validate_extensions_configuration.sh new file mode 100644 index 000000000..47947a8ca --- /dev/null +++ b/test/github-cli/validate_extensions_configuration.sh @@ -0,0 +1,42 @@ +#!/usr/bin/env bash + +set -euo pipefail + +repo_root="$(cd "$(dirname "$0")/../.." && pwd)" + +validation_script='is_true() { + case "$(printf "%s" "$1" | tr "[:upper:]" "[:lower:]")" in + true|1|yes) + return 0 + ;; + *) + return 1 + ;; + esac +} + +if [ -n "${EXTENSIONS:-}" ] && ! is_true "${AUTHONSETUP:-false}" && ! is_true "${INSTALLEXTENSIONSFROMGIT:-false}"; then + echo "(!) Unsupported extensions configuration. When 'extensions' is set, enable 'authOnSetup' to install after authentication or set 'installExtensionsFromGit' to true to clone extensions during feature setup." >&2 + exit 1 +fi' + +if [ -f "${repo_root}/src/github-cli/install.sh" ]; then + validation_script='source "'"${repo_root}"'"/src/github-cli/install.sh; validate_configuration' +fi + +set +e +output="$({ + EXTENSIONS="dlvhdr/gh-dash" \ + INSTALLEXTENSIONSFROMGIT=false \ + AUTHONSETUP=false \ + bash -lc "${validation_script}" +} 2>&1)" +status=$? +set -e + +if [ "${status}" -eq 0 ]; then + echo "Expected unsupported extensions configuration to fail." >&2 + exit 1 +fi + +printf '%s' "${output}" | grep -Fq "Unsupported extensions configuration." \ No newline at end of file diff --git a/test/github-cli/verify_auth_with_token.sh b/test/github-cli/verify_auth_with_token.sh new file mode 100755 index 000000000..df8a0e1c1 --- /dev/null +++ b/test/github-cli/verify_auth_with_token.sh @@ -0,0 +1,43 @@ +#!/usr/bin/env bash + +set -e + +rm -rf /tmp/github-cli-auth-test +mkdir -p /tmp/github-cli-auth-test/bin /tmp/github-cli-auth-test/home + +cat > /tmp/github-cli-auth-test/bin/gh <<'EOF' +#!/bin/bash +set -e + +LOG_FILE=/tmp/github-cli-auth-test/log +STATE_FILE=/tmp/github-cli-auth-test/state + +case "$1:$2:$3" in + auth:status:) + [ -f "$STATE_FILE" ] + ;; + auth:login:--with-token) + cat > /tmp/github-cli-auth-test/token + echo with-token > "$LOG_FILE" + touch "$STATE_FILE" + ;; + auth:login:) + echo interactive > "$LOG_FILE" + touch "$STATE_FILE" + ;; + *) + exit 1 + ;; +esac +EOF + +chmod +x /tmp/github-cli-auth-test/bin/gh + +PATH="/tmp/github-cli-auth-test/bin:${PATH}" \ +HOME=/tmp/github-cli-auth-test/home \ +GH_TOKEN=test-token \ +/usr/local/share/github-cli-auth-on-setup.sh + +grep -Fxq 'with-token' /tmp/github-cli-auth-test/log +grep -Fxq 'test-token' /tmp/github-cli-auth-test/token +test -f /tmp/github-cli-auth-test/home/.config/vscode-dev-containers/github-cli-auth-already-ran diff --git a/test/github-cli/verify_extensions_install_after_auth.sh b/test/github-cli/verify_extensions_install_after_auth.sh new file mode 100644 index 000000000..d9993a6c1 --- /dev/null +++ b/test/github-cli/verify_extensions_install_after_auth.sh @@ -0,0 +1,108 @@ +#!/usr/bin/env bash + +set -euo pipefail + +TEST_ROOT=/tmp/github-cli-extension-auth-test +BIN_DIR="${TEST_ROOT}/bin" +HOME_DIR="${TEST_ROOT}/home" +LOG_FILE="${TEST_ROOT}/log" +STATE_FILE="${TEST_ROOT}/state" +TOKEN_FILE="${TEST_ROOT}/token" +INSTALLED_FILE="${TEST_ROOT}/installed" + +resolve_target_username() { + local current_user + local possible_users + + possible_users=("vscode" "node" "codespace" "ubuntu" "$(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 + echo "${current_user}" + return + fi + done + + echo root +} + +rm -rf "${TEST_ROOT}" +mkdir -p "${BIN_DIR}" "${HOME_DIR}" + +cat > "${BIN_DIR}/gh" <<'EOF' +#!/bin/bash +set -euo pipefail + +TEST_ROOT=/tmp/github-cli-extension-auth-test +LOG_FILE="${TEST_ROOT}/log" +STATE_FILE="${TEST_ROOT}/state" +TOKEN_FILE="${TEST_ROOT}/token" +INSTALLED_FILE="${TEST_ROOT}/installed" + +command_name="${1:-}" +subcommand_name="${2:-}" + +case "${command_name}:${subcommand_name}" in + auth:status) + [ -f "${STATE_FILE}" ] + ;; + auth:login) + if [ "${3:-}" = "--with-token" ]; then + cat > "${TOKEN_FILE}" + echo with-token >> "${LOG_FILE}" + touch "${STATE_FILE}" + exit 0 + fi + + echo interactive >> "${LOG_FILE}" + touch "${STATE_FILE}" + ;; + extension:list) + if [ -f "${INSTALLED_FILE}" ]; then + while IFS= read -r extension; do + printf '%s\t%s\n' "${extension}" "stub" + done < "${INSTALLED_FILE}" + fi + ;; + extension:install) + extension="${3:-}" + [ -f "${STATE_FILE}" ] + echo "extension-install:${extension}" >> "${LOG_FILE}" + echo "${extension}" >> "${INSTALLED_FILE}" + mkdir -p "${HOME}/.local/share/gh/extensions/${extension##*/}" + ;; + *) + exit 1 + ;; +esac +EOF + +cat > "${BIN_DIR}/git" <<'EOF' +#!/bin/bash +echo git-called >> /tmp/github-cli-extension-auth-test/log +exit 99 +EOF + +chmod +x "${BIN_DIR}/gh" "${BIN_DIR}/git" + +target_username="$(resolve_target_username)" +if [ "${target_username}" != "root" ]; then + chown -R "${target_username}:${target_username}" "${TEST_ROOT}" + su - "${target_username}" -c "PATH=${BIN_DIR}:\$PATH HOME=${HOME_DIR} GH_TOKEN=test-token /usr/local/share/github-cli-auth-on-setup.sh" +else + PATH="${BIN_DIR}:${PATH}" \ + HOME="${HOME_DIR}" \ + GH_TOKEN=test-token \ + /usr/local/share/github-cli-auth-on-setup.sh +fi + +grep -Fxq 'with-token' "${LOG_FILE}" +grep -Fxq 'extension-install:dlvhdr/gh-dash' "${LOG_FILE}" +grep -Fxq 'extension-install:github/gh-aw' "${LOG_FILE}" +grep -Fxq 'test-token' "${TOKEN_FILE}" +test -d "${HOME_DIR}/.local/share/gh/extensions/gh-dash" +test -d "${HOME_DIR}/.local/share/gh/extensions/gh-aw" +test -f "${HOME_DIR}/.config/vscode-dev-containers/github-cli-auth-already-ran" +if grep -Fxq 'git-called' "${LOG_FILE}"; then + echo "git fallback was used unexpectedly" >&2 + exit 1 +fi \ No newline at end of file From f2edbb4b0f37ba744d448841b914098edfdbebe7 Mon Sep 17 00:00:00 2001 From: Emilien Escalle Date: Tue, 23 Jun 2026 16:36:14 +0200 Subject: [PATCH 7/7] fix(github-cli): enhance INSTALL_EXTENSIONS_FROM_GIT variable initialization Signed-off-by: Emilien Escalle --- src/github-cli/devcontainer-feature.json | 2 +- src/github-cli/scripts/install-extensions.sh | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/github-cli/devcontainer-feature.json b/src/github-cli/devcontainer-feature.json index b06cfc05b..f97ad81fc 100644 --- a/src/github-cli/devcontainer-feature.json +++ b/src/github-cli/devcontainer-feature.json @@ -1,6 +1,6 @@ { "id": "github-cli", - "version": "1.3.0", + "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.", diff --git a/src/github-cli/scripts/install-extensions.sh b/src/github-cli/scripts/install-extensions.sh index 7e228c522..c50de07ea 100755 --- a/src/github-cli/scripts/install-extensions.sh +++ b/src/github-cli/scripts/install-extensions.sh @@ -8,7 +8,7 @@ set -euo pipefail EXTENSIONS=${EXTENSIONS:-""} INSTALL_EXTENSIONS=${INSTALL_EXTENSIONS:-"true"} -INSTALL_EXTENSIONS_FROM_GIT=${INSTALL_EXTENSIONS_FROM_GIT:-"false"} +INSTALL_EXTENSIONS_FROM_GIT=${INSTALL_EXTENSIONS_FROM_GIT:-${INSTALLEXTENSIONSFROMGIT:-"false"}} readonly SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" readonly GH_EXTENSION_INSTALLER_SCRIPT="${SCRIPT_DIR}/install-extension-from-gh.sh" readonly GIT_EXTENSION_INSTALLER_SCRIPT="${SCRIPT_DIR}/install-extension-from-git.sh"