diff --git a/.github/workflows/version-resolution.yaml b/.github/workflows/version-resolution.yaml new file mode 100644 index 000000000..c9820e00f --- /dev/null +++ b/.github/workflows/version-resolution.yaml @@ -0,0 +1,41 @@ +name: "CI - Version Resolution Fallbacks" + +on: + push: + branches: + - main + paths: + - ".github/workflows/version-resolution.yaml" + - "scripts/version-resolution.sh" + - "scripts/sync-version-resolution.sh" + - "src/**/install.sh" + - "src/**/scripts/version-resolution.sh" + - "test/_global/version-resolution/**" + pull_request: + paths: + - ".github/workflows/version-resolution.yaml" + - "scripts/version-resolution.sh" + - "scripts/sync-version-resolution.sh" + - "src/**/install.sh" + - "src/**/scripts/version-resolution.sh" + - "test/_global/version-resolution/**" + +jobs: + version-resolution: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + + - name: Check synchronized resolver copies + run: bash scripts/sync-version-resolution.sh --check + + - name: Test fallback resolution + run: bash test/_global/version-resolution/test.sh + + - name: Lint resolver scripts + run: | + shellcheck -e SC2072 \ + scripts/version-resolution.sh \ + scripts/sync-version-resolution.sh \ + test/_global/version-resolution/test.sh \ + test/_global/version-resolution/cases.sh \ No newline at end of file diff --git a/scripts/sync-version-resolution.sh b/scripts/sync-version-resolution.sh new file mode 100644 index 000000000..0851fc371 --- /dev/null +++ b/scripts/sync-version-resolution.sh @@ -0,0 +1,40 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +SOURCE_FILE="${ROOT_DIR}/scripts/version-resolution.sh" +FEATURES=( + copilot-cli + docker-in-docker + docker-outside-of-docker + git-lfs + github-cli + go + kubectl-helm-minikube + nix + node + php + powershell + python + rust + terraform +) + +check_only=false +if [ "${1:-}" = "--check" ]; then + check_only=true +fi + +for feature in "${FEATURES[@]}"; do + target_dir="${ROOT_DIR}/src/${feature}/scripts" + target_file="${target_dir}/version-resolution.sh" + if ${check_only}; then + if ! cmp -s "${SOURCE_FILE}" "${target_file}"; then + echo "${target_file} is not synchronized with ${SOURCE_FILE}." >&2 + exit 1 + fi + else + mkdir -p "${target_dir}" + cp "${SOURCE_FILE}" "${target_file}" + fi +done \ No newline at end of file diff --git a/scripts/version-resolution.sh b/scripts/version-resolution.sh new file mode 100644 index 000000000..d1b9d5f81 --- /dev/null +++ b/scripts/version-resolution.sh @@ -0,0 +1,140 @@ +#!/usr/bin/env bash + +# Resolve version requests from git tags, with a caller-provided fallback resolver. +# The fallback function must print candidate versions, one per line. +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 version_suffix_regex=${6:-""} + local known_good_version=${7:-""} + local fallback_name=${8:-""} + local fallback_function=${9:-""} + + if [ "${requested_version}" = "none" ]; then + return + fi + + local tag_prefix=${prefix#refs/} + tag_prefix=${tag_prefix#tags/} + local normalized_request=${requested_version#"${tag_prefix}"} + + local escaped_separator=${separator//./\.} + local patch_regex="${escaped_separator}[0-9]+" + if [ "${last_part_optional}" = "true" ]; then + patch_regex="(${patch_regex})?" + fi + local version_regex="[0-9]+${escaped_separator}[0-9]+${patch_regex}${version_suffix_regex//./\.}" + + # Fully qualified versions are user assertions. Do not require the network + # to prove that a requested release exists. + if echo "${normalized_request}" | grep -Eq "^${version_regex}$"; then + declare -g "${variable_name}=${normalized_request}" + echo "${variable_name}=${normalized_request}" + return + fi + + local version_list="" + local git_output="" + if git_output="$(git ls-remote --tags "${repository}" 2>/dev/null)"; then + version_list="$(_extract_version_candidates "${git_output}" "${prefix}" "${separator}" "${version_regex}")" + fi + + if [ -z "${version_list}" ]; then + echo "(!) Unable to resolve '${requested_version}' from git tags at ${repository}." >&2 + if [ -z "${fallback_function}" ] && echo "${repository}" | grep -qE '^https://github.com/[^/]+/[^/]+/?$'; then + fallback_name="GitHub REST API" + fallback_function="_github_rest_version_candidates" + fi + if [ -n "${fallback_function}" ]; then + echo "(*) Trying ${fallback_name:-alternate version source}." >&2 + local fallback_output="" + if fallback_output="$(${fallback_function} "${repository}" "${prefix}" 2>/dev/null)"; then + version_list="$(_normalize_version_candidates "${fallback_output}" "${separator}" "${version_regex}")" + fi + if [ -n "${version_list}" ]; then + echo "(*) Resolved version using ${fallback_name:-alternate version source}." >&2 + fi + fi + fi + + local resolved_version="" + resolved_version="$(_select_requested_version "${normalized_request}" "${version_list}")" + if [ -z "${resolved_version}" ] && [ -n "${known_good_version}" ] && _version_matches_request "${normalized_request}" "${known_good_version}"; then + resolved_version="${known_good_version}" + echo "(!) Dynamic version resolution failed; using known-good version ${known_good_version}." >&2 + fi + + if [ -z "${resolved_version}" ]; then + echo "(!) Unable to resolve '${requested_version}' for ${variable_name}. Tried git tags at ${repository}${fallback_name:+ and ${fallback_name}}. Specify an exact version to avoid remote resolution." >&2 + return 1 + fi + + declare -g "${variable_name}=${resolved_version}" + echo "${variable_name}=${resolved_version}" +} + +_github_rest_version_candidates() { + local repository=$1 + local prefix=${2:-"tags/v"} + local slug=${repository#https://github.com/} + slug=${slug%/} + local tag_prefix=${prefix#refs/} + tag_prefix=${tag_prefix#tags/} + + curl -fsSL "https://api.github.com/repos/${slug}/tags?per_page=100" \ + | grep -oE '"name"[[:space:]]*:[[:space:]]*"[^"]+"' \ + | sed -E 's/^.*"([^"]+)"$/\1/' \ + | sed "s|^${tag_prefix}||" +} + +_extract_version_candidates() { + local input=$1 + local prefix=$2 + local separator=$3 + local version_regex=$4 + local escaped_prefix=${prefix//./\.} + + printf '%s\n' "${input}" \ + | grep -oE "${escaped_prefix}${version_regex}$" \ + | sed "s|^${prefix}||" \ + | tr "${separator}" "." \ + | sort -rVu || true +} + +_normalize_version_candidates() { + local input=$1 + local separator=$2 + local version_regex=$3 + + printf '%s\n' "${input}" \ + | grep -oE "^${version_regex}$" \ + | tr "${separator}" "." \ + | sort -rVu || true +} + +_select_requested_version() { + local requested_version=$1 + local version_list=$2 + + if [ "${requested_version}" = "latest" ] || [ "${requested_version}" = "current" ] || [ "${requested_version}" = "stable" ] || [ "${requested_version}" = "lts" ] || [ "${requested_version}" = "prerelease" ] || [ "${requested_version}" = "preview" ]; then + printf '%s\n' "${version_list}" | head -n 1 + return + fi + + printf '%s\n' "${version_list}" | grep -E -m 1 "^${requested_version//./\.}([.]|[-]|$)" || true +} + +_version_matches_request() { + local requested_version=$1 + local candidate=$2 + + if [ "${requested_version}" = "latest" ] || [ "${requested_version}" = "current" ] || [ "${requested_version}" = "stable" ] || [ "${requested_version}" = "lts" ] || [ "${requested_version}" = "prerelease" ] || [ "${requested_version}" = "preview" ]; then + return 0 + fi + + echo "${candidate}" | grep -Eq "^${requested_version//./\.}([.]|[-]|$)" +} \ No newline at end of file diff --git a/src/copilot-cli/devcontainer-feature.json b/src/copilot-cli/devcontainer-feature.json index f4db33ced..ee16a60e8 100644 --- a/src/copilot-cli/devcontainer-feature.json +++ b/src/copilot-cli/devcontainer-feature.json @@ -1,6 +1,6 @@ { "id": "copilot-cli", - "version": "1.1.3", + "version": "1.1.4", "name": "GitHub Copilot CLI", "documentationURL": "https://github.com/devcontainers/features/tree/main/src/copilot-cli", "description": "Installs the GitHub Copilot CLI.", diff --git a/src/copilot-cli/install.sh b/src/copilot-cli/install.sh index ad4a19701..f507ae8e3 100755 --- a/src/copilot-cli/install.sh +++ b/src/copilot-cli/install.sh @@ -8,6 +8,8 @@ # Maintainer: The VS Code and Codespaces Teams CLI_VERSION=${VERSION:-"latest"} +REQUESTED_CLI_VERSION="${CLI_VERSION}" +COPILOT_CLI_LAST_KNOWN_VERSION="1.0.87-0" set -e @@ -31,13 +33,7 @@ check_packages() { fi } -resolve_prerelease_version() { - local repo_versions="${1:?resolve_prerelease_version requires the copilot-cli repo tags as input}" - printf '%s\n' "${repo_versions}" \ - | awk '{print $2}' | sed 's|refs/tags/||' \ - | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+(-[0-9]+)?$' \ - | sort -V | tail -n1 -} +. "$(dirname "${BASH_SOURCE[0]}")/scripts/version-resolution.sh" download_from_github() { local release_url=$1 @@ -71,9 +67,8 @@ install_using_github() { if [ "${CLI_VERSION}" = "latest" ]; then download_from_github "https://github.com/github/copilot-cli/releases/latest/download/${cli_filename}" elif [ "${CLI_VERSION}" = "prerelease" ]; then - - prerelease_version="$(resolve_prerelease_version "$(git ls-remote --tags https://github.com/github/copilot-cli)")" - download_from_github "https://github.com/github/copilot-cli/releases/download/${prerelease_version}/${cli_filename}" + find_version_from_git_tags CLI_VERSION "https://github.com/github/copilot-cli" "tags/v" "." "false" "(-[0-9]+)" "${COPILOT_CLI_LAST_KNOWN_VERSION}" + download_from_github "https://github.com/github/copilot-cli/releases/download/v${CLI_VERSION}/${cli_filename}" else # Install specific version @@ -91,7 +86,7 @@ echo "Downloading GitHub Copilot CLI..." install_using_github # Create a flag file if using "latest" or "prerelease" so the postStartCommand knows to auto-update -if [ "${CLI_VERSION}" = "latest" ] || [ "${CLI_VERSION}" = "prerelease" ]; then +if [ "${REQUESTED_CLI_VERSION}" = "latest" ] || [ "${REQUESTED_CLI_VERSION}" = "prerelease" ]; then mkdir -p /etc/devcontainer-copilot-cli touch /etc/devcontainer-copilot-cli/auto-update fi diff --git a/src/copilot-cli/scripts/version-resolution.sh b/src/copilot-cli/scripts/version-resolution.sh new file mode 100644 index 000000000..d1b9d5f81 --- /dev/null +++ b/src/copilot-cli/scripts/version-resolution.sh @@ -0,0 +1,140 @@ +#!/usr/bin/env bash + +# Resolve version requests from git tags, with a caller-provided fallback resolver. +# The fallback function must print candidate versions, one per line. +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 version_suffix_regex=${6:-""} + local known_good_version=${7:-""} + local fallback_name=${8:-""} + local fallback_function=${9:-""} + + if [ "${requested_version}" = "none" ]; then + return + fi + + local tag_prefix=${prefix#refs/} + tag_prefix=${tag_prefix#tags/} + local normalized_request=${requested_version#"${tag_prefix}"} + + local escaped_separator=${separator//./\.} + local patch_regex="${escaped_separator}[0-9]+" + if [ "${last_part_optional}" = "true" ]; then + patch_regex="(${patch_regex})?" + fi + local version_regex="[0-9]+${escaped_separator}[0-9]+${patch_regex}${version_suffix_regex//./\.}" + + # Fully qualified versions are user assertions. Do not require the network + # to prove that a requested release exists. + if echo "${normalized_request}" | grep -Eq "^${version_regex}$"; then + declare -g "${variable_name}=${normalized_request}" + echo "${variable_name}=${normalized_request}" + return + fi + + local version_list="" + local git_output="" + if git_output="$(git ls-remote --tags "${repository}" 2>/dev/null)"; then + version_list="$(_extract_version_candidates "${git_output}" "${prefix}" "${separator}" "${version_regex}")" + fi + + if [ -z "${version_list}" ]; then + echo "(!) Unable to resolve '${requested_version}' from git tags at ${repository}." >&2 + if [ -z "${fallback_function}" ] && echo "${repository}" | grep -qE '^https://github.com/[^/]+/[^/]+/?$'; then + fallback_name="GitHub REST API" + fallback_function="_github_rest_version_candidates" + fi + if [ -n "${fallback_function}" ]; then + echo "(*) Trying ${fallback_name:-alternate version source}." >&2 + local fallback_output="" + if fallback_output="$(${fallback_function} "${repository}" "${prefix}" 2>/dev/null)"; then + version_list="$(_normalize_version_candidates "${fallback_output}" "${separator}" "${version_regex}")" + fi + if [ -n "${version_list}" ]; then + echo "(*) Resolved version using ${fallback_name:-alternate version source}." >&2 + fi + fi + fi + + local resolved_version="" + resolved_version="$(_select_requested_version "${normalized_request}" "${version_list}")" + if [ -z "${resolved_version}" ] && [ -n "${known_good_version}" ] && _version_matches_request "${normalized_request}" "${known_good_version}"; then + resolved_version="${known_good_version}" + echo "(!) Dynamic version resolution failed; using known-good version ${known_good_version}." >&2 + fi + + if [ -z "${resolved_version}" ]; then + echo "(!) Unable to resolve '${requested_version}' for ${variable_name}. Tried git tags at ${repository}${fallback_name:+ and ${fallback_name}}. Specify an exact version to avoid remote resolution." >&2 + return 1 + fi + + declare -g "${variable_name}=${resolved_version}" + echo "${variable_name}=${resolved_version}" +} + +_github_rest_version_candidates() { + local repository=$1 + local prefix=${2:-"tags/v"} + local slug=${repository#https://github.com/} + slug=${slug%/} + local tag_prefix=${prefix#refs/} + tag_prefix=${tag_prefix#tags/} + + curl -fsSL "https://api.github.com/repos/${slug}/tags?per_page=100" \ + | grep -oE '"name"[[:space:]]*:[[:space:]]*"[^"]+"' \ + | sed -E 's/^.*"([^"]+)"$/\1/' \ + | sed "s|^${tag_prefix}||" +} + +_extract_version_candidates() { + local input=$1 + local prefix=$2 + local separator=$3 + local version_regex=$4 + local escaped_prefix=${prefix//./\.} + + printf '%s\n' "${input}" \ + | grep -oE "${escaped_prefix}${version_regex}$" \ + | sed "s|^${prefix}||" \ + | tr "${separator}" "." \ + | sort -rVu || true +} + +_normalize_version_candidates() { + local input=$1 + local separator=$2 + local version_regex=$3 + + printf '%s\n' "${input}" \ + | grep -oE "^${version_regex}$" \ + | tr "${separator}" "." \ + | sort -rVu || true +} + +_select_requested_version() { + local requested_version=$1 + local version_list=$2 + + if [ "${requested_version}" = "latest" ] || [ "${requested_version}" = "current" ] || [ "${requested_version}" = "stable" ] || [ "${requested_version}" = "lts" ] || [ "${requested_version}" = "prerelease" ] || [ "${requested_version}" = "preview" ]; then + printf '%s\n' "${version_list}" | head -n 1 + return + fi + + printf '%s\n' "${version_list}" | grep -E -m 1 "^${requested_version//./\.}([.]|[-]|$)" || true +} + +_version_matches_request() { + local requested_version=$1 + local candidate=$2 + + if [ "${requested_version}" = "latest" ] || [ "${requested_version}" = "current" ] || [ "${requested_version}" = "stable" ] || [ "${requested_version}" = "lts" ] || [ "${requested_version}" = "prerelease" ] || [ "${requested_version}" = "preview" ]; then + return 0 + fi + + echo "${candidate}" | grep -Eq "^${requested_version//./\.}([.]|[-]|$)" +} \ No newline at end of file diff --git a/src/docker-in-docker/devcontainer-feature.json b/src/docker-in-docker/devcontainer-feature.json index 2325a19cd..c76df9661 100644 --- a/src/docker-in-docker/devcontainer-feature.json +++ b/src/docker-in-docker/devcontainer-feature.json @@ -1,6 +1,6 @@ { "id": "docker-in-docker", - "version": "4.1.1", + "version": "4.1.2", "name": "Docker (Docker-in-Docker)", "documentationURL": "https://github.com/devcontainers/features/tree/main/src/docker-in-docker", "description": "Create child containers *inside* a container, independent from the host's docker instance. Installs Docker extension in the container along with needed CLIs.", diff --git a/src/docker-in-docker/install.sh b/src/docker-in-docker/install.sh index cd25b2fb8..af45b197b 100755 --- a/src/docker-in-docker/install.sh +++ b/src/docker-in-docker/install.sh @@ -12,6 +12,9 @@ DOCKER_VERSION="${VERSION:-"latest"}" # The Docker/Moby Engine + CLI should matc USE_MOBY="${MOBY:-"true"}" MOBY_BUILDX_VERSION="${MOBYBUILDXVERSION:-"latest"}" DOCKER_DASH_COMPOSE_VERSION="${DOCKERDASHCOMPOSEVERSION:-"latest"}" #v1, v2, latest or none +DOCKER_COMPOSE_LAST_KNOWN_VERSION="5.5.1" +DOCKER_COMPOSE_SWITCH_LAST_KNOWN_VERSION="1.0.5" +DOCKER_BUILDX_LAST_KNOWN_VERSION="0.37.1" AZURE_DNS_AUTO_DETECTION="${AZUREDNSAUTODETECTION:-"true"}" DOCKER_DEFAULT_ADDRESS_POOL="${DOCKERDEFAULTADDRESSPOOL:-""}" DOCKER_HOST_GATEWAY_IP="${DOCKERHOSTGATEWAYIP:-""}" @@ -209,6 +212,8 @@ get_github_api_repo_url() { echo "${url/https:\/\/github.com/https:\/\/api.github.com\/repos}/releases" } +. "$(dirname "${BASH_SOURCE[0]}")/scripts/version-resolution.sh" + ########################################### # Start docker-in-docker installation ########################################### @@ -770,7 +775,7 @@ if [ "${DOCKER_DASH_COMPOSE_VERSION}" != "none" ]; then else compose_version=${DOCKER_DASH_COMPOSE_VERSION#v} docker_compose_url="https://github.com/docker/compose" - find_version_from_git_tags compose_version "$docker_compose_url" "tags/v" + find_version_from_git_tags compose_version "$docker_compose_url" "tags/v" "." "false" "" "${DOCKER_COMPOSE_LAST_KNOWN_VERSION}" echo "(*) Installing docker-compose ${compose_version}..." curl -fsSL "https://github.com/docker/compose/releases/download/v${compose_version}/docker-compose-linux-${target_compose_arch}" -o ${docker_compose_path} || { echo -e "\n(!) Failed to fetch the latest artifacts for docker-compose v${compose_version}..." @@ -808,7 +813,7 @@ if [ "${INSTALL_DOCKER_COMPOSE_SWITCH}" = "true" ] && ! type compose-switch > /d compose_switch_url="https://github.com/docker/compose-switch" # Try to get latest version, fallback to known stable version if GitHub API fails set +e - find_version_from_git_tags compose_switch_version "$compose_switch_url" + find_version_from_git_tags compose_switch_version "$compose_switch_url" "tags/v" "." "false" "" "${DOCKER_COMPOSE_SWITCH_LAST_KNOWN_VERSION}" if [ $? -ne 0 ] || [ -z "${compose_switch_version}" ] || [ "${compose_switch_version}" = "latest" ]; then echo "(*) GitHub API rate limited or failed, using fallback method" fallback_compose-switch "$compose_switch_url" @@ -862,7 +867,7 @@ fallback_buildx() { if [ "${INSTALL_DOCKER_BUILDX}" = "true" ]; then buildx_version="latest" docker_buildx_url="https://github.com/docker/buildx" - find_version_from_git_tags buildx_version "$docker_buildx_url" "refs/tags/v" + find_version_from_git_tags buildx_version "$docker_buildx_url" "refs/tags/v" "." "false" "" "${DOCKER_BUILDX_LAST_KNOWN_VERSION}" echo "(*) Installing buildx ${buildx_version}..." # Map architecture for buildx downloads diff --git a/src/docker-in-docker/scripts/version-resolution.sh b/src/docker-in-docker/scripts/version-resolution.sh new file mode 100644 index 000000000..d1b9d5f81 --- /dev/null +++ b/src/docker-in-docker/scripts/version-resolution.sh @@ -0,0 +1,140 @@ +#!/usr/bin/env bash + +# Resolve version requests from git tags, with a caller-provided fallback resolver. +# The fallback function must print candidate versions, one per line. +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 version_suffix_regex=${6:-""} + local known_good_version=${7:-""} + local fallback_name=${8:-""} + local fallback_function=${9:-""} + + if [ "${requested_version}" = "none" ]; then + return + fi + + local tag_prefix=${prefix#refs/} + tag_prefix=${tag_prefix#tags/} + local normalized_request=${requested_version#"${tag_prefix}"} + + local escaped_separator=${separator//./\.} + local patch_regex="${escaped_separator}[0-9]+" + if [ "${last_part_optional}" = "true" ]; then + patch_regex="(${patch_regex})?" + fi + local version_regex="[0-9]+${escaped_separator}[0-9]+${patch_regex}${version_suffix_regex//./\.}" + + # Fully qualified versions are user assertions. Do not require the network + # to prove that a requested release exists. + if echo "${normalized_request}" | grep -Eq "^${version_regex}$"; then + declare -g "${variable_name}=${normalized_request}" + echo "${variable_name}=${normalized_request}" + return + fi + + local version_list="" + local git_output="" + if git_output="$(git ls-remote --tags "${repository}" 2>/dev/null)"; then + version_list="$(_extract_version_candidates "${git_output}" "${prefix}" "${separator}" "${version_regex}")" + fi + + if [ -z "${version_list}" ]; then + echo "(!) Unable to resolve '${requested_version}' from git tags at ${repository}." >&2 + if [ -z "${fallback_function}" ] && echo "${repository}" | grep -qE '^https://github.com/[^/]+/[^/]+/?$'; then + fallback_name="GitHub REST API" + fallback_function="_github_rest_version_candidates" + fi + if [ -n "${fallback_function}" ]; then + echo "(*) Trying ${fallback_name:-alternate version source}." >&2 + local fallback_output="" + if fallback_output="$(${fallback_function} "${repository}" "${prefix}" 2>/dev/null)"; then + version_list="$(_normalize_version_candidates "${fallback_output}" "${separator}" "${version_regex}")" + fi + if [ -n "${version_list}" ]; then + echo "(*) Resolved version using ${fallback_name:-alternate version source}." >&2 + fi + fi + fi + + local resolved_version="" + resolved_version="$(_select_requested_version "${normalized_request}" "${version_list}")" + if [ -z "${resolved_version}" ] && [ -n "${known_good_version}" ] && _version_matches_request "${normalized_request}" "${known_good_version}"; then + resolved_version="${known_good_version}" + echo "(!) Dynamic version resolution failed; using known-good version ${known_good_version}." >&2 + fi + + if [ -z "${resolved_version}" ]; then + echo "(!) Unable to resolve '${requested_version}' for ${variable_name}. Tried git tags at ${repository}${fallback_name:+ and ${fallback_name}}. Specify an exact version to avoid remote resolution." >&2 + return 1 + fi + + declare -g "${variable_name}=${resolved_version}" + echo "${variable_name}=${resolved_version}" +} + +_github_rest_version_candidates() { + local repository=$1 + local prefix=${2:-"tags/v"} + local slug=${repository#https://github.com/} + slug=${slug%/} + local tag_prefix=${prefix#refs/} + tag_prefix=${tag_prefix#tags/} + + curl -fsSL "https://api.github.com/repos/${slug}/tags?per_page=100" \ + | grep -oE '"name"[[:space:]]*:[[:space:]]*"[^"]+"' \ + | sed -E 's/^.*"([^"]+)"$/\1/' \ + | sed "s|^${tag_prefix}||" +} + +_extract_version_candidates() { + local input=$1 + local prefix=$2 + local separator=$3 + local version_regex=$4 + local escaped_prefix=${prefix//./\.} + + printf '%s\n' "${input}" \ + | grep -oE "${escaped_prefix}${version_regex}$" \ + | sed "s|^${prefix}||" \ + | tr "${separator}" "." \ + | sort -rVu || true +} + +_normalize_version_candidates() { + local input=$1 + local separator=$2 + local version_regex=$3 + + printf '%s\n' "${input}" \ + | grep -oE "^${version_regex}$" \ + | tr "${separator}" "." \ + | sort -rVu || true +} + +_select_requested_version() { + local requested_version=$1 + local version_list=$2 + + if [ "${requested_version}" = "latest" ] || [ "${requested_version}" = "current" ] || [ "${requested_version}" = "stable" ] || [ "${requested_version}" = "lts" ] || [ "${requested_version}" = "prerelease" ] || [ "${requested_version}" = "preview" ]; then + printf '%s\n' "${version_list}" | head -n 1 + return + fi + + printf '%s\n' "${version_list}" | grep -E -m 1 "^${requested_version//./\.}([.]|[-]|$)" || true +} + +_version_matches_request() { + local requested_version=$1 + local candidate=$2 + + if [ "${requested_version}" = "latest" ] || [ "${requested_version}" = "current" ] || [ "${requested_version}" = "stable" ] || [ "${requested_version}" = "lts" ] || [ "${requested_version}" = "prerelease" ] || [ "${requested_version}" = "preview" ]; then + return 0 + fi + + echo "${candidate}" | grep -Eq "^${requested_version//./\.}([.]|[-]|$)" +} \ No newline at end of file diff --git a/src/docker-outside-of-docker/devcontainer-feature.json b/src/docker-outside-of-docker/devcontainer-feature.json index 9ab361cd6..7ebbd4783 100644 --- a/src/docker-outside-of-docker/devcontainer-feature.json +++ b/src/docker-outside-of-docker/devcontainer-feature.json @@ -1,7 +1,7 @@ { "id": "docker-outside-of-docker", - "version": "1.10.1", + "version": "1.10.2", "name": "Docker (docker-outside-of-docker)", "documentationURL": "https://github.com/devcontainers/features/tree/main/src/docker-outside-of-docker", "description": "Re-use the host docker socket, adding the Docker CLI to a container. Feature invokes a script to enable using a forwarded Docker socket within a container to run Docker commands.", diff --git a/src/docker-outside-of-docker/install.sh b/src/docker-outside-of-docker/install.sh index 2a1515565..b883227c0 100755 --- a/src/docker-outside-of-docker/install.sh +++ b/src/docker-outside-of-docker/install.sh @@ -11,6 +11,8 @@ DOCKER_VERSION="${VERSION:-"latest"}" USE_MOBY="${MOBY:-"true"}" MOBY_BUILDX_VERSION="${MOBYBUILDXVERSION:-"latest"}" DOCKER_DASH_COMPOSE_VERSION="${DOCKERDASHCOMPOSEVERSION:-"latest"}" # v1 or v2 or none or latest +DOCKER_COMPOSE_LAST_KNOWN_VERSION="5.5.1" +DOCKER_COMPOSE_SWITCH_LAST_KNOWN_VERSION="1.0.5" ENABLE_NONROOT_DOCKER="${ENABLE_NONROOT_DOCKER:-"true"}" SOCKET_PATH="${SOCKETPATH:-"/var/run/docker-host.sock"}" # From feature option @@ -179,6 +181,8 @@ get_github_api_repo_url() { echo "${url/https:\/\/github.com/https:\/\/api.github.com\/repos}/releases" } +. "$(dirname "${BASH_SOURCE[0]}")/scripts/version-resolution.sh" + install_compose_switch_fallback() { compose_switch_url=$1 repo_url=$(get_github_api_repo_url "${compose_switch_url}") @@ -383,7 +387,7 @@ if [ "${DOCKER_DASH_COMPOSE_VERSION}" != "none" ]; then else compose_version=${DOCKER_DASH_COMPOSE_VERSION#v} docker_compose_url="https://github.com/docker/compose" - find_version_from_git_tags compose_version "$docker_compose_url" "tags/v" + find_version_from_git_tags compose_version "$docker_compose_url" "tags/v" "." "false" "" "${DOCKER_COMPOSE_LAST_KNOWN_VERSION}" echo "(*) Installing docker-compose ${compose_version}..." curl -fsSL "https://github.com/docker/compose/releases/download/v${compose_version}/docker-compose-linux-${target_compose_arch}" -o ${docker_compose_path} || { install_compose_fallback "$docker_compose_url" "$compose_version" "$target_compose_arch" "$docker_compose_path" @@ -408,7 +412,7 @@ if [ "${INSTALL_DOCKER_COMPOSE_SWITCH}" = "true" ] && ! type compose-switch > /d target_compose_path="$(dirname "${current_compose_path}")/docker-compose-v1" compose_switch_version="latest" compose_switch_url="https://github.com/docker/compose-switch" - find_version_from_git_tags compose_switch_version "${compose_switch_url}" + find_version_from_git_tags compose_switch_version "${compose_switch_url}" "tags/v" "." "false" "" "${DOCKER_COMPOSE_SWITCH_LAST_KNOWN_VERSION}" curl -fsSL "https://github.com/docker/compose-switch/releases/download/v${compose_switch_version}/docker-compose-linux-${architecture}" -o /usr/local/bin/compose-switch || install_compose_switch_fallback "${compose_switch_url}" chmod +x /usr/local/bin/compose-switch # TODO: Verify checksum once available: https://github.com/docker/compose-switch/issues/11 diff --git a/src/docker-outside-of-docker/scripts/version-resolution.sh b/src/docker-outside-of-docker/scripts/version-resolution.sh new file mode 100644 index 000000000..d1b9d5f81 --- /dev/null +++ b/src/docker-outside-of-docker/scripts/version-resolution.sh @@ -0,0 +1,140 @@ +#!/usr/bin/env bash + +# Resolve version requests from git tags, with a caller-provided fallback resolver. +# The fallback function must print candidate versions, one per line. +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 version_suffix_regex=${6:-""} + local known_good_version=${7:-""} + local fallback_name=${8:-""} + local fallback_function=${9:-""} + + if [ "${requested_version}" = "none" ]; then + return + fi + + local tag_prefix=${prefix#refs/} + tag_prefix=${tag_prefix#tags/} + local normalized_request=${requested_version#"${tag_prefix}"} + + local escaped_separator=${separator//./\.} + local patch_regex="${escaped_separator}[0-9]+" + if [ "${last_part_optional}" = "true" ]; then + patch_regex="(${patch_regex})?" + fi + local version_regex="[0-9]+${escaped_separator}[0-9]+${patch_regex}${version_suffix_regex//./\.}" + + # Fully qualified versions are user assertions. Do not require the network + # to prove that a requested release exists. + if echo "${normalized_request}" | grep -Eq "^${version_regex}$"; then + declare -g "${variable_name}=${normalized_request}" + echo "${variable_name}=${normalized_request}" + return + fi + + local version_list="" + local git_output="" + if git_output="$(git ls-remote --tags "${repository}" 2>/dev/null)"; then + version_list="$(_extract_version_candidates "${git_output}" "${prefix}" "${separator}" "${version_regex}")" + fi + + if [ -z "${version_list}" ]; then + echo "(!) Unable to resolve '${requested_version}' from git tags at ${repository}." >&2 + if [ -z "${fallback_function}" ] && echo "${repository}" | grep -qE '^https://github.com/[^/]+/[^/]+/?$'; then + fallback_name="GitHub REST API" + fallback_function="_github_rest_version_candidates" + fi + if [ -n "${fallback_function}" ]; then + echo "(*) Trying ${fallback_name:-alternate version source}." >&2 + local fallback_output="" + if fallback_output="$(${fallback_function} "${repository}" "${prefix}" 2>/dev/null)"; then + version_list="$(_normalize_version_candidates "${fallback_output}" "${separator}" "${version_regex}")" + fi + if [ -n "${version_list}" ]; then + echo "(*) Resolved version using ${fallback_name:-alternate version source}." >&2 + fi + fi + fi + + local resolved_version="" + resolved_version="$(_select_requested_version "${normalized_request}" "${version_list}")" + if [ -z "${resolved_version}" ] && [ -n "${known_good_version}" ] && _version_matches_request "${normalized_request}" "${known_good_version}"; then + resolved_version="${known_good_version}" + echo "(!) Dynamic version resolution failed; using known-good version ${known_good_version}." >&2 + fi + + if [ -z "${resolved_version}" ]; then + echo "(!) Unable to resolve '${requested_version}' for ${variable_name}. Tried git tags at ${repository}${fallback_name:+ and ${fallback_name}}. Specify an exact version to avoid remote resolution." >&2 + return 1 + fi + + declare -g "${variable_name}=${resolved_version}" + echo "${variable_name}=${resolved_version}" +} + +_github_rest_version_candidates() { + local repository=$1 + local prefix=${2:-"tags/v"} + local slug=${repository#https://github.com/} + slug=${slug%/} + local tag_prefix=${prefix#refs/} + tag_prefix=${tag_prefix#tags/} + + curl -fsSL "https://api.github.com/repos/${slug}/tags?per_page=100" \ + | grep -oE '"name"[[:space:]]*:[[:space:]]*"[^"]+"' \ + | sed -E 's/^.*"([^"]+)"$/\1/' \ + | sed "s|^${tag_prefix}||" +} + +_extract_version_candidates() { + local input=$1 + local prefix=$2 + local separator=$3 + local version_regex=$4 + local escaped_prefix=${prefix//./\.} + + printf '%s\n' "${input}" \ + | grep -oE "${escaped_prefix}${version_regex}$" \ + | sed "s|^${prefix}||" \ + | tr "${separator}" "." \ + | sort -rVu || true +} + +_normalize_version_candidates() { + local input=$1 + local separator=$2 + local version_regex=$3 + + printf '%s\n' "${input}" \ + | grep -oE "^${version_regex}$" \ + | tr "${separator}" "." \ + | sort -rVu || true +} + +_select_requested_version() { + local requested_version=$1 + local version_list=$2 + + if [ "${requested_version}" = "latest" ] || [ "${requested_version}" = "current" ] || [ "${requested_version}" = "stable" ] || [ "${requested_version}" = "lts" ] || [ "${requested_version}" = "prerelease" ] || [ "${requested_version}" = "preview" ]; then + printf '%s\n' "${version_list}" | head -n 1 + return + fi + + printf '%s\n' "${version_list}" | grep -E -m 1 "^${requested_version//./\.}([.]|[-]|$)" || true +} + +_version_matches_request() { + local requested_version=$1 + local candidate=$2 + + if [ "${requested_version}" = "latest" ] || [ "${requested_version}" = "current" ] || [ "${requested_version}" = "stable" ] || [ "${requested_version}" = "lts" ] || [ "${requested_version}" = "prerelease" ] || [ "${requested_version}" = "preview" ]; then + return 0 + fi + + echo "${candidate}" | grep -Eq "^${requested_version//./\.}([.]|[-]|$)" +} \ No newline at end of file diff --git a/src/git-lfs/devcontainer-feature.json b/src/git-lfs/devcontainer-feature.json index fe23e02cf..a8d65b714 100644 --- a/src/git-lfs/devcontainer-feature.json +++ b/src/git-lfs/devcontainer-feature.json @@ -1,6 +1,6 @@ { "id": "git-lfs", - "version": "1.2.5", + "version": "1.2.6", "name": "Git Large File Support (LFS)", "documentationURL": "https://github.com/devcontainers/features/tree/main/src/git-lfs", "description": "Installs Git Large File Support (Git LFS) along with needed dependencies. Useful for base Dockerfiles that often are missing required install dependencies like git and curl.", diff --git a/src/git-lfs/install.sh b/src/git-lfs/install.sh index 71066c7b3..f806f1258 100755 --- a/src/git-lfs/install.sh +++ b/src/git-lfs/install.sh @@ -10,6 +10,7 @@ GIT_LFS_VERSION=${VERSION:-"latest"} AUTO_PULL=${AUTOPULL:="true"} INSTALL_WITH_GITHUB=${INSTALLDIRECTLYFROMGITHUBRELEASE:="false"} +GIT_LFS_LAST_KNOWN_VERSION="3.8.0" GIT_LFS_ARCHIVE_GPG_KEY_URI="https://packagecloud.io/github/git-lfs/gpgkey" GIT_LFS_ARCHIVE_ARCHITECTURES="amd64 arm64" @@ -150,7 +151,7 @@ check_packages() { install_using_apt() { # Soft version matching if [ "${GIT_LFS_VERSION}" != "latest" ] && [ "${GIT_LFS_VERSION}" != "lts" ] && [ "${GIT_LFS_VERSION}" != "stable" ]; then - find_version_from_git_tags GIT_LFS_VERSION "https://github.com/git-lfs/git-lfs" + find_version_from_git_tags GIT_LFS_VERSION "https://github.com/git-lfs/git-lfs" "tags/v" "." "false" "" "${GIT_LFS_LAST_KNOWN_VERSION}" version_suffix="=${GIT_LFS_VERSION}" else version_suffix="" @@ -184,7 +185,7 @@ install_using_github() { echo "(*) No apt package for ${VERSION_CODENAME} ${architecture}. Installing manually." mkdir -p /tmp/git-lfs cd /tmp/git-lfs - find_version_from_git_tags GIT_LFS_VERSION "https://github.com/git-lfs/git-lfs" + find_version_from_git_tags GIT_LFS_VERSION "https://github.com/git-lfs/git-lfs" "tags/v" "." "false" "" "${GIT_LFS_LAST_KNOWN_VERSION}" install_from_release if grep -q "Not Found" "${git_lfs_filename}"; then @@ -219,6 +220,8 @@ install_using_github() { rm -rf /tmp/git-lfs /tmp/tmp-gnupg } +. "$(dirname "${BASH_SOURCE[0]}")/scripts/version-resolution.sh" + export DEBIAN_FRONTEND=noninteractive # Install git, curl, gpg, dirmngr and debian-archive-keyring if missing diff --git a/src/git-lfs/scripts/version-resolution.sh b/src/git-lfs/scripts/version-resolution.sh new file mode 100644 index 000000000..d1b9d5f81 --- /dev/null +++ b/src/git-lfs/scripts/version-resolution.sh @@ -0,0 +1,140 @@ +#!/usr/bin/env bash + +# Resolve version requests from git tags, with a caller-provided fallback resolver. +# The fallback function must print candidate versions, one per line. +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 version_suffix_regex=${6:-""} + local known_good_version=${7:-""} + local fallback_name=${8:-""} + local fallback_function=${9:-""} + + if [ "${requested_version}" = "none" ]; then + return + fi + + local tag_prefix=${prefix#refs/} + tag_prefix=${tag_prefix#tags/} + local normalized_request=${requested_version#"${tag_prefix}"} + + local escaped_separator=${separator//./\.} + local patch_regex="${escaped_separator}[0-9]+" + if [ "${last_part_optional}" = "true" ]; then + patch_regex="(${patch_regex})?" + fi + local version_regex="[0-9]+${escaped_separator}[0-9]+${patch_regex}${version_suffix_regex//./\.}" + + # Fully qualified versions are user assertions. Do not require the network + # to prove that a requested release exists. + if echo "${normalized_request}" | grep -Eq "^${version_regex}$"; then + declare -g "${variable_name}=${normalized_request}" + echo "${variable_name}=${normalized_request}" + return + fi + + local version_list="" + local git_output="" + if git_output="$(git ls-remote --tags "${repository}" 2>/dev/null)"; then + version_list="$(_extract_version_candidates "${git_output}" "${prefix}" "${separator}" "${version_regex}")" + fi + + if [ -z "${version_list}" ]; then + echo "(!) Unable to resolve '${requested_version}' from git tags at ${repository}." >&2 + if [ -z "${fallback_function}" ] && echo "${repository}" | grep -qE '^https://github.com/[^/]+/[^/]+/?$'; then + fallback_name="GitHub REST API" + fallback_function="_github_rest_version_candidates" + fi + if [ -n "${fallback_function}" ]; then + echo "(*) Trying ${fallback_name:-alternate version source}." >&2 + local fallback_output="" + if fallback_output="$(${fallback_function} "${repository}" "${prefix}" 2>/dev/null)"; then + version_list="$(_normalize_version_candidates "${fallback_output}" "${separator}" "${version_regex}")" + fi + if [ -n "${version_list}" ]; then + echo "(*) Resolved version using ${fallback_name:-alternate version source}." >&2 + fi + fi + fi + + local resolved_version="" + resolved_version="$(_select_requested_version "${normalized_request}" "${version_list}")" + if [ -z "${resolved_version}" ] && [ -n "${known_good_version}" ] && _version_matches_request "${normalized_request}" "${known_good_version}"; then + resolved_version="${known_good_version}" + echo "(!) Dynamic version resolution failed; using known-good version ${known_good_version}." >&2 + fi + + if [ -z "${resolved_version}" ]; then + echo "(!) Unable to resolve '${requested_version}' for ${variable_name}. Tried git tags at ${repository}${fallback_name:+ and ${fallback_name}}. Specify an exact version to avoid remote resolution." >&2 + return 1 + fi + + declare -g "${variable_name}=${resolved_version}" + echo "${variable_name}=${resolved_version}" +} + +_github_rest_version_candidates() { + local repository=$1 + local prefix=${2:-"tags/v"} + local slug=${repository#https://github.com/} + slug=${slug%/} + local tag_prefix=${prefix#refs/} + tag_prefix=${tag_prefix#tags/} + + curl -fsSL "https://api.github.com/repos/${slug}/tags?per_page=100" \ + | grep -oE '"name"[[:space:]]*:[[:space:]]*"[^"]+"' \ + | sed -E 's/^.*"([^"]+)"$/\1/' \ + | sed "s|^${tag_prefix}||" +} + +_extract_version_candidates() { + local input=$1 + local prefix=$2 + local separator=$3 + local version_regex=$4 + local escaped_prefix=${prefix//./\.} + + printf '%s\n' "${input}" \ + | grep -oE "${escaped_prefix}${version_regex}$" \ + | sed "s|^${prefix}||" \ + | tr "${separator}" "." \ + | sort -rVu || true +} + +_normalize_version_candidates() { + local input=$1 + local separator=$2 + local version_regex=$3 + + printf '%s\n' "${input}" \ + | grep -oE "^${version_regex}$" \ + | tr "${separator}" "." \ + | sort -rVu || true +} + +_select_requested_version() { + local requested_version=$1 + local version_list=$2 + + if [ "${requested_version}" = "latest" ] || [ "${requested_version}" = "current" ] || [ "${requested_version}" = "stable" ] || [ "${requested_version}" = "lts" ] || [ "${requested_version}" = "prerelease" ] || [ "${requested_version}" = "preview" ]; then + printf '%s\n' "${version_list}" | head -n 1 + return + fi + + printf '%s\n' "${version_list}" | grep -E -m 1 "^${requested_version//./\.}([.]|[-]|$)" || true +} + +_version_matches_request() { + local requested_version=$1 + local candidate=$2 + + if [ "${requested_version}" = "latest" ] || [ "${requested_version}" = "current" ] || [ "${requested_version}" = "stable" ] || [ "${requested_version}" = "lts" ] || [ "${requested_version}" = "prerelease" ] || [ "${requested_version}" = "preview" ]; then + return 0 + fi + + echo "${candidate}" | grep -Eq "^${requested_version//./\.}([.]|[-]|$)" +} \ No newline at end of file diff --git a/src/github-cli/devcontainer-feature.json b/src/github-cli/devcontainer-feature.json index 9b7f51e89..2f63ef76c 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.2", + "version": "1.1.3", "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/install.sh b/src/github-cli/install.sh index 162803620..5f1ecf5a6 100755 --- a/src/github-cli/install.sh +++ b/src/github-cli/install.sh @@ -10,6 +10,7 @@ CLI_VERSION=${VERSION:-"latest"} INSTALL_DIRECTLY_FROM_GITHUB_RELEASE=${INSTALLDIRECTLYFROMGITHUBRELEASE:-"true"} EXTENSIONS=${EXTENSIONS:-""} +GITHUB_CLI_LAST_KNOWN_VERSION="2.101.0" GITHUB_CLI_ARCHIVE_GPG_KEY=7F38BBB59D064DBCB3D84D725612B36462313325 @@ -191,7 +192,7 @@ install_deb_using_github() { check_packages wget arch=$(dpkg --print-architecture) - find_version_from_git_tags CLI_VERSION https://github.com/cli/cli + find_version_from_git_tags CLI_VERSION https://github.com/cli/cli "tags/v" "." "false" "" "${GITHUB_CLI_LAST_KNOWN_VERSION}" cli_filename="gh_${CLI_VERSION}_linux_${arch}.deb" mkdir -p /tmp/ghcli @@ -211,6 +212,8 @@ install_deb_using_github() { rm -rf /tmp/ghcli } +. "$(dirname "${BASH_SOURCE[0]}")/scripts/version-resolution.sh" + export DEBIAN_FRONTEND=noninteractive # Install curl, apt-transport-https, curl, gpg, or dirmngr, git if missing @@ -221,7 +224,7 @@ 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" + find_version_from_git_tags CLI_VERSION "https://github.com/cli/cli" "tags/v" "." "false" "" "${GITHUB_CLI_LAST_KNOWN_VERSION}" version_suffix="=${CLI_VERSION}" else version_suffix="" diff --git a/src/github-cli/scripts/version-resolution.sh b/src/github-cli/scripts/version-resolution.sh new file mode 100644 index 000000000..d1b9d5f81 --- /dev/null +++ b/src/github-cli/scripts/version-resolution.sh @@ -0,0 +1,140 @@ +#!/usr/bin/env bash + +# Resolve version requests from git tags, with a caller-provided fallback resolver. +# The fallback function must print candidate versions, one per line. +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 version_suffix_regex=${6:-""} + local known_good_version=${7:-""} + local fallback_name=${8:-""} + local fallback_function=${9:-""} + + if [ "${requested_version}" = "none" ]; then + return + fi + + local tag_prefix=${prefix#refs/} + tag_prefix=${tag_prefix#tags/} + local normalized_request=${requested_version#"${tag_prefix}"} + + local escaped_separator=${separator//./\.} + local patch_regex="${escaped_separator}[0-9]+" + if [ "${last_part_optional}" = "true" ]; then + patch_regex="(${patch_regex})?" + fi + local version_regex="[0-9]+${escaped_separator}[0-9]+${patch_regex}${version_suffix_regex//./\.}" + + # Fully qualified versions are user assertions. Do not require the network + # to prove that a requested release exists. + if echo "${normalized_request}" | grep -Eq "^${version_regex}$"; then + declare -g "${variable_name}=${normalized_request}" + echo "${variable_name}=${normalized_request}" + return + fi + + local version_list="" + local git_output="" + if git_output="$(git ls-remote --tags "${repository}" 2>/dev/null)"; then + version_list="$(_extract_version_candidates "${git_output}" "${prefix}" "${separator}" "${version_regex}")" + fi + + if [ -z "${version_list}" ]; then + echo "(!) Unable to resolve '${requested_version}' from git tags at ${repository}." >&2 + if [ -z "${fallback_function}" ] && echo "${repository}" | grep -qE '^https://github.com/[^/]+/[^/]+/?$'; then + fallback_name="GitHub REST API" + fallback_function="_github_rest_version_candidates" + fi + if [ -n "${fallback_function}" ]; then + echo "(*) Trying ${fallback_name:-alternate version source}." >&2 + local fallback_output="" + if fallback_output="$(${fallback_function} "${repository}" "${prefix}" 2>/dev/null)"; then + version_list="$(_normalize_version_candidates "${fallback_output}" "${separator}" "${version_regex}")" + fi + if [ -n "${version_list}" ]; then + echo "(*) Resolved version using ${fallback_name:-alternate version source}." >&2 + fi + fi + fi + + local resolved_version="" + resolved_version="$(_select_requested_version "${normalized_request}" "${version_list}")" + if [ -z "${resolved_version}" ] && [ -n "${known_good_version}" ] && _version_matches_request "${normalized_request}" "${known_good_version}"; then + resolved_version="${known_good_version}" + echo "(!) Dynamic version resolution failed; using known-good version ${known_good_version}." >&2 + fi + + if [ -z "${resolved_version}" ]; then + echo "(!) Unable to resolve '${requested_version}' for ${variable_name}. Tried git tags at ${repository}${fallback_name:+ and ${fallback_name}}. Specify an exact version to avoid remote resolution." >&2 + return 1 + fi + + declare -g "${variable_name}=${resolved_version}" + echo "${variable_name}=${resolved_version}" +} + +_github_rest_version_candidates() { + local repository=$1 + local prefix=${2:-"tags/v"} + local slug=${repository#https://github.com/} + slug=${slug%/} + local tag_prefix=${prefix#refs/} + tag_prefix=${tag_prefix#tags/} + + curl -fsSL "https://api.github.com/repos/${slug}/tags?per_page=100" \ + | grep -oE '"name"[[:space:]]*:[[:space:]]*"[^"]+"' \ + | sed -E 's/^.*"([^"]+)"$/\1/' \ + | sed "s|^${tag_prefix}||" +} + +_extract_version_candidates() { + local input=$1 + local prefix=$2 + local separator=$3 + local version_regex=$4 + local escaped_prefix=${prefix//./\.} + + printf '%s\n' "${input}" \ + | grep -oE "${escaped_prefix}${version_regex}$" \ + | sed "s|^${prefix}||" \ + | tr "${separator}" "." \ + | sort -rVu || true +} + +_normalize_version_candidates() { + local input=$1 + local separator=$2 + local version_regex=$3 + + printf '%s\n' "${input}" \ + | grep -oE "^${version_regex}$" \ + | tr "${separator}" "." \ + | sort -rVu || true +} + +_select_requested_version() { + local requested_version=$1 + local version_list=$2 + + if [ "${requested_version}" = "latest" ] || [ "${requested_version}" = "current" ] || [ "${requested_version}" = "stable" ] || [ "${requested_version}" = "lts" ] || [ "${requested_version}" = "prerelease" ] || [ "${requested_version}" = "preview" ]; then + printf '%s\n' "${version_list}" | head -n 1 + return + fi + + printf '%s\n' "${version_list}" | grep -E -m 1 "^${requested_version//./\.}([.]|[-]|$)" || true +} + +_version_matches_request() { + local requested_version=$1 + local candidate=$2 + + if [ "${requested_version}" = "latest" ] || [ "${requested_version}" = "current" ] || [ "${requested_version}" = "stable" ] || [ "${requested_version}" = "lts" ] || [ "${requested_version}" = "prerelease" ] || [ "${requested_version}" = "preview" ]; then + return 0 + fi + + echo "${candidate}" | grep -Eq "^${requested_version//./\.}([.]|[-]|$)" +} \ No newline at end of file diff --git a/src/go/devcontainer-feature.json b/src/go/devcontainer-feature.json index b61e93605..9a06623c7 100644 --- a/src/go/devcontainer-feature.json +++ b/src/go/devcontainer-feature.json @@ -1,6 +1,6 @@ { "id": "go", - "version": "1.3.4", + "version": "1.3.5", "name": "Go", "documentationURL": "https://github.com/devcontainers/features/tree/main/src/go", "description": "Installs Go and common Go utilities. Auto-detects latest version and installs needed dependencies.", diff --git a/src/go/install.sh b/src/go/install.sh index db0ac7977..e7e0197d5 100755 --- a/src/go/install.sh +++ b/src/go/install.sh @@ -9,6 +9,7 @@ TARGET_GO_VERSION="${VERSION:-"latest"}" GOLANGCILINT_VERSION="${GOLANGCILINTVERSION:-"latest"}" +GO_LAST_KNOWN_VERSION="1.27.1" TARGET_GOROOT="${TARGET_GOROOT:-"/usr/local/go"}" TARGET_GOPATH="${TARGET_GOPATH:-"/go"}" @@ -191,6 +192,14 @@ elif [ "${USERNAME}" = "none" ] || ! id -u ${USERNAME} > /dev/null 2>&1; then USERNAME=root fi +. "$(dirname "${BASH_SOURCE[0]}")/scripts/version-resolution.sh" + +get_go_release_versions() { + curl -fsSL "https://go.dev/dl/?mode=json&include=all" \ + | grep -oE '"version"[[:space:]]*:[[:space:]]*"go[0-9]+\.[0-9]+(\.[0-9]+)?"' \ + | sed -E 's/^.*"go([0-9.]+)"$/\1/' +} + export DEBIAN_FRONTEND=noninteractive check_packages ca-certificates gnupg2 tar gcc make pkg-config @@ -220,7 +229,7 @@ if ! [ -f /usr/bin/find ]; then fi # Get closest match for version number specified -find_version_from_git_tags TARGET_GO_VERSION "https://go.googlesource.com/go" "tags/go" "." "true" +find_version_from_git_tags TARGET_GO_VERSION "https://go.googlesource.com/go" "tags/go" "." "true" "" "${GO_LAST_KNOWN_VERSION}" "the official Go release index" get_go_release_versions architecture="$(uname -m)" case $architecture in @@ -263,7 +272,7 @@ if [[ "${TARGET_GO_VERSION}" != "none" ]] && [[ "$(go version 2>/dev/null)" != * ((minor=minor-1)) TARGET_GO_VERSION="${major}.${minor}" # Look for latest version from previous minor release - find_version_from_git_tags TARGET_GO_VERSION "https://go.googlesource.com/go" "tags/go" "." "true" + find_version_from_git_tags TARGET_GO_VERSION "https://go.googlesource.com/go" "tags/go" "." "true" "" "${GO_LAST_KNOWN_VERSION}" "the official Go release index" get_go_release_versions else ((breakfix=breakfix-1)) if [ "${breakfix}" = "0" ]; then diff --git a/src/go/scripts/version-resolution.sh b/src/go/scripts/version-resolution.sh new file mode 100644 index 000000000..d1b9d5f81 --- /dev/null +++ b/src/go/scripts/version-resolution.sh @@ -0,0 +1,140 @@ +#!/usr/bin/env bash + +# Resolve version requests from git tags, with a caller-provided fallback resolver. +# The fallback function must print candidate versions, one per line. +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 version_suffix_regex=${6:-""} + local known_good_version=${7:-""} + local fallback_name=${8:-""} + local fallback_function=${9:-""} + + if [ "${requested_version}" = "none" ]; then + return + fi + + local tag_prefix=${prefix#refs/} + tag_prefix=${tag_prefix#tags/} + local normalized_request=${requested_version#"${tag_prefix}"} + + local escaped_separator=${separator//./\.} + local patch_regex="${escaped_separator}[0-9]+" + if [ "${last_part_optional}" = "true" ]; then + patch_regex="(${patch_regex})?" + fi + local version_regex="[0-9]+${escaped_separator}[0-9]+${patch_regex}${version_suffix_regex//./\.}" + + # Fully qualified versions are user assertions. Do not require the network + # to prove that a requested release exists. + if echo "${normalized_request}" | grep -Eq "^${version_regex}$"; then + declare -g "${variable_name}=${normalized_request}" + echo "${variable_name}=${normalized_request}" + return + fi + + local version_list="" + local git_output="" + if git_output="$(git ls-remote --tags "${repository}" 2>/dev/null)"; then + version_list="$(_extract_version_candidates "${git_output}" "${prefix}" "${separator}" "${version_regex}")" + fi + + if [ -z "${version_list}" ]; then + echo "(!) Unable to resolve '${requested_version}' from git tags at ${repository}." >&2 + if [ -z "${fallback_function}" ] && echo "${repository}" | grep -qE '^https://github.com/[^/]+/[^/]+/?$'; then + fallback_name="GitHub REST API" + fallback_function="_github_rest_version_candidates" + fi + if [ -n "${fallback_function}" ]; then + echo "(*) Trying ${fallback_name:-alternate version source}." >&2 + local fallback_output="" + if fallback_output="$(${fallback_function} "${repository}" "${prefix}" 2>/dev/null)"; then + version_list="$(_normalize_version_candidates "${fallback_output}" "${separator}" "${version_regex}")" + fi + if [ -n "${version_list}" ]; then + echo "(*) Resolved version using ${fallback_name:-alternate version source}." >&2 + fi + fi + fi + + local resolved_version="" + resolved_version="$(_select_requested_version "${normalized_request}" "${version_list}")" + if [ -z "${resolved_version}" ] && [ -n "${known_good_version}" ] && _version_matches_request "${normalized_request}" "${known_good_version}"; then + resolved_version="${known_good_version}" + echo "(!) Dynamic version resolution failed; using known-good version ${known_good_version}." >&2 + fi + + if [ -z "${resolved_version}" ]; then + echo "(!) Unable to resolve '${requested_version}' for ${variable_name}. Tried git tags at ${repository}${fallback_name:+ and ${fallback_name}}. Specify an exact version to avoid remote resolution." >&2 + return 1 + fi + + declare -g "${variable_name}=${resolved_version}" + echo "${variable_name}=${resolved_version}" +} + +_github_rest_version_candidates() { + local repository=$1 + local prefix=${2:-"tags/v"} + local slug=${repository#https://github.com/} + slug=${slug%/} + local tag_prefix=${prefix#refs/} + tag_prefix=${tag_prefix#tags/} + + curl -fsSL "https://api.github.com/repos/${slug}/tags?per_page=100" \ + | grep -oE '"name"[[:space:]]*:[[:space:]]*"[^"]+"' \ + | sed -E 's/^.*"([^"]+)"$/\1/' \ + | sed "s|^${tag_prefix}||" +} + +_extract_version_candidates() { + local input=$1 + local prefix=$2 + local separator=$3 + local version_regex=$4 + local escaped_prefix=${prefix//./\.} + + printf '%s\n' "${input}" \ + | grep -oE "${escaped_prefix}${version_regex}$" \ + | sed "s|^${prefix}||" \ + | tr "${separator}" "." \ + | sort -rVu || true +} + +_normalize_version_candidates() { + local input=$1 + local separator=$2 + local version_regex=$3 + + printf '%s\n' "${input}" \ + | grep -oE "^${version_regex}$" \ + | tr "${separator}" "." \ + | sort -rVu || true +} + +_select_requested_version() { + local requested_version=$1 + local version_list=$2 + + if [ "${requested_version}" = "latest" ] || [ "${requested_version}" = "current" ] || [ "${requested_version}" = "stable" ] || [ "${requested_version}" = "lts" ] || [ "${requested_version}" = "prerelease" ] || [ "${requested_version}" = "preview" ]; then + printf '%s\n' "${version_list}" | head -n 1 + return + fi + + printf '%s\n' "${version_list}" | grep -E -m 1 "^${requested_version//./\.}([.]|[-]|$)" || true +} + +_version_matches_request() { + local requested_version=$1 + local candidate=$2 + + if [ "${requested_version}" = "latest" ] || [ "${requested_version}" = "current" ] || [ "${requested_version}" = "stable" ] || [ "${requested_version}" = "lts" ] || [ "${requested_version}" = "prerelease" ] || [ "${requested_version}" = "preview" ]; then + return 0 + fi + + echo "${candidate}" | grep -Eq "^${requested_version//./\.}([.]|[-]|$)" +} \ No newline at end of file diff --git a/src/kubectl-helm-minikube/devcontainer-feature.json b/src/kubectl-helm-minikube/devcontainer-feature.json index a88aebde6..87ce23815 100644 --- a/src/kubectl-helm-minikube/devcontainer-feature.json +++ b/src/kubectl-helm-minikube/devcontainer-feature.json @@ -1,6 +1,6 @@ { "id": "kubectl-helm-minikube", - "version": "1.3.1", + "version": "1.3.2", "name": "Kubectl, Helm, and Minikube", "documentationURL": "https://github.com/devcontainers/features/tree/main/src/kubectl-helm-minikube", "description": "Installs latest version of kubectl, Helm, and optionally minikube. Auto-detects latest versions and installs needed dependencies.", diff --git a/src/kubectl-helm-minikube/install.sh b/src/kubectl-helm-minikube/install.sh index 40901d3cb..da7d82d13 100755 --- a/src/kubectl-helm-minikube/install.sh +++ b/src/kubectl-helm-minikube/install.sh @@ -19,6 +19,10 @@ KUBECTL_VERSION="${VERSION:-"latest"}" HELM_VERSION="${HELM:-"latest"}" MINIKUBE_VERSION="${MINIKUBE:-"latest"}" # latest is also valid +KUBECTL_LAST_KNOWN_VERSION="1.37.0" +HELM_LAST_KNOWN_VERSION="4.3.0" +MINIKUBE_LAST_KNOWN_VERSION="1.39.0" + KUBECTL_SHA256="${KUBECTL_SHA256:-"automatic"}" HELM_SHA256="${HELM_SHA256:-"automatic"}" MINIKUBE_SHA256="${MINIKUBE_SHA256:-"automatic"}" @@ -146,6 +150,8 @@ check_packages() { } # Ensure apt is in non-interactive to avoid prompts +. "$(dirname "${BASH_SOURCE[0]}")/scripts/version-resolution.sh" + export DEBIAN_FRONTEND=noninteractive # Install dependencies @@ -177,7 +183,7 @@ if [ ${KUBECTL_VERSION} != "none" ]; then KUBECTL_VERSION="${KUBECTL_FALLBACK_VERSION}" fi else - find_version_from_git_tags KUBECTL_VERSION https://github.com/kubernetes/kubernetes + find_version_from_git_tags KUBECTL_VERSION https://github.com/kubernetes/kubernetes "tags/v" "." "false" "" "${KUBECTL_LAST_KNOWN_VERSION}" fi if [ "${KUBECTL_VERSION::1}" != 'v' ]; then KUBECTL_VERSION="v${KUBECTL_VERSION}" @@ -278,7 +284,7 @@ if [ ${HELM_VERSION} != "none" ]; then # Install Helm, verify signature and checksum echo "Downloading Helm..." helm_url="https://github.com/helm/helm" - find_version_from_git_tags HELM_VERSION "${helm_url}" + find_version_from_git_tags HELM_VERSION "${helm_url}" "tags/v" "." "false" "" "${HELM_LAST_KNOWN_VERSION}" if [ "${HELM_VERSION::1}" != 'v' ]; then HELM_VERSION="v${HELM_VERSION}" fi @@ -341,7 +347,7 @@ if [ "${MINIKUBE_VERSION}" != "none" ]; then if [ "${MINIKUBE_VERSION}" = "latest" ] || [ "${MINIKUBE_VERSION}" = "lts" ] || [ "${MINIKUBE_VERSION}" = "current" ] || [ "${MINIKUBE_VERSION}" = "stable" ]; then MINIKUBE_VERSION="latest" else - find_version_from_git_tags MINIKUBE_VERSION https://github.com/kubernetes/minikube + find_version_from_git_tags MINIKUBE_VERSION https://github.com/kubernetes/minikube "tags/v" "." "false" "" "${MINIKUBE_LAST_KNOWN_VERSION}" if [ "${MINIKUBE_VERSION::1}" != "v" ]; then MINIKUBE_VERSION="v${MINIKUBE_VERSION}" fi diff --git a/src/kubectl-helm-minikube/scripts/version-resolution.sh b/src/kubectl-helm-minikube/scripts/version-resolution.sh new file mode 100644 index 000000000..d1b9d5f81 --- /dev/null +++ b/src/kubectl-helm-minikube/scripts/version-resolution.sh @@ -0,0 +1,140 @@ +#!/usr/bin/env bash + +# Resolve version requests from git tags, with a caller-provided fallback resolver. +# The fallback function must print candidate versions, one per line. +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 version_suffix_regex=${6:-""} + local known_good_version=${7:-""} + local fallback_name=${8:-""} + local fallback_function=${9:-""} + + if [ "${requested_version}" = "none" ]; then + return + fi + + local tag_prefix=${prefix#refs/} + tag_prefix=${tag_prefix#tags/} + local normalized_request=${requested_version#"${tag_prefix}"} + + local escaped_separator=${separator//./\.} + local patch_regex="${escaped_separator}[0-9]+" + if [ "${last_part_optional}" = "true" ]; then + patch_regex="(${patch_regex})?" + fi + local version_regex="[0-9]+${escaped_separator}[0-9]+${patch_regex}${version_suffix_regex//./\.}" + + # Fully qualified versions are user assertions. Do not require the network + # to prove that a requested release exists. + if echo "${normalized_request}" | grep -Eq "^${version_regex}$"; then + declare -g "${variable_name}=${normalized_request}" + echo "${variable_name}=${normalized_request}" + return + fi + + local version_list="" + local git_output="" + if git_output="$(git ls-remote --tags "${repository}" 2>/dev/null)"; then + version_list="$(_extract_version_candidates "${git_output}" "${prefix}" "${separator}" "${version_regex}")" + fi + + if [ -z "${version_list}" ]; then + echo "(!) Unable to resolve '${requested_version}' from git tags at ${repository}." >&2 + if [ -z "${fallback_function}" ] && echo "${repository}" | grep -qE '^https://github.com/[^/]+/[^/]+/?$'; then + fallback_name="GitHub REST API" + fallback_function="_github_rest_version_candidates" + fi + if [ -n "${fallback_function}" ]; then + echo "(*) Trying ${fallback_name:-alternate version source}." >&2 + local fallback_output="" + if fallback_output="$(${fallback_function} "${repository}" "${prefix}" 2>/dev/null)"; then + version_list="$(_normalize_version_candidates "${fallback_output}" "${separator}" "${version_regex}")" + fi + if [ -n "${version_list}" ]; then + echo "(*) Resolved version using ${fallback_name:-alternate version source}." >&2 + fi + fi + fi + + local resolved_version="" + resolved_version="$(_select_requested_version "${normalized_request}" "${version_list}")" + if [ -z "${resolved_version}" ] && [ -n "${known_good_version}" ] && _version_matches_request "${normalized_request}" "${known_good_version}"; then + resolved_version="${known_good_version}" + echo "(!) Dynamic version resolution failed; using known-good version ${known_good_version}." >&2 + fi + + if [ -z "${resolved_version}" ]; then + echo "(!) Unable to resolve '${requested_version}' for ${variable_name}. Tried git tags at ${repository}${fallback_name:+ and ${fallback_name}}. Specify an exact version to avoid remote resolution." >&2 + return 1 + fi + + declare -g "${variable_name}=${resolved_version}" + echo "${variable_name}=${resolved_version}" +} + +_github_rest_version_candidates() { + local repository=$1 + local prefix=${2:-"tags/v"} + local slug=${repository#https://github.com/} + slug=${slug%/} + local tag_prefix=${prefix#refs/} + tag_prefix=${tag_prefix#tags/} + + curl -fsSL "https://api.github.com/repos/${slug}/tags?per_page=100" \ + | grep -oE '"name"[[:space:]]*:[[:space:]]*"[^"]+"' \ + | sed -E 's/^.*"([^"]+)"$/\1/' \ + | sed "s|^${tag_prefix}||" +} + +_extract_version_candidates() { + local input=$1 + local prefix=$2 + local separator=$3 + local version_regex=$4 + local escaped_prefix=${prefix//./\.} + + printf '%s\n' "${input}" \ + | grep -oE "${escaped_prefix}${version_regex}$" \ + | sed "s|^${prefix}||" \ + | tr "${separator}" "." \ + | sort -rVu || true +} + +_normalize_version_candidates() { + local input=$1 + local separator=$2 + local version_regex=$3 + + printf '%s\n' "${input}" \ + | grep -oE "^${version_regex}$" \ + | tr "${separator}" "." \ + | sort -rVu || true +} + +_select_requested_version() { + local requested_version=$1 + local version_list=$2 + + if [ "${requested_version}" = "latest" ] || [ "${requested_version}" = "current" ] || [ "${requested_version}" = "stable" ] || [ "${requested_version}" = "lts" ] || [ "${requested_version}" = "prerelease" ] || [ "${requested_version}" = "preview" ]; then + printf '%s\n' "${version_list}" | head -n 1 + return + fi + + printf '%s\n' "${version_list}" | grep -E -m 1 "^${requested_version//./\.}([.]|[-]|$)" || true +} + +_version_matches_request() { + local requested_version=$1 + local candidate=$2 + + if [ "${requested_version}" = "latest" ] || [ "${requested_version}" = "current" ] || [ "${requested_version}" = "stable" ] || [ "${requested_version}" = "lts" ] || [ "${requested_version}" = "prerelease" ] || [ "${requested_version}" = "preview" ]; then + return 0 + fi + + echo "${candidate}" | grep -Eq "^${requested_version//./\.}([.]|[-]|$)" +} \ No newline at end of file diff --git a/src/nix/devcontainer-feature.json b/src/nix/devcontainer-feature.json index f2e956d8a..92e167d8f 100644 --- a/src/nix/devcontainer-feature.json +++ b/src/nix/devcontainer-feature.json @@ -1,6 +1,6 @@ { "id": "nix", - "version": "1.4.0", + "version": "1.4.1", "name": "Nix Package Manager", "documentationURL": "https://github.com/devcontainers/features/tree/main/src/nix", "description": "Installs the Nix package manager and optionally a set of packages.", diff --git a/src/nix/install.sh b/src/nix/install.sh index ca19f658a..c37b56d14 100755 --- a/src/nix/install.sh +++ b/src/nix/install.sh @@ -12,6 +12,7 @@ USEATTRIBUTEPATH="${USEATTRIBUTEPATH:-"false"}" FLAKEURI="${FLAKEURI:-""}" EXTRANIXCONFIG="${EXTRANIXCONFIG:-""}" USERNAME="${USERNAME:-"${_REMOTE_USER:-"automatic"}"}" +NIX_LAST_KNOWN_VERSION="2.35.2" 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.' @@ -20,6 +21,7 @@ fi # Import common utils . ./utils.sh +. "$(dirname "${BASH_SOURCE[0]}")/scripts/version-resolution.sh" detect_user USERNAME @@ -38,7 +40,7 @@ check_command git git git git check_command xargs findutils findutils findutils # Determine version -find_version_from_git_tags VERSION https://github.com/NixOS/nix "tags/" +find_version_from_git_tags VERSION https://github.com/NixOS/nix "tags/" "." "false" "" "${NIX_LAST_KNOWN_VERSION}" # Download and verify install per https://nixos.org/download.html#nix-verify-installation tmpdir="$(mktemp -d)" diff --git a/src/nix/scripts/version-resolution.sh b/src/nix/scripts/version-resolution.sh new file mode 100644 index 000000000..d1b9d5f81 --- /dev/null +++ b/src/nix/scripts/version-resolution.sh @@ -0,0 +1,140 @@ +#!/usr/bin/env bash + +# Resolve version requests from git tags, with a caller-provided fallback resolver. +# The fallback function must print candidate versions, one per line. +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 version_suffix_regex=${6:-""} + local known_good_version=${7:-""} + local fallback_name=${8:-""} + local fallback_function=${9:-""} + + if [ "${requested_version}" = "none" ]; then + return + fi + + local tag_prefix=${prefix#refs/} + tag_prefix=${tag_prefix#tags/} + local normalized_request=${requested_version#"${tag_prefix}"} + + local escaped_separator=${separator//./\.} + local patch_regex="${escaped_separator}[0-9]+" + if [ "${last_part_optional}" = "true" ]; then + patch_regex="(${patch_regex})?" + fi + local version_regex="[0-9]+${escaped_separator}[0-9]+${patch_regex}${version_suffix_regex//./\.}" + + # Fully qualified versions are user assertions. Do not require the network + # to prove that a requested release exists. + if echo "${normalized_request}" | grep -Eq "^${version_regex}$"; then + declare -g "${variable_name}=${normalized_request}" + echo "${variable_name}=${normalized_request}" + return + fi + + local version_list="" + local git_output="" + if git_output="$(git ls-remote --tags "${repository}" 2>/dev/null)"; then + version_list="$(_extract_version_candidates "${git_output}" "${prefix}" "${separator}" "${version_regex}")" + fi + + if [ -z "${version_list}" ]; then + echo "(!) Unable to resolve '${requested_version}' from git tags at ${repository}." >&2 + if [ -z "${fallback_function}" ] && echo "${repository}" | grep -qE '^https://github.com/[^/]+/[^/]+/?$'; then + fallback_name="GitHub REST API" + fallback_function="_github_rest_version_candidates" + fi + if [ -n "${fallback_function}" ]; then + echo "(*) Trying ${fallback_name:-alternate version source}." >&2 + local fallback_output="" + if fallback_output="$(${fallback_function} "${repository}" "${prefix}" 2>/dev/null)"; then + version_list="$(_normalize_version_candidates "${fallback_output}" "${separator}" "${version_regex}")" + fi + if [ -n "${version_list}" ]; then + echo "(*) Resolved version using ${fallback_name:-alternate version source}." >&2 + fi + fi + fi + + local resolved_version="" + resolved_version="$(_select_requested_version "${normalized_request}" "${version_list}")" + if [ -z "${resolved_version}" ] && [ -n "${known_good_version}" ] && _version_matches_request "${normalized_request}" "${known_good_version}"; then + resolved_version="${known_good_version}" + echo "(!) Dynamic version resolution failed; using known-good version ${known_good_version}." >&2 + fi + + if [ -z "${resolved_version}" ]; then + echo "(!) Unable to resolve '${requested_version}' for ${variable_name}. Tried git tags at ${repository}${fallback_name:+ and ${fallback_name}}. Specify an exact version to avoid remote resolution." >&2 + return 1 + fi + + declare -g "${variable_name}=${resolved_version}" + echo "${variable_name}=${resolved_version}" +} + +_github_rest_version_candidates() { + local repository=$1 + local prefix=${2:-"tags/v"} + local slug=${repository#https://github.com/} + slug=${slug%/} + local tag_prefix=${prefix#refs/} + tag_prefix=${tag_prefix#tags/} + + curl -fsSL "https://api.github.com/repos/${slug}/tags?per_page=100" \ + | grep -oE '"name"[[:space:]]*:[[:space:]]*"[^"]+"' \ + | sed -E 's/^.*"([^"]+)"$/\1/' \ + | sed "s|^${tag_prefix}||" +} + +_extract_version_candidates() { + local input=$1 + local prefix=$2 + local separator=$3 + local version_regex=$4 + local escaped_prefix=${prefix//./\.} + + printf '%s\n' "${input}" \ + | grep -oE "${escaped_prefix}${version_regex}$" \ + | sed "s|^${prefix}||" \ + | tr "${separator}" "." \ + | sort -rVu || true +} + +_normalize_version_candidates() { + local input=$1 + local separator=$2 + local version_regex=$3 + + printf '%s\n' "${input}" \ + | grep -oE "^${version_regex}$" \ + | tr "${separator}" "." \ + | sort -rVu || true +} + +_select_requested_version() { + local requested_version=$1 + local version_list=$2 + + if [ "${requested_version}" = "latest" ] || [ "${requested_version}" = "current" ] || [ "${requested_version}" = "stable" ] || [ "${requested_version}" = "lts" ] || [ "${requested_version}" = "prerelease" ] || [ "${requested_version}" = "preview" ]; then + printf '%s\n' "${version_list}" | head -n 1 + return + fi + + printf '%s\n' "${version_list}" | grep -E -m 1 "^${requested_version//./\.}([.]|[-]|$)" || true +} + +_version_matches_request() { + local requested_version=$1 + local candidate=$2 + + if [ "${requested_version}" = "latest" ] || [ "${requested_version}" = "current" ] || [ "${requested_version}" = "stable" ] || [ "${requested_version}" = "lts" ] || [ "${requested_version}" = "prerelease" ] || [ "${requested_version}" = "preview" ]; then + return 0 + fi + + echo "${candidate}" | grep -Eq "^${requested_version//./\.}([.]|[-]|$)" +} \ No newline at end of file diff --git a/src/node/devcontainer-feature.json b/src/node/devcontainer-feature.json index 5e0970b2d..5e0b8063e 100644 --- a/src/node/devcontainer-feature.json +++ b/src/node/devcontainer-feature.json @@ -1,6 +1,6 @@ { "id": "node", - "version": "2.1.0", + "version": "2.1.1", "name": "Node.js (via nvm), yarn and pnpm.", "documentationURL": "https://github.com/devcontainers/features/tree/main/src/node", "description": "Installs Node.js, nvm, yarn, pnpm, and needed dependencies.", diff --git a/src/node/install.sh b/src/node/install.sh index 0e277812e..3765c98ac 100755 --- a/src/node/install.sh +++ b/src/node/install.sh @@ -11,6 +11,7 @@ export NODE_VERSION="${VERSION:-"lts"}" export NPM_VERSION="${NPMVERSION:-"lts"}" export PNPM_VERSION="${PNPMVERSION:-"latest"}" export NVM_VERSION="${NVMVERSION:-"latest"}" +NVM_LAST_KNOWN_VERSION="0.40.8" export NVM_DIR="${NVMINSTALLPATH:-"/usr/local/share/nvm"}" INSTALL_TOOLS_FOR_NODE_GYP="${NODEGYPDEPENDENCIES:-true}" export INSTALL_YARN_USING_APT="${INSTALLYARNUSINGAPT:-false}" # only concerns Debian-based systems @@ -289,6 +290,8 @@ if ! type git > /dev/null 2>&1; then check_packages git fi +. "$(dirname "${BASH_SOURCE[0]}")/scripts/version-resolution.sh" + # Adjust node version if required if [ "${NODE_VERSION}" = "none" ]; then export NODE_VERSION= @@ -298,7 +301,7 @@ elif [ "${NODE_VERSION}" = "latest" ]; then export NODE_VERSION="node" fi -find_version_from_git_tags NVM_VERSION "https://github.com/nvm-sh/nvm" +find_version_from_git_tags NVM_VERSION "https://github.com/nvm-sh/nvm" "tags/v" "." "false" "" "${NVM_LAST_KNOWN_VERSION}" # Install snipppet that we will run as the user nvm_install_snippet="$(cat << EOF diff --git a/src/node/scripts/version-resolution.sh b/src/node/scripts/version-resolution.sh new file mode 100644 index 000000000..d1b9d5f81 --- /dev/null +++ b/src/node/scripts/version-resolution.sh @@ -0,0 +1,140 @@ +#!/usr/bin/env bash + +# Resolve version requests from git tags, with a caller-provided fallback resolver. +# The fallback function must print candidate versions, one per line. +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 version_suffix_regex=${6:-""} + local known_good_version=${7:-""} + local fallback_name=${8:-""} + local fallback_function=${9:-""} + + if [ "${requested_version}" = "none" ]; then + return + fi + + local tag_prefix=${prefix#refs/} + tag_prefix=${tag_prefix#tags/} + local normalized_request=${requested_version#"${tag_prefix}"} + + local escaped_separator=${separator//./\.} + local patch_regex="${escaped_separator}[0-9]+" + if [ "${last_part_optional}" = "true" ]; then + patch_regex="(${patch_regex})?" + fi + local version_regex="[0-9]+${escaped_separator}[0-9]+${patch_regex}${version_suffix_regex//./\.}" + + # Fully qualified versions are user assertions. Do not require the network + # to prove that a requested release exists. + if echo "${normalized_request}" | grep -Eq "^${version_regex}$"; then + declare -g "${variable_name}=${normalized_request}" + echo "${variable_name}=${normalized_request}" + return + fi + + local version_list="" + local git_output="" + if git_output="$(git ls-remote --tags "${repository}" 2>/dev/null)"; then + version_list="$(_extract_version_candidates "${git_output}" "${prefix}" "${separator}" "${version_regex}")" + fi + + if [ -z "${version_list}" ]; then + echo "(!) Unable to resolve '${requested_version}' from git tags at ${repository}." >&2 + if [ -z "${fallback_function}" ] && echo "${repository}" | grep -qE '^https://github.com/[^/]+/[^/]+/?$'; then + fallback_name="GitHub REST API" + fallback_function="_github_rest_version_candidates" + fi + if [ -n "${fallback_function}" ]; then + echo "(*) Trying ${fallback_name:-alternate version source}." >&2 + local fallback_output="" + if fallback_output="$(${fallback_function} "${repository}" "${prefix}" 2>/dev/null)"; then + version_list="$(_normalize_version_candidates "${fallback_output}" "${separator}" "${version_regex}")" + fi + if [ -n "${version_list}" ]; then + echo "(*) Resolved version using ${fallback_name:-alternate version source}." >&2 + fi + fi + fi + + local resolved_version="" + resolved_version="$(_select_requested_version "${normalized_request}" "${version_list}")" + if [ -z "${resolved_version}" ] && [ -n "${known_good_version}" ] && _version_matches_request "${normalized_request}" "${known_good_version}"; then + resolved_version="${known_good_version}" + echo "(!) Dynamic version resolution failed; using known-good version ${known_good_version}." >&2 + fi + + if [ -z "${resolved_version}" ]; then + echo "(!) Unable to resolve '${requested_version}' for ${variable_name}. Tried git tags at ${repository}${fallback_name:+ and ${fallback_name}}. Specify an exact version to avoid remote resolution." >&2 + return 1 + fi + + declare -g "${variable_name}=${resolved_version}" + echo "${variable_name}=${resolved_version}" +} + +_github_rest_version_candidates() { + local repository=$1 + local prefix=${2:-"tags/v"} + local slug=${repository#https://github.com/} + slug=${slug%/} + local tag_prefix=${prefix#refs/} + tag_prefix=${tag_prefix#tags/} + + curl -fsSL "https://api.github.com/repos/${slug}/tags?per_page=100" \ + | grep -oE '"name"[[:space:]]*:[[:space:]]*"[^"]+"' \ + | sed -E 's/^.*"([^"]+)"$/\1/' \ + | sed "s|^${tag_prefix}||" +} + +_extract_version_candidates() { + local input=$1 + local prefix=$2 + local separator=$3 + local version_regex=$4 + local escaped_prefix=${prefix//./\.} + + printf '%s\n' "${input}" \ + | grep -oE "${escaped_prefix}${version_regex}$" \ + | sed "s|^${prefix}||" \ + | tr "${separator}" "." \ + | sort -rVu || true +} + +_normalize_version_candidates() { + local input=$1 + local separator=$2 + local version_regex=$3 + + printf '%s\n' "${input}" \ + | grep -oE "^${version_regex}$" \ + | tr "${separator}" "." \ + | sort -rVu || true +} + +_select_requested_version() { + local requested_version=$1 + local version_list=$2 + + if [ "${requested_version}" = "latest" ] || [ "${requested_version}" = "current" ] || [ "${requested_version}" = "stable" ] || [ "${requested_version}" = "lts" ] || [ "${requested_version}" = "prerelease" ] || [ "${requested_version}" = "preview" ]; then + printf '%s\n' "${version_list}" | head -n 1 + return + fi + + printf '%s\n' "${version_list}" | grep -E -m 1 "^${requested_version//./\.}([.]|[-]|$)" || true +} + +_version_matches_request() { + local requested_version=$1 + local candidate=$2 + + if [ "${requested_version}" = "latest" ] || [ "${requested_version}" = "current" ] || [ "${requested_version}" = "stable" ] || [ "${requested_version}" = "lts" ] || [ "${requested_version}" = "prerelease" ] || [ "${requested_version}" = "preview" ]; then + return 0 + fi + + echo "${candidate}" | grep -Eq "^${requested_version//./\.}([.]|[-]|$)" +} \ No newline at end of file diff --git a/src/php/devcontainer-feature.json b/src/php/devcontainer-feature.json index 6abdc965b..0a43843bc 100644 --- a/src/php/devcontainer-feature.json +++ b/src/php/devcontainer-feature.json @@ -1,6 +1,6 @@ { "id": "php", - "version": "1.1.5", + "version": "1.1.6", "name": "PHP", "documentationURL": "https://github.com/devcontainers/features/tree/main/src/php", "options": { diff --git a/src/php/install.sh b/src/php/install.sh index 531b16518..d1d0c1181 100755 --- a/src/php/install.sh +++ b/src/php/install.sh @@ -14,6 +14,8 @@ rm -rf /var/lib/apt/lists/* PHP_VERSION="${VERSION:-"latest"}" INSTALL_COMPOSER="${INSTALLCOMPOSER:-"true"}" OVERRIDE_DEFAULT_VERSION="${OVERRIDEDEFAULTVERSION:-"true"}" +PHP_LAST_KNOWN_VERSION="8.5.10" +XDEBUG_LAST_KNOWN_VERSION="3.5.3" export PHP_DIR="${PHP_DIR:-"/usr/local/php"}" USERNAME="${USERNAME:-"${_REMOTE_USER:-"automatic"}"}" @@ -161,6 +163,8 @@ find_prev_version_from_git_tags() { set -e } +. "$(dirname "${BASH_SOURCE[0]}")/scripts/version-resolution.sh" + # Install PHP Composer addcomposer() { "${PHP_SRC}" -r "copy('https://getcomposer.org/installer', 'composer-setup.php');" @@ -175,7 +179,7 @@ addcomposer() { # the current PHP version (common around new PHP releases). install_xdebug_from_source() { XDEBUG_VERSION="latest" - find_version_from_git_tags XDEBUG_VERSION https://github.com/xdebug/xdebug "tags/" + find_version_from_git_tags XDEBUG_VERSION https://github.com/xdebug/xdebug "tags/" "." "false" "" "${XDEBUG_LAST_KNOWN_VERSION}" local xdebug_src_dir="/tmp/xdebug-src" rm -rf "${xdebug_src_dir}" @@ -305,7 +309,7 @@ if [ "${PHP_VERSION}" != "none" ]; then # storing value of PHP_VERSION before it changes ORIGINAL_PHP_VERSION=$PHP_VERSION - find_version_from_git_tags PHP_VERSION https://github.com/php/php-src "tags/php-" + find_version_from_git_tags PHP_VERSION https://github.com/php/php-src "tags/php-" "." "false" "" "${PHP_LAST_KNOWN_VERSION}" install_php "${PHP_VERSION}" PHP_SRC="${PHP_INSTALL_DIR}/bin/php" diff --git a/src/php/scripts/version-resolution.sh b/src/php/scripts/version-resolution.sh new file mode 100644 index 000000000..d1b9d5f81 --- /dev/null +++ b/src/php/scripts/version-resolution.sh @@ -0,0 +1,140 @@ +#!/usr/bin/env bash + +# Resolve version requests from git tags, with a caller-provided fallback resolver. +# The fallback function must print candidate versions, one per line. +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 version_suffix_regex=${6:-""} + local known_good_version=${7:-""} + local fallback_name=${8:-""} + local fallback_function=${9:-""} + + if [ "${requested_version}" = "none" ]; then + return + fi + + local tag_prefix=${prefix#refs/} + tag_prefix=${tag_prefix#tags/} + local normalized_request=${requested_version#"${tag_prefix}"} + + local escaped_separator=${separator//./\.} + local patch_regex="${escaped_separator}[0-9]+" + if [ "${last_part_optional}" = "true" ]; then + patch_regex="(${patch_regex})?" + fi + local version_regex="[0-9]+${escaped_separator}[0-9]+${patch_regex}${version_suffix_regex//./\.}" + + # Fully qualified versions are user assertions. Do not require the network + # to prove that a requested release exists. + if echo "${normalized_request}" | grep -Eq "^${version_regex}$"; then + declare -g "${variable_name}=${normalized_request}" + echo "${variable_name}=${normalized_request}" + return + fi + + local version_list="" + local git_output="" + if git_output="$(git ls-remote --tags "${repository}" 2>/dev/null)"; then + version_list="$(_extract_version_candidates "${git_output}" "${prefix}" "${separator}" "${version_regex}")" + fi + + if [ -z "${version_list}" ]; then + echo "(!) Unable to resolve '${requested_version}' from git tags at ${repository}." >&2 + if [ -z "${fallback_function}" ] && echo "${repository}" | grep -qE '^https://github.com/[^/]+/[^/]+/?$'; then + fallback_name="GitHub REST API" + fallback_function="_github_rest_version_candidates" + fi + if [ -n "${fallback_function}" ]; then + echo "(*) Trying ${fallback_name:-alternate version source}." >&2 + local fallback_output="" + if fallback_output="$(${fallback_function} "${repository}" "${prefix}" 2>/dev/null)"; then + version_list="$(_normalize_version_candidates "${fallback_output}" "${separator}" "${version_regex}")" + fi + if [ -n "${version_list}" ]; then + echo "(*) Resolved version using ${fallback_name:-alternate version source}." >&2 + fi + fi + fi + + local resolved_version="" + resolved_version="$(_select_requested_version "${normalized_request}" "${version_list}")" + if [ -z "${resolved_version}" ] && [ -n "${known_good_version}" ] && _version_matches_request "${normalized_request}" "${known_good_version}"; then + resolved_version="${known_good_version}" + echo "(!) Dynamic version resolution failed; using known-good version ${known_good_version}." >&2 + fi + + if [ -z "${resolved_version}" ]; then + echo "(!) Unable to resolve '${requested_version}' for ${variable_name}. Tried git tags at ${repository}${fallback_name:+ and ${fallback_name}}. Specify an exact version to avoid remote resolution." >&2 + return 1 + fi + + declare -g "${variable_name}=${resolved_version}" + echo "${variable_name}=${resolved_version}" +} + +_github_rest_version_candidates() { + local repository=$1 + local prefix=${2:-"tags/v"} + local slug=${repository#https://github.com/} + slug=${slug%/} + local tag_prefix=${prefix#refs/} + tag_prefix=${tag_prefix#tags/} + + curl -fsSL "https://api.github.com/repos/${slug}/tags?per_page=100" \ + | grep -oE '"name"[[:space:]]*:[[:space:]]*"[^"]+"' \ + | sed -E 's/^.*"([^"]+)"$/\1/' \ + | sed "s|^${tag_prefix}||" +} + +_extract_version_candidates() { + local input=$1 + local prefix=$2 + local separator=$3 + local version_regex=$4 + local escaped_prefix=${prefix//./\.} + + printf '%s\n' "${input}" \ + | grep -oE "${escaped_prefix}${version_regex}$" \ + | sed "s|^${prefix}||" \ + | tr "${separator}" "." \ + | sort -rVu || true +} + +_normalize_version_candidates() { + local input=$1 + local separator=$2 + local version_regex=$3 + + printf '%s\n' "${input}" \ + | grep -oE "^${version_regex}$" \ + | tr "${separator}" "." \ + | sort -rVu || true +} + +_select_requested_version() { + local requested_version=$1 + local version_list=$2 + + if [ "${requested_version}" = "latest" ] || [ "${requested_version}" = "current" ] || [ "${requested_version}" = "stable" ] || [ "${requested_version}" = "lts" ] || [ "${requested_version}" = "prerelease" ] || [ "${requested_version}" = "preview" ]; then + printf '%s\n' "${version_list}" | head -n 1 + return + fi + + printf '%s\n' "${version_list}" | grep -E -m 1 "^${requested_version//./\.}([.]|[-]|$)" || true +} + +_version_matches_request() { + local requested_version=$1 + local candidate=$2 + + if [ "${requested_version}" = "latest" ] || [ "${requested_version}" = "current" ] || [ "${requested_version}" = "stable" ] || [ "${requested_version}" = "lts" ] || [ "${requested_version}" = "prerelease" ] || [ "${requested_version}" = "preview" ]; then + return 0 + fi + + echo "${candidate}" | grep -Eq "^${requested_version//./\.}([.]|[-]|$)" +} \ No newline at end of file diff --git a/src/powershell/devcontainer-feature.json b/src/powershell/devcontainer-feature.json index 906d4bbcf..7e0089283 100644 --- a/src/powershell/devcontainer-feature.json +++ b/src/powershell/devcontainer-feature.json @@ -1,6 +1,6 @@ { "id": "powershell", - "version": "2.0.3", + "version": "2.0.4", "name": "PowerShell", "documentationURL": "https://github.com/devcontainers/features/tree/main/src/powershell", "description": "Installs PowerShell along with needed dependencies. Useful for base Dockerfiles that often are missing required install dependencies like gpg.", diff --git a/src/powershell/install.sh b/src/powershell/install.sh index 9856cc5e0..ea81c509a 100755 --- a/src/powershell/install.sh +++ b/src/powershell/install.sh @@ -19,6 +19,8 @@ POWERSHELL_VERSION=${VERSION:-"latest"} POWERSHELL_MODULES="${MODULES:-""}" POWERSHELL_PROFILE_URL="${POWERSHELLPROFILEURL}" PRESERVE_HISTORY="${PRESERVEHISTORY}" +POWERSHELL_STABLE_LAST_KNOWN_VERSION="7.6.6" +POWERSHELL_PREVIEW_LAST_KNOWN_VERSION="7.7.0-preview.4" MICROSOFT_GPG_KEYS_URI="https://packages.microsoft.com/keys/microsoft.asc" #MICROSOFT_GPG_KEYS_URI=$(curl https://packages.microsoft.com/keys/microsoft.asc -o /usr/share/keyrings/microsoft-archive-keyring.gpg) @@ -353,6 +355,8 @@ get_github_api_repo_url() { echo "${url/https:\/\/github.com/https:\/\/api.github.com\/repos}/releases/latest" } +. "$(dirname "${BASH_SOURCE[0]}")/scripts/version-resolution.sh" + install_prev_pwsh() { pwsh_url=$1 @@ -393,9 +397,9 @@ install_using_github() { # Check if we need to find a preview version or stable version if [[ "${POWERSHELL_VERSION}" == *"preview"* ]] || [ "${POWERSHELL_VERSION}" = "preview" ] || [[ "${POWERSHELL_VERSION}" == *"-rc."* ]]; then echo "Finding preview version..." - find_preview_version_from_git_tags POWERSHELL_VERSION "${pwsh_url}" + find_version_from_git_tags POWERSHELL_VERSION "${pwsh_url}" "tags/v" "." "false" "(-preview\.[0-9]+|-rc\.[0-9]+)" "${POWERSHELL_PREVIEW_LAST_KNOWN_VERSION}" "GitHub REST API" _github_rest_version_candidates else - find_version_from_git_tags POWERSHELL_VERSION "${pwsh_url}" + find_version_from_git_tags POWERSHELL_VERSION "${pwsh_url}" "tags/v" "." "false" "" "${POWERSHELL_STABLE_LAST_KNOWN_VERSION}" fi install_pwsh "${POWERSHELL_VERSION}" diff --git a/src/powershell/scripts/version-resolution.sh b/src/powershell/scripts/version-resolution.sh new file mode 100644 index 000000000..d1b9d5f81 --- /dev/null +++ b/src/powershell/scripts/version-resolution.sh @@ -0,0 +1,140 @@ +#!/usr/bin/env bash + +# Resolve version requests from git tags, with a caller-provided fallback resolver. +# The fallback function must print candidate versions, one per line. +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 version_suffix_regex=${6:-""} + local known_good_version=${7:-""} + local fallback_name=${8:-""} + local fallback_function=${9:-""} + + if [ "${requested_version}" = "none" ]; then + return + fi + + local tag_prefix=${prefix#refs/} + tag_prefix=${tag_prefix#tags/} + local normalized_request=${requested_version#"${tag_prefix}"} + + local escaped_separator=${separator//./\.} + local patch_regex="${escaped_separator}[0-9]+" + if [ "${last_part_optional}" = "true" ]; then + patch_regex="(${patch_regex})?" + fi + local version_regex="[0-9]+${escaped_separator}[0-9]+${patch_regex}${version_suffix_regex//./\.}" + + # Fully qualified versions are user assertions. Do not require the network + # to prove that a requested release exists. + if echo "${normalized_request}" | grep -Eq "^${version_regex}$"; then + declare -g "${variable_name}=${normalized_request}" + echo "${variable_name}=${normalized_request}" + return + fi + + local version_list="" + local git_output="" + if git_output="$(git ls-remote --tags "${repository}" 2>/dev/null)"; then + version_list="$(_extract_version_candidates "${git_output}" "${prefix}" "${separator}" "${version_regex}")" + fi + + if [ -z "${version_list}" ]; then + echo "(!) Unable to resolve '${requested_version}' from git tags at ${repository}." >&2 + if [ -z "${fallback_function}" ] && echo "${repository}" | grep -qE '^https://github.com/[^/]+/[^/]+/?$'; then + fallback_name="GitHub REST API" + fallback_function="_github_rest_version_candidates" + fi + if [ -n "${fallback_function}" ]; then + echo "(*) Trying ${fallback_name:-alternate version source}." >&2 + local fallback_output="" + if fallback_output="$(${fallback_function} "${repository}" "${prefix}" 2>/dev/null)"; then + version_list="$(_normalize_version_candidates "${fallback_output}" "${separator}" "${version_regex}")" + fi + if [ -n "${version_list}" ]; then + echo "(*) Resolved version using ${fallback_name:-alternate version source}." >&2 + fi + fi + fi + + local resolved_version="" + resolved_version="$(_select_requested_version "${normalized_request}" "${version_list}")" + if [ -z "${resolved_version}" ] && [ -n "${known_good_version}" ] && _version_matches_request "${normalized_request}" "${known_good_version}"; then + resolved_version="${known_good_version}" + echo "(!) Dynamic version resolution failed; using known-good version ${known_good_version}." >&2 + fi + + if [ -z "${resolved_version}" ]; then + echo "(!) Unable to resolve '${requested_version}' for ${variable_name}. Tried git tags at ${repository}${fallback_name:+ and ${fallback_name}}. Specify an exact version to avoid remote resolution." >&2 + return 1 + fi + + declare -g "${variable_name}=${resolved_version}" + echo "${variable_name}=${resolved_version}" +} + +_github_rest_version_candidates() { + local repository=$1 + local prefix=${2:-"tags/v"} + local slug=${repository#https://github.com/} + slug=${slug%/} + local tag_prefix=${prefix#refs/} + tag_prefix=${tag_prefix#tags/} + + curl -fsSL "https://api.github.com/repos/${slug}/tags?per_page=100" \ + | grep -oE '"name"[[:space:]]*:[[:space:]]*"[^"]+"' \ + | sed -E 's/^.*"([^"]+)"$/\1/' \ + | sed "s|^${tag_prefix}||" +} + +_extract_version_candidates() { + local input=$1 + local prefix=$2 + local separator=$3 + local version_regex=$4 + local escaped_prefix=${prefix//./\.} + + printf '%s\n' "${input}" \ + | grep -oE "${escaped_prefix}${version_regex}$" \ + | sed "s|^${prefix}||" \ + | tr "${separator}" "." \ + | sort -rVu || true +} + +_normalize_version_candidates() { + local input=$1 + local separator=$2 + local version_regex=$3 + + printf '%s\n' "${input}" \ + | grep -oE "^${version_regex}$" \ + | tr "${separator}" "." \ + | sort -rVu || true +} + +_select_requested_version() { + local requested_version=$1 + local version_list=$2 + + if [ "${requested_version}" = "latest" ] || [ "${requested_version}" = "current" ] || [ "${requested_version}" = "stable" ] || [ "${requested_version}" = "lts" ] || [ "${requested_version}" = "prerelease" ] || [ "${requested_version}" = "preview" ]; then + printf '%s\n' "${version_list}" | head -n 1 + return + fi + + printf '%s\n' "${version_list}" | grep -E -m 1 "^${requested_version//./\.}([.]|[-]|$)" || true +} + +_version_matches_request() { + local requested_version=$1 + local candidate=$2 + + if [ "${requested_version}" = "latest" ] || [ "${requested_version}" = "current" ] || [ "${requested_version}" = "stable" ] || [ "${requested_version}" = "lts" ] || [ "${requested_version}" = "prerelease" ] || [ "${requested_version}" = "preview" ]; then + return 0 + fi + + echo "${candidate}" | grep -Eq "^${requested_version//./\.}([.]|[-]|$)" +} \ No newline at end of file diff --git a/src/python/devcontainer-feature.json b/src/python/devcontainer-feature.json index 6a4121415..8012bbc8e 100644 --- a/src/python/devcontainer-feature.json +++ b/src/python/devcontainer-feature.json @@ -1,6 +1,6 @@ { "id": "python", - "version": "1.8.0", + "version": "1.8.1", "name": "Python", "documentationURL": "https://github.com/devcontainers/features/tree/main/src/python", "description": "Installs the provided version of Python, as well as PIPX, and other common Python utilities. JupyterLab is conditionally installed with the python feature. Note: May require source code compilation.", diff --git a/src/python/install.sh b/src/python/install.sh index be895927d..8d3ff1f82 100755 --- a/src/python/install.sh +++ b/src/python/install.sh @@ -14,6 +14,9 @@ OPTIMIZE_BUILD_FROM_SOURCE="${OPTIMIZE:-"false"}" ENABLE_SHARED_FROM_SOURCE="${ENABLESHARED:-"false"}" PYTHON_INSTALL_PATH="${INSTALLPATH:-"/usr/local/python"}" OVERRIDE_DEFAULT_VERSION="${OVERRIDEDEFAULTVERSION:-"true"}" +PYTHON_LAST_KNOWN_VERSION="3.14.7" +OPENSSL3_LAST_KNOWN_VERSION="4.0.2" +COSIGN_LAST_KNOWN_VERSION="3.1.3" export PIPX_HOME=${PIPX_HOME:-"/usr/local/py-utils"} @@ -442,6 +445,8 @@ check_packages() { esac } +. "$(dirname "${BASH_SOURCE[0]}")/scripts/version-resolution.sh" + add_symlink() { if [[ ! -d "${CURRENT_PATH}" ]]; then ln -s -r "${INSTALL_PATH}" "${CURRENT_PATH}" @@ -461,7 +466,7 @@ install_openssl3() { cd /tmp/openssl3 openssl3_version="3.0" # Find version using soft match - find_version_from_git_tags openssl3_version "https://github.com/openssl/openssl" "openssl-" + find_version_from_git_tags openssl3_version "https://github.com/openssl/openssl" "openssl-" "." "false" "" "${OPENSSL3_LAST_KNOWN_VERSION}" local tgz_filename="openssl-${openssl3_version}.tar.gz" local tgz_url="https://github.com/openssl/openssl/releases/download/openssl-${openssl3_version}/${tgz_filename}" echo "Downloading ${tgz_filename}..." @@ -519,7 +524,7 @@ install_cosign() { local cosign_url='https://github.com/sigstore/cosign' local architecture=$(get_architecture) - find_version_from_git_tags COSIGN_VERSION "${cosign_url}" + find_version_from_git_tags COSIGN_VERSION "${cosign_url}" "tags/v" "." "false" "" "${COSIGN_LAST_KNOWN_VERSION}" # Remove 'v' prefix if present for download URL local version_for_url="${COSIGN_VERSION#v}" @@ -632,7 +637,7 @@ install_from_source() { fi # Find version using soft match - find_version_from_git_tags VERSION "https://github.com/python/cpython" + find_version_from_git_tags VERSION "https://github.com/python/cpython" "tags/v" "." "false" "" "${PYTHON_LAST_KNOWN_VERSION}" # Some platforms/os versions need modern versions of openssl installed # via common package repositories, for now rhel-7 family, use case statement to diff --git a/src/python/scripts/version-resolution.sh b/src/python/scripts/version-resolution.sh new file mode 100644 index 000000000..d1b9d5f81 --- /dev/null +++ b/src/python/scripts/version-resolution.sh @@ -0,0 +1,140 @@ +#!/usr/bin/env bash + +# Resolve version requests from git tags, with a caller-provided fallback resolver. +# The fallback function must print candidate versions, one per line. +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 version_suffix_regex=${6:-""} + local known_good_version=${7:-""} + local fallback_name=${8:-""} + local fallback_function=${9:-""} + + if [ "${requested_version}" = "none" ]; then + return + fi + + local tag_prefix=${prefix#refs/} + tag_prefix=${tag_prefix#tags/} + local normalized_request=${requested_version#"${tag_prefix}"} + + local escaped_separator=${separator//./\.} + local patch_regex="${escaped_separator}[0-9]+" + if [ "${last_part_optional}" = "true" ]; then + patch_regex="(${patch_regex})?" + fi + local version_regex="[0-9]+${escaped_separator}[0-9]+${patch_regex}${version_suffix_regex//./\.}" + + # Fully qualified versions are user assertions. Do not require the network + # to prove that a requested release exists. + if echo "${normalized_request}" | grep -Eq "^${version_regex}$"; then + declare -g "${variable_name}=${normalized_request}" + echo "${variable_name}=${normalized_request}" + return + fi + + local version_list="" + local git_output="" + if git_output="$(git ls-remote --tags "${repository}" 2>/dev/null)"; then + version_list="$(_extract_version_candidates "${git_output}" "${prefix}" "${separator}" "${version_regex}")" + fi + + if [ -z "${version_list}" ]; then + echo "(!) Unable to resolve '${requested_version}' from git tags at ${repository}." >&2 + if [ -z "${fallback_function}" ] && echo "${repository}" | grep -qE '^https://github.com/[^/]+/[^/]+/?$'; then + fallback_name="GitHub REST API" + fallback_function="_github_rest_version_candidates" + fi + if [ -n "${fallback_function}" ]; then + echo "(*) Trying ${fallback_name:-alternate version source}." >&2 + local fallback_output="" + if fallback_output="$(${fallback_function} "${repository}" "${prefix}" 2>/dev/null)"; then + version_list="$(_normalize_version_candidates "${fallback_output}" "${separator}" "${version_regex}")" + fi + if [ -n "${version_list}" ]; then + echo "(*) Resolved version using ${fallback_name:-alternate version source}." >&2 + fi + fi + fi + + local resolved_version="" + resolved_version="$(_select_requested_version "${normalized_request}" "${version_list}")" + if [ -z "${resolved_version}" ] && [ -n "${known_good_version}" ] && _version_matches_request "${normalized_request}" "${known_good_version}"; then + resolved_version="${known_good_version}" + echo "(!) Dynamic version resolution failed; using known-good version ${known_good_version}." >&2 + fi + + if [ -z "${resolved_version}" ]; then + echo "(!) Unable to resolve '${requested_version}' for ${variable_name}. Tried git tags at ${repository}${fallback_name:+ and ${fallback_name}}. Specify an exact version to avoid remote resolution." >&2 + return 1 + fi + + declare -g "${variable_name}=${resolved_version}" + echo "${variable_name}=${resolved_version}" +} + +_github_rest_version_candidates() { + local repository=$1 + local prefix=${2:-"tags/v"} + local slug=${repository#https://github.com/} + slug=${slug%/} + local tag_prefix=${prefix#refs/} + tag_prefix=${tag_prefix#tags/} + + curl -fsSL "https://api.github.com/repos/${slug}/tags?per_page=100" \ + | grep -oE '"name"[[:space:]]*:[[:space:]]*"[^"]+"' \ + | sed -E 's/^.*"([^"]+)"$/\1/' \ + | sed "s|^${tag_prefix}||" +} + +_extract_version_candidates() { + local input=$1 + local prefix=$2 + local separator=$3 + local version_regex=$4 + local escaped_prefix=${prefix//./\.} + + printf '%s\n' "${input}" \ + | grep -oE "${escaped_prefix}${version_regex}$" \ + | sed "s|^${prefix}||" \ + | tr "${separator}" "." \ + | sort -rVu || true +} + +_normalize_version_candidates() { + local input=$1 + local separator=$2 + local version_regex=$3 + + printf '%s\n' "${input}" \ + | grep -oE "^${version_regex}$" \ + | tr "${separator}" "." \ + | sort -rVu || true +} + +_select_requested_version() { + local requested_version=$1 + local version_list=$2 + + if [ "${requested_version}" = "latest" ] || [ "${requested_version}" = "current" ] || [ "${requested_version}" = "stable" ] || [ "${requested_version}" = "lts" ] || [ "${requested_version}" = "prerelease" ] || [ "${requested_version}" = "preview" ]; then + printf '%s\n' "${version_list}" | head -n 1 + return + fi + + printf '%s\n' "${version_list}" | grep -E -m 1 "^${requested_version//./\.}([.]|[-]|$)" || true +} + +_version_matches_request() { + local requested_version=$1 + local candidate=$2 + + if [ "${requested_version}" = "latest" ] || [ "${requested_version}" = "current" ] || [ "${requested_version}" = "stable" ] || [ "${requested_version}" = "lts" ] || [ "${requested_version}" = "prerelease" ] || [ "${requested_version}" = "preview" ]; then + return 0 + fi + + echo "${candidate}" | grep -Eq "^${requested_version//./\.}([.]|[-]|$)" +} \ No newline at end of file diff --git a/src/rust/devcontainer-feature.json b/src/rust/devcontainer-feature.json index f94d0dc18..b8baf3afc 100644 --- a/src/rust/devcontainer-feature.json +++ b/src/rust/devcontainer-feature.json @@ -1,6 +1,6 @@ { "id": "rust", - "version": "1.6.0", + "version": "1.6.1", "name": "Rust", "documentationURL": "https://github.com/devcontainers/features/tree/main/src/rust", "description": "Installs Rust, common Rust utilities, and their required dependencies", diff --git a/src/rust/install.sh b/src/rust/install.sh index 163dfc5c0..cfa01b624 100755 --- a/src/rust/install.sh +++ b/src/rust/install.sh @@ -10,6 +10,7 @@ RUST_VERSION="${VERSION:-"latest"}" RUSTUP_PROFILE="${PROFILE:-"minimal"}" RUSTUP_TARGETS="${TARGETS:-""}" +RUST_LAST_KNOWN_VERSION="1.98.1" # Set to "none" to install no components beyond the selected profile. RUSTUP_COMPONENTS="${COMPONENTS:-rust-analyzer,rust-src,rustfmt,clippy}" IFS=',' read -ra components <<< "${RUSTUP_COMPONENTS}" @@ -351,6 +352,8 @@ case ${download_architecture} in ;; esac +. "$(dirname "${BASH_SOURCE[0]}")/scripts/version-resolution.sh" + # Install Rust umask 0002 if ! grep -e "^rustlang:" /etc/group > /dev/null 2>&1; then @@ -375,7 +378,7 @@ else if [ $is_nightly = 0 ]; then check_nightly_version_formatting RUST_VERSION else - find_version_from_git_tags RUST_VERSION "https://github.com/rust-lang/rust" "tags/" + find_version_from_git_tags RUST_VERSION "https://github.com/rust-lang/rust" "tags/" "." "false" "" "${RUST_LAST_KNOWN_VERSION}" fi default_toolchain_arg="--default-toolchain ${RUST_VERSION}" fi diff --git a/src/rust/scripts/version-resolution.sh b/src/rust/scripts/version-resolution.sh new file mode 100644 index 000000000..d1b9d5f81 --- /dev/null +++ b/src/rust/scripts/version-resolution.sh @@ -0,0 +1,140 @@ +#!/usr/bin/env bash + +# Resolve version requests from git tags, with a caller-provided fallback resolver. +# The fallback function must print candidate versions, one per line. +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 version_suffix_regex=${6:-""} + local known_good_version=${7:-""} + local fallback_name=${8:-""} + local fallback_function=${9:-""} + + if [ "${requested_version}" = "none" ]; then + return + fi + + local tag_prefix=${prefix#refs/} + tag_prefix=${tag_prefix#tags/} + local normalized_request=${requested_version#"${tag_prefix}"} + + local escaped_separator=${separator//./\.} + local patch_regex="${escaped_separator}[0-9]+" + if [ "${last_part_optional}" = "true" ]; then + patch_regex="(${patch_regex})?" + fi + local version_regex="[0-9]+${escaped_separator}[0-9]+${patch_regex}${version_suffix_regex//./\.}" + + # Fully qualified versions are user assertions. Do not require the network + # to prove that a requested release exists. + if echo "${normalized_request}" | grep -Eq "^${version_regex}$"; then + declare -g "${variable_name}=${normalized_request}" + echo "${variable_name}=${normalized_request}" + return + fi + + local version_list="" + local git_output="" + if git_output="$(git ls-remote --tags "${repository}" 2>/dev/null)"; then + version_list="$(_extract_version_candidates "${git_output}" "${prefix}" "${separator}" "${version_regex}")" + fi + + if [ -z "${version_list}" ]; then + echo "(!) Unable to resolve '${requested_version}' from git tags at ${repository}." >&2 + if [ -z "${fallback_function}" ] && echo "${repository}" | grep -qE '^https://github.com/[^/]+/[^/]+/?$'; then + fallback_name="GitHub REST API" + fallback_function="_github_rest_version_candidates" + fi + if [ -n "${fallback_function}" ]; then + echo "(*) Trying ${fallback_name:-alternate version source}." >&2 + local fallback_output="" + if fallback_output="$(${fallback_function} "${repository}" "${prefix}" 2>/dev/null)"; then + version_list="$(_normalize_version_candidates "${fallback_output}" "${separator}" "${version_regex}")" + fi + if [ -n "${version_list}" ]; then + echo "(*) Resolved version using ${fallback_name:-alternate version source}." >&2 + fi + fi + fi + + local resolved_version="" + resolved_version="$(_select_requested_version "${normalized_request}" "${version_list}")" + if [ -z "${resolved_version}" ] && [ -n "${known_good_version}" ] && _version_matches_request "${normalized_request}" "${known_good_version}"; then + resolved_version="${known_good_version}" + echo "(!) Dynamic version resolution failed; using known-good version ${known_good_version}." >&2 + fi + + if [ -z "${resolved_version}" ]; then + echo "(!) Unable to resolve '${requested_version}' for ${variable_name}. Tried git tags at ${repository}${fallback_name:+ and ${fallback_name}}. Specify an exact version to avoid remote resolution." >&2 + return 1 + fi + + declare -g "${variable_name}=${resolved_version}" + echo "${variable_name}=${resolved_version}" +} + +_github_rest_version_candidates() { + local repository=$1 + local prefix=${2:-"tags/v"} + local slug=${repository#https://github.com/} + slug=${slug%/} + local tag_prefix=${prefix#refs/} + tag_prefix=${tag_prefix#tags/} + + curl -fsSL "https://api.github.com/repos/${slug}/tags?per_page=100" \ + | grep -oE '"name"[[:space:]]*:[[:space:]]*"[^"]+"' \ + | sed -E 's/^.*"([^"]+)"$/\1/' \ + | sed "s|^${tag_prefix}||" +} + +_extract_version_candidates() { + local input=$1 + local prefix=$2 + local separator=$3 + local version_regex=$4 + local escaped_prefix=${prefix//./\.} + + printf '%s\n' "${input}" \ + | grep -oE "${escaped_prefix}${version_regex}$" \ + | sed "s|^${prefix}||" \ + | tr "${separator}" "." \ + | sort -rVu || true +} + +_normalize_version_candidates() { + local input=$1 + local separator=$2 + local version_regex=$3 + + printf '%s\n' "${input}" \ + | grep -oE "^${version_regex}$" \ + | tr "${separator}" "." \ + | sort -rVu || true +} + +_select_requested_version() { + local requested_version=$1 + local version_list=$2 + + if [ "${requested_version}" = "latest" ] || [ "${requested_version}" = "current" ] || [ "${requested_version}" = "stable" ] || [ "${requested_version}" = "lts" ] || [ "${requested_version}" = "prerelease" ] || [ "${requested_version}" = "preview" ]; then + printf '%s\n' "${version_list}" | head -n 1 + return + fi + + printf '%s\n' "${version_list}" | grep -E -m 1 "^${requested_version//./\.}([.]|[-]|$)" || true +} + +_version_matches_request() { + local requested_version=$1 + local candidate=$2 + + if [ "${requested_version}" = "latest" ] || [ "${requested_version}" = "current" ] || [ "${requested_version}" = "stable" ] || [ "${requested_version}" = "lts" ] || [ "${requested_version}" = "prerelease" ] || [ "${requested_version}" = "preview" ]; then + return 0 + fi + + echo "${candidate}" | grep -Eq "^${requested_version//./\.}([.]|[-]|$)" +} \ No newline at end of file diff --git a/src/terraform/devcontainer-feature.json b/src/terraform/devcontainer-feature.json index 634d865ef..5355239cd 100644 --- a/src/terraform/devcontainer-feature.json +++ b/src/terraform/devcontainer-feature.json @@ -1,6 +1,6 @@ { "id": "terraform", - "version": "1.5.0", + "version": "1.5.1", "name": "Terraform, tflint, and TFGrunt", "documentationURL": "https://github.com/devcontainers/features/tree/main/src/terraform", "description": "Installs the Terraform CLI and optionally TFLint and Terragrunt. Auto-detects latest version and installs needed dependencies.", diff --git a/src/terraform/install.sh b/src/terraform/install.sh index 43779f825..68cff24b9 100755 --- a/src/terraform/install.sh +++ b/src/terraform/install.sh @@ -15,6 +15,12 @@ rm -rf /var/lib/apt/lists/* TERRAFORM_VERSION="${VERSION:-"latest"}" TFLINT_VERSION="${TFLINT:-"latest"}" TERRAGRUNT_VERSION="${TERRAGRUNT:-"latest"}" +TERRAFORM_LAST_KNOWN_VERSION="1.16.3" +TFLINT_LAST_KNOWN_VERSION="0.64.0" +TERRAGRUNT_LAST_KNOWN_VERSION="1.1.5" +TFSEC_LAST_KNOWN_VERSION="1.28.14" +TERRAFORM_DOCS_LAST_KNOWN_VERSION="0.24.0" +COSIGN_LAST_KNOWN_VERSION="3.1.3" INSTALL_SENTINEL=${INSTALLSENTINEL:-false} INSTALL_TFSEC=${INSTALLTFSEC:-false} INSTALL_TERRAFORM_DOCS=${INSTALLTERRAFORMDOCS:-false} @@ -349,6 +355,8 @@ install_cosign() { echo "Installation of cosign succeeded with ${COSIGN_VERSION}." } +. "$(dirname "${BASH_SOURCE[0]}")/scripts/version-resolution.sh" + # Install 'cosign' for validating signatures # https://docs.sigstore.dev/cosign/overview/ ensure_cosign() { @@ -358,7 +366,7 @@ ensure_cosign() { echo "Installing cosign..." COSIGN_VERSION="latest" cosign_url='https://github.com/sigstore/cosign' - find_version_from_git_tags COSIGN_VERSION "${cosign_url}" + find_version_from_git_tags COSIGN_VERSION "${cosign_url}" "tags/v" "." "false" "" "${COSIGN_LAST_KNOWN_VERSION}" install_cosign "${COSIGN_VERSION}" "${cosign_url}" fi if ! type cosign > /dev/null 2>&1; then @@ -381,9 +389,9 @@ terraform_url='https://github.com/hashicorp/terraform' tflint_url='https://github.com/terraform-linters/tflint' terragrunt_url='https://github.com/gruntwork-io/terragrunt' # Verify requested version is available, convert latest -find_version_from_git_tags TERRAFORM_VERSION "$terraform_url" -find_version_from_git_tags TFLINT_VERSION "$tflint_url" -find_version_from_git_tags TERRAGRUNT_VERSION "$terragrunt_url" +find_version_from_git_tags TERRAFORM_VERSION "$terraform_url" "tags/v" "." "false" "" "${TERRAFORM_LAST_KNOWN_VERSION}" +find_version_from_git_tags TFLINT_VERSION "$tflint_url" "tags/v" "." "false" "" "${TFLINT_LAST_KNOWN_VERSION}" +find_version_from_git_tags TERRAGRUNT_VERSION "$terragrunt_url" "tags/v" "." "false" "" "${TERRAGRUNT_LAST_KNOWN_VERSION}" install_terraform() { local TERRAFORM_VERSION=$1 @@ -613,7 +621,7 @@ install_tfsec() { if [ "${INSTALL_TFSEC}" = "true" ]; then TFSEC_VERSION="latest" tfsec_url='https://github.com/aquasecurity/tfsec' - find_version_from_git_tags TFSEC_VERSION $tfsec_url + find_version_from_git_tags TFSEC_VERSION "$tfsec_url" "tags/v" "." "false" "" "${TFSEC_LAST_KNOWN_VERSION}" tfsec_filename="tfsec_${TFSEC_VERSION}_linux_${architecture}.tar.gz" echo "(*) Downloading TFSec... ${tfsec_filename}" install_tfsec "$TFSEC_VERSION" @@ -643,7 +651,7 @@ install_terraform_docs() { if [ "${INSTALL_TERRAFORM_DOCS}" = "true" ]; then terraform_docs_url='https://github.com/terraform-docs/terraform-docs' - find_version_from_git_tags TERRAFORM_DOCS_VERSION $terraform_docs_url + find_version_from_git_tags TERRAFORM_DOCS_VERSION "$terraform_docs_url" "tags/v" "." "false" "" "${TERRAFORM_DOCS_LAST_KNOWN_VERSION}" tfdocs_filename="terraform-docs-v${TERRAFORM_DOCS_VERSION}-linux-${architecture}.tar.gz" echo "(*) Downloading Terraform docs... ${tfdocs_filename}" install_terraform_docs "$TERRAFORM_DOCS_VERSION" diff --git a/src/terraform/scripts/version-resolution.sh b/src/terraform/scripts/version-resolution.sh new file mode 100644 index 000000000..d1b9d5f81 --- /dev/null +++ b/src/terraform/scripts/version-resolution.sh @@ -0,0 +1,140 @@ +#!/usr/bin/env bash + +# Resolve version requests from git tags, with a caller-provided fallback resolver. +# The fallback function must print candidate versions, one per line. +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 version_suffix_regex=${6:-""} + local known_good_version=${7:-""} + local fallback_name=${8:-""} + local fallback_function=${9:-""} + + if [ "${requested_version}" = "none" ]; then + return + fi + + local tag_prefix=${prefix#refs/} + tag_prefix=${tag_prefix#tags/} + local normalized_request=${requested_version#"${tag_prefix}"} + + local escaped_separator=${separator//./\.} + local patch_regex="${escaped_separator}[0-9]+" + if [ "${last_part_optional}" = "true" ]; then + patch_regex="(${patch_regex})?" + fi + local version_regex="[0-9]+${escaped_separator}[0-9]+${patch_regex}${version_suffix_regex//./\.}" + + # Fully qualified versions are user assertions. Do not require the network + # to prove that a requested release exists. + if echo "${normalized_request}" | grep -Eq "^${version_regex}$"; then + declare -g "${variable_name}=${normalized_request}" + echo "${variable_name}=${normalized_request}" + return + fi + + local version_list="" + local git_output="" + if git_output="$(git ls-remote --tags "${repository}" 2>/dev/null)"; then + version_list="$(_extract_version_candidates "${git_output}" "${prefix}" "${separator}" "${version_regex}")" + fi + + if [ -z "${version_list}" ]; then + echo "(!) Unable to resolve '${requested_version}' from git tags at ${repository}." >&2 + if [ -z "${fallback_function}" ] && echo "${repository}" | grep -qE '^https://github.com/[^/]+/[^/]+/?$'; then + fallback_name="GitHub REST API" + fallback_function="_github_rest_version_candidates" + fi + if [ -n "${fallback_function}" ]; then + echo "(*) Trying ${fallback_name:-alternate version source}." >&2 + local fallback_output="" + if fallback_output="$(${fallback_function} "${repository}" "${prefix}" 2>/dev/null)"; then + version_list="$(_normalize_version_candidates "${fallback_output}" "${separator}" "${version_regex}")" + fi + if [ -n "${version_list}" ]; then + echo "(*) Resolved version using ${fallback_name:-alternate version source}." >&2 + fi + fi + fi + + local resolved_version="" + resolved_version="$(_select_requested_version "${normalized_request}" "${version_list}")" + if [ -z "${resolved_version}" ] && [ -n "${known_good_version}" ] && _version_matches_request "${normalized_request}" "${known_good_version}"; then + resolved_version="${known_good_version}" + echo "(!) Dynamic version resolution failed; using known-good version ${known_good_version}." >&2 + fi + + if [ -z "${resolved_version}" ]; then + echo "(!) Unable to resolve '${requested_version}' for ${variable_name}. Tried git tags at ${repository}${fallback_name:+ and ${fallback_name}}. Specify an exact version to avoid remote resolution." >&2 + return 1 + fi + + declare -g "${variable_name}=${resolved_version}" + echo "${variable_name}=${resolved_version}" +} + +_github_rest_version_candidates() { + local repository=$1 + local prefix=${2:-"tags/v"} + local slug=${repository#https://github.com/} + slug=${slug%/} + local tag_prefix=${prefix#refs/} + tag_prefix=${tag_prefix#tags/} + + curl -fsSL "https://api.github.com/repos/${slug}/tags?per_page=100" \ + | grep -oE '"name"[[:space:]]*:[[:space:]]*"[^"]+"' \ + | sed -E 's/^.*"([^"]+)"$/\1/' \ + | sed "s|^${tag_prefix}||" +} + +_extract_version_candidates() { + local input=$1 + local prefix=$2 + local separator=$3 + local version_regex=$4 + local escaped_prefix=${prefix//./\.} + + printf '%s\n' "${input}" \ + | grep -oE "${escaped_prefix}${version_regex}$" \ + | sed "s|^${prefix}||" \ + | tr "${separator}" "." \ + | sort -rVu || true +} + +_normalize_version_candidates() { + local input=$1 + local separator=$2 + local version_regex=$3 + + printf '%s\n' "${input}" \ + | grep -oE "^${version_regex}$" \ + | tr "${separator}" "." \ + | sort -rVu || true +} + +_select_requested_version() { + local requested_version=$1 + local version_list=$2 + + if [ "${requested_version}" = "latest" ] || [ "${requested_version}" = "current" ] || [ "${requested_version}" = "stable" ] || [ "${requested_version}" = "lts" ] || [ "${requested_version}" = "prerelease" ] || [ "${requested_version}" = "preview" ]; then + printf '%s\n' "${version_list}" | head -n 1 + return + fi + + printf '%s\n' "${version_list}" | grep -E -m 1 "^${requested_version//./\.}([.]|[-]|$)" || true +} + +_version_matches_request() { + local requested_version=$1 + local candidate=$2 + + if [ "${requested_version}" = "latest" ] || [ "${requested_version}" = "current" ] || [ "${requested_version}" = "stable" ] || [ "${requested_version}" = "lts" ] || [ "${requested_version}" = "prerelease" ] || [ "${requested_version}" = "preview" ]; then + return 0 + fi + + echo "${candidate}" | grep -Eq "^${requested_version//./\.}([.]|[-]|$)" +} \ No newline at end of file diff --git a/test/_global/version-resolution/cases.sh b/test/_global/version-resolution/cases.sh new file mode 100644 index 000000000..df7cbd29e --- /dev/null +++ b/test/_global/version-resolution/cases.sh @@ -0,0 +1,32 @@ +#!/usr/bin/env bash +# shellcheck disable=SC2034 +# feature|label|known-good variable|repository|prefix|optional patch|suffix regex|request +VERSION_RESOLUTION_CASES=( + "docker-in-docker|compose|DOCKER_COMPOSE_LAST_KNOWN_VERSION|https://github.com/docker/compose|tags/v|false||latest" + "docker-in-docker|compose switch|DOCKER_COMPOSE_SWITCH_LAST_KNOWN_VERSION|https://github.com/docker/compose-switch|tags/v|false||latest" + "docker-in-docker|buildx|DOCKER_BUILDX_LAST_KNOWN_VERSION|https://github.com/docker/buildx|refs/tags/v|false||latest" + "docker-outside-of-docker|compose|DOCKER_COMPOSE_LAST_KNOWN_VERSION|https://github.com/docker/compose|tags/v|false||latest" + "docker-outside-of-docker|compose switch|DOCKER_COMPOSE_SWITCH_LAST_KNOWN_VERSION|https://github.com/docker/compose-switch|tags/v|false||latest" + "github-cli|gh|GITHUB_CLI_LAST_KNOWN_VERSION|https://github.com/cli/cli|tags/v|false||latest" + "git-lfs|git lfs|GIT_LFS_LAST_KNOWN_VERSION|https://github.com/git-lfs/git-lfs|tags/v|false||latest" + "go|go|GO_LAST_KNOWN_VERSION|https://go.googlesource.com/go|tags/go|true||latest" + "kubectl-helm-minikube|kubectl|KUBECTL_LAST_KNOWN_VERSION|https://github.com/kubernetes/kubernetes|tags/v|false||latest" + "kubectl-helm-minikube|helm|HELM_LAST_KNOWN_VERSION|https://github.com/helm/helm|tags/v|false||latest" + "kubectl-helm-minikube|minikube|MINIKUBE_LAST_KNOWN_VERSION|https://github.com/kubernetes/minikube|tags/v|false||latest" + "nix|nix|NIX_LAST_KNOWN_VERSION|https://github.com/NixOS/nix|tags/|false||latest" + "node|nvm|NVM_LAST_KNOWN_VERSION|https://github.com/nvm-sh/nvm|tags/v|false||latest" + "php|php|PHP_LAST_KNOWN_VERSION|https://github.com/php/php-src|tags/php-|false||latest" + "php|xdebug|XDEBUG_LAST_KNOWN_VERSION|https://github.com/xdebug/xdebug|tags/|false||latest" + "powershell|stable|POWERSHELL_STABLE_LAST_KNOWN_VERSION|https://github.com/PowerShell/PowerShell|tags/v|false||latest" + "powershell|preview|POWERSHELL_PREVIEW_LAST_KNOWN_VERSION|https://github.com/PowerShell/PowerShell|tags/v|false|-preview\\.[0-9]+|preview" + "python|cpython|PYTHON_LAST_KNOWN_VERSION|https://github.com/python/cpython|tags/v|false||latest" + "python|openssl|OPENSSL3_LAST_KNOWN_VERSION|https://github.com/openssl/openssl|openssl-|false||latest" + "python|cosign|COSIGN_LAST_KNOWN_VERSION|https://github.com/sigstore/cosign|tags/v|false||latest" + "rust|rust|RUST_LAST_KNOWN_VERSION|https://github.com/rust-lang/rust|tags/|false||latest" + "copilot-cli|prerelease|COPILOT_CLI_LAST_KNOWN_VERSION|https://github.com/github/copilot-cli|tags/v|false|(-[0-9]+)|prerelease" + "terraform|terraform|TERRAFORM_LAST_KNOWN_VERSION|https://github.com/hashicorp/terraform|tags/v|false||latest" + "terraform|tflint|TFLINT_LAST_KNOWN_VERSION|https://github.com/terraform-linters/tflint|tags/v|false||latest" + "terraform|terragrunt|TERRAGRUNT_LAST_KNOWN_VERSION|https://github.com/gruntwork-io/terragrunt|tags/v|false||latest" + "terraform|tfsec|TFSEC_LAST_KNOWN_VERSION|https://github.com/aquasecurity/tfsec|tags/v|false||latest" + "terraform|terraform-docs|TERRAFORM_DOCS_LAST_KNOWN_VERSION|https://github.com/terraform-docs/terraform-docs|tags/v|false||latest" +) \ No newline at end of file diff --git a/test/_global/version-resolution/test.sh b/test/_global/version-resolution/test.sh new file mode 100644 index 000000000..b947e9015 --- /dev/null +++ b/test/_global/version-resolution/test.sh @@ -0,0 +1,226 @@ +#!/usr/bin/env bash +# shellcheck disable=SC2317,SC2329 +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)" +source "${ROOT_DIR}/scripts/version-resolution.sh" +# shellcheck disable=SC1091 +source "$(dirname "${BASH_SOURCE[0]}")/cases.sh" + +bash "${ROOT_DIR}/scripts/sync-version-resolution.sh" --check + +passed=0 + +pass() { + passed=$((passed + 1)) + printf 'ok %d - %s\n' "${passed}" "$1" +} + +fail() { + printf 'not ok %d - %s\n' "$((passed + 1))" "$1" >&2 + if [ -s /tmp/version-resolution-output.log ]; then + cat /tmp/version-resolution-output.log >&2 + fi + exit 1 +} + +network_calls=0 +git() { + network_calls=$((network_calls + 1)) + return 1 +} + +fallback_versions() { + printf '%s\n' "1.4.2" "1.3.9" +} + +curl() { + printf '[{"name":"%s"}]\n' "${FAKE_FALLBACK_TAG:-v2.3.4}" +} + +VERSION="1.2.3" +find_version_from_git_tags VERSION https://github.com/example/tool +if [ "${VERSION}" != "1.2.3" ] || [ "${network_calls}" -ne 0 ]; then + fail "exact versions avoid remote resolution" +fi +pass "exact versions avoid remote resolution" + +git() { + printf '%s\n' \ + 'abc refs/tags/v3.1.0' \ + 'def refs/tags/v3.2.1' +} +VERSION="3.2" +find_version_from_git_tags VERSION https://github.com/example/tool >/tmp/version-resolution-output.log 2>&1 +if [ "${VERSION}" != "3.2.1" ] || grep -q "Trying" /tmp/version-resolution-output.log; then + fail "primary git tags select the requested version line" +fi +pass "primary git tags select the requested version line" + +rm -f /tmp/version-resolution-network-call +git() { + touch /tmp/version-resolution-network-call + return 1 +} +VERSION="v1.33.0" +find_version_from_git_tags VERSION https://github.com/kubernetes/kubernetes >/tmp/version-resolution-output.log 2>&1 +if [ "${VERSION}" != "1.33.0" ] || [ -e /tmp/version-resolution-network-call ]; then + fail "prefixed exact versions normalize without remote resolution" +fi +pass "prefixed exact versions normalize without remote resolution" + +git() { + network_calls=$((network_calls + 1)) + return 1 +} + +VERSION="latest" +find_version_from_git_tags VERSION https://github.com/example/tool tags/v . false '' 1.2.3 'example releases API' fallback_versions >/tmp/version-resolution-output.log 2>&1 +if [ "${VERSION}" != "1.4.2" ]; then + fail "alternate resolver selects the latest version" +fi +if ! grep -q "Trying example releases API" /tmp/version-resolution-output.log; then + fail "alternate resolver warning identifies its source" +fi +pass "alternate resolver selects the latest version and reports its source" + +fallback_versions() { + return 1 +} +VERSION="1.2" +find_version_from_git_tags VERSION https://github.com/example/tool tags/v . false '' 1.2.3 'example releases API' fallback_versions >/tmp/version-resolution-output.log 2>&1 +if [ "${VERSION}" != "1.2.3" ] || ! grep -q "known-good version 1.2.3" /tmp/version-resolution-output.log; then + fail "compatible known-good fallback is selected and reported" +fi +pass "compatible known-good fallback is selected and reported" + +VERSION="2.0" +if find_version_from_git_tags VERSION https://github.com/example/tool tags/v . false '' 1.2.3 'example releases API' fallback_versions >/tmp/version-resolution-error.log 2>&1; then + fail "incompatible known-good versions are rejected" +fi +if ! grep -q "Specify an exact version" /tmp/version-resolution-error.log; then + fail "final resolution error is actionable" +fi +pass "incompatible known-good versions are rejected with actionable errors" + +fallback_versions() { + printf '%s\n' "not-a-version" "release-next" +} +VERSION="latest" +if find_version_from_git_tags VERSION https://github.com/example/tool tags/v . false '' '' 'malformed source' fallback_versions >/tmp/version-resolution-error.log 2>&1; then + fail "malformed fallback versions are rejected" +fi +pass "malformed fallback versions are rejected" + +VERSION="latest" +find_version_from_git_tags VERSION https://github.com/example/unpinned >/tmp/version-resolution-output.log 2>&1 +if [ "${VERSION}" != "2.3.4" ] || ! grep -q "GitHub REST API" /tmp/version-resolution-output.log; then + fail "automatic GitHub REST fallback selects and reports a version" +fi +pass "automatic GitHub REST fallback selects and reports a version" + +required_features=( + copilot-cli + docker-in-docker + docker-outside-of-docker + git-lfs + github-cli + go + kubectl-helm-minikube + nix + node + php + powershell + python + rust + terraform +) +declare -A covered_features=() +for test_case in "${VERSION_RESOLUTION_CASES[@]}"; do + IFS='|' read -r feature _ <<< "${test_case}" + covered_features["${feature}"]=true +done +for feature in "${required_features[@]}"; do + if [ "${covered_features[${feature}]:-false}" != "true" ]; then + fail "fallback matrix covers ${feature}" + fi +done +pass "fallback matrix covers all ${#required_features[@]} required features" + +for test_case in "${VERSION_RESOLUTION_CASES[@]}"; do + IFS='|' read -r feature label known_good_variable repository prefix last_part_optional suffix_regex request <<< "${test_case}" + installer="${ROOT_DIR}/src/${feature}/install.sh" + expected="$(sed -n "s/^${known_good_variable}=\"\(.*\)\"$/\1/p" "${installer}")" + if [ -z "${expected}" ]; then + fail "${feature}/${label}: ${known_good_variable} is declared at the top level" + fi + if ! grep 'find_version_from_git_tags' "${installer}" | grep -Fq "\${${known_good_variable}}"; then + fail "${feature}/${label}: resolver uses ${known_good_variable}" + fi + pass "${feature}/${label}: installer wires ${known_good_variable}" + + tag_prefix=${prefix#refs/} + tag_prefix=${tag_prefix#tags/} + fallback_tag="${tag_prefix}${expected}" + VERSION="${expected}" + rm -f /tmp/version-resolution-network-call + git() { + touch /tmp/version-resolution-network-call + return 1 + } + find_version_from_git_tags VERSION "${repository}" "${prefix}" . "${last_part_optional}" "${suffix_regex}" "${expected}" >/tmp/version-resolution-output.log 2>&1 + if [ -e /tmp/version-resolution-network-call ]; then + fail "${feature}/${label}: exact version avoids remote resolution" + fi + pass "${feature}/${label}: exact version avoids remote resolution" + + git() { + return 1 + } + FAKE_FALLBACK_TAG="${fallback_tag}" + VERSION="${request}" + if [ "${feature}" = "go" ]; then + go_fallback_versions() { + printf '%s\n' "${expected}" + } + find_version_from_git_tags VERSION "${repository}" "${prefix}" . "${last_part_optional}" "${suffix_regex}" "${expected}" "the official Go release index" go_fallback_versions >/tmp/version-resolution-output.log 2>&1 + else + find_version_from_git_tags VERSION "${repository}" "${prefix}" . "${last_part_optional}" "${suffix_regex}" "${expected}" >/tmp/version-resolution-output.log 2>&1 + fi + if [ "${VERSION}" != "${expected}" ]; then + fail "${feature}/${label}: alternate source selects ${expected}" + fi + if ! grep -qE 'Trying (GitHub REST API|the official Go release index)' /tmp/version-resolution-output.log; then + fail "${feature}/${label}: alternate-source warning identifies its source" + fi + pass "${feature}/${label}: alternate source selects ${expected} and is reported" + + git() { + return 1 + } + curl() { + return 1 + } + fallback_unavailable() { + return 1 + } + VERSION="${request}" + if [ "${feature}" = "go" ]; then + find_version_from_git_tags VERSION "${repository}" "${prefix}" . "${last_part_optional}" "${suffix_regex}" "${expected}" "the official Go release index" fallback_unavailable >/tmp/version-resolution-output.log 2>&1 + elif [ "${feature}" = "powershell" ] && [ "${label}" = "preview" ]; then + find_version_from_git_tags VERSION "${repository}" "${prefix}" . "${last_part_optional}" "${suffix_regex}" "${expected}" "GitHub REST API" fallback_unavailable >/tmp/version-resolution-output.log 2>&1 + else + find_version_from_git_tags VERSION "${repository}" "${prefix}" . "${last_part_optional}" "${suffix_regex}" "${expected}" >/tmp/version-resolution-output.log 2>&1 + fi + if [ "${VERSION}" != "${expected}" ] || ! grep -q "known-good version ${expected}" /tmp/version-resolution-output.log; then + fail "${feature}/${label}: unavailable alternate source selects known-good ${expected}" + fi + pass "${feature}/${label}: unavailable alternate source selects known-good ${expected}" + + curl() { + printf '[{"name":"%s"}]\n' "${FAKE_FALLBACK_TAG:-v2.3.4}" + } +done + +printf '1..%d\n' "${passed}" +printf 'Version resolution tests passed: %d assertions.\n' "${passed}" \ No newline at end of file