Created
March 12, 2026 11:15
-
-
Save mrliptontea/b928cea4dad0c9bfce98b840b3dfaab6 to your computer and use it in GitHub Desktop.
Download artifacts from CircleCI.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/bin/bash | |
| SELF_NAME="$(basename "$0")" | |
| RESET='' | |
| BOLD='' | |
| DIM='' | |
| RED='' | |
| GREEN='' | |
| YELLOW='' | |
| CYAN='' | |
| if test -t 1 && test -n "$(tput colors)"; then | |
| RESET="$(tput sgr0)" | |
| BOLD="$(tput bold)" | |
| DIM="$(tput dim || true)" | |
| RED="$(tput setaf 1)" | |
| GREEN="$(tput setaf 2)" | |
| YELLOW="$(tput setaf 3)" | |
| CYAN="$(tput setaf 6)" | |
| fi | |
| usage() { | |
| cat <<EOS | |
| Usage: $SELF_NAME <REPOSITORY> <IDENTIFIER> [OPTION]... | |
| Download artifacts from CircleCI. | |
| ${BOLD}Arguments:${RESET} | |
| REPOSITORY Name of the repository in the format of [<vcs>/]<owner>/<repo>. | |
| Example: gh/acme/foobar or acme/foobar | |
| IDENTIFIER CircleCI pipeline number or ID, or workflow ID, or job number. | |
| ${BOLD}Options:${RESET} | |
| -h, --help Display this message and exit. | |
| -j, --job Identifier is a job number (default: auto-detect). | |
| -p, --pipeline Identifier is a pipeline number or ID (default: auto-detect). | |
| -w, --workflow Identifier is a workflow ID (default: auto-detect). | |
| -o, --output-dir Directory to save the downloaded artifacts (default: current directory). | |
| -g, --glob-pattern Only download artifacts matching the given glob patterns (repeatable). | |
| Example: -g "build/*.zip" -g "*.log" | |
| --failed-only Only download artifacts from failed jobs. | |
| --list-only Only list artifact paths without downloading. | |
| ${BOLD}Examples:${RESET} | |
| ${DIM}# Download all artifacts by an identifier auto-detected as pipeline number${RESET} | |
| $SELF_NAME acme/foobar 12345 | |
| ${DIM}# Download all artifacts by job number from persona-server repository${RESET} | |
| $SELF_NAME acme/foobar -j 345 | |
| ${DIM}# Download all artifacts by workflow ID${RESET} | |
| $SELF_NAME acme/foobar -w abcdef12-3456-7890-abcd-ef1234567890 | |
| ${DIM}# Download artifacts with .log, .txt, or .xml extension${RESET} | |
| $SELF_NAME acme/foobar 23456 -g '*.log' -g '*.txt' -g '*.xml' | |
| ${DIM}# Download zip artifacts from failed jobs from pipeline 12345 to 'artifacts' directory${RESET} | |
| $SELF_NAME acme/foobar -p 12345 -o artifacts -g "build/*.zip" --failed-only | |
| ${DIM}# List artifact paths without downloading${RESET} | |
| $SELF_NAME acme/foobar -p 12345 --list-only | |
| EOS | |
| } | |
| log() { | |
| echo >&2 -e "$*" | |
| } | |
| log_error() { | |
| log "${RED}$*${RESET}"; | |
| } | |
| log_info() { | |
| log "${GREEN}$*${RESET}"; | |
| } | |
| log_warn() { | |
| log "${YELLOW}$*${RESET}"; | |
| } | |
| draw_progress() { | |
| local count="$1" | |
| local total="$2" | |
| local width=36 | |
| if [ "$total" -le 0 ]; then | |
| return | |
| fi | |
| local filled=$(( count * width / total )) | |
| local empty=$(( width - filled )) | |
| local percent=$(( count * 100 / total )) | |
| local filled_bar empty_bar | |
| printf -v filled_bar '%*s' "$filled" '' | |
| filled_bar=${filled_bar// /#} | |
| printf -v empty_bar '%*s' "$empty" '' | |
| empty_bar=${empty_bar// /-} | |
| printf '\r[%s%s] %3d%% (%d/%d)' "$filled_bar" "$empty_bar" "$percent" "$count" "$total" >&2 | |
| if [ "$count" -ge "$total" ]; then | |
| printf '\n' >&2 | |
| fi | |
| } | |
| ## Wrapper for calling cURL to CircleCI API | |
| # Arguments: | |
| # cURL options | |
| curl_circleci() { | |
| local curl_opts=("$@") | |
| if [[ -n "$DEBUG" ]]; then | |
| log "> ${CYAN}curl -sS -H \"Circle-Token: \$CIRCLE_TOKEN\" ${curl_opts[*]}${RESET}" | |
| fi | |
| curl \ | |
| --silent --show-error \ | |
| --retry 3 \ | |
| --retry-connrefused \ | |
| --retry-max-time 60 \ | |
| -H "Circle-Token: ${CIRCLE_TOKEN?:CircleCI token not set}" \ | |
| "${curl_opts[@]}" | |
| } | |
| paginate() { | |
| local base_url="$1" | |
| local url="$base_url" | |
| shift | |
| local jq_filters=("$@") | |
| local page_token response | |
| while true; do | |
| response=$(curl_circleci "$url") | |
| echo "$response" | jq "${jq_filters[@]}" | |
| page_token=$(echo "$response" | jq -r '.next_page_token // empty') | |
| if [ -n "$page_token" ]; then | |
| url="${base_url}?page-token=${page_token}" | |
| else | |
| break | |
| fi | |
| done | |
| } | |
| GLOB_PATTERNS=() | |
| glob_artifact() { | |
| local artifact_path="$1" | |
| if [ "${#GLOB_PATTERNS[@]}" -eq 0 ]; then | |
| return 0 | |
| fi | |
| for GLOB_PATTERN in "${GLOB_PATTERNS[@]}"; do | |
| # shellcheck disable=SC2053 # We want to use glob pattern matching here | |
| if [[ "$artifact_path" == $GLOB_PATTERN ]]; then | |
| return 0 | |
| fi | |
| done | |
| return 1 | |
| } | |
| IS_JOB=false | |
| IS_PIPELINE=false | |
| IS_WORKFLOW=false | |
| OUTPUT_DIR="." | |
| FILTER_JOB_STATUS="" | |
| LIST_ONLY=false | |
| arg_num=0 | |
| while [ "$#" -gt 0 ]; do | |
| case "$1" in | |
| -h|--help) usage; exit 0 ;; | |
| -j|--job) IS_JOB=true ;; | |
| -p|--pipeline) IS_PIPELINE=true ;; | |
| -w|--workflow) IS_WORKFLOW=true ;; | |
| -o|--output-dir) shift; OUTPUT_DIR="$1" ;; | |
| -g|--glob-pattern) shift; GLOB_PATTERNS+=("$1") ;; | |
| --failed-only) FILTER_JOB_STATUS=failed ;; | |
| --list-only) LIST_ONLY=true ;; | |
| -*) log_error "unknown option: $1"; usage; exit 1 ;; | |
| *) | |
| arg_num=$((arg_num + 1)) | |
| case "$arg_num" in | |
| 1) REPOSITORY="$1" ;; | |
| 2) IDENTIFIER="$1" ;; | |
| *) log_error "too many arguments: $1"; usage; exit 1 ;; | |
| esac | |
| ;; | |
| esac | |
| shift | |
| done | |
| if [ -z "$REPOSITORY" ]; then | |
| log_error "Missing repository name"; usage; exit 1 | |
| fi | |
| if [ -z "$IDENTIFIER" ]; then | |
| log_error "Missing pipeline number or workflow ID"; usage; exit 1 | |
| fi | |
| # <vcs>/<owner>/<repo> | |
| if [[ "$REPOSITORY" =~ ^[^/]+/[^/]+/[^/]+$ ]]; then | |
| PROJECT_SLUG="${REPOSITORY}" | |
| # <owner>/<repo> | |
| elif [[ "$REPOSITORY" =~ ^[^/]+/[^/]+$ ]]; then | |
| PROJECT_SLUG="gh/${REPOSITORY}" | |
| else | |
| log_error "Invalid repository format: $REPOSITORY"; usage; exit 1 | |
| fi | |
| JOB_NUMBERS=() | |
| WORKFLOW_IDS=() | |
| PIPELINE_NUMBER="" | |
| PIPELINE_ID="" | |
| FILES_TO_DOWNLOAD=() | |
| if [ "$IS_JOB" = true ]; then | |
| JOB_NUMBERS+=("$IDENTIFIER") | |
| elif [ "$IS_WORKFLOW" = true ]; then | |
| WORKFLOW_IDS+=("$IDENTIFIER") | |
| else | |
| # Check if identifier is a UUID | |
| if [[ "$IDENTIFIER" =~ ^\{?[A-F0-9a-f]{8}-[A-F0-9a-f]{4}-[A-F0-9a-f]{4}-[A-F0-9a-f]{4}-[A-F0-9a-f]{12}\}?$ ]]; then | |
| # Check if it's a pipeline ID | |
| if [ "$IS_PIPELINE" = true ] || [[ $(curl_circleci "https://circleci.com/api/v2/pipeline/${IDENTIFIER}" | jq -r '.id') == "$IDENTIFIER" ]]; then | |
| PIPELINE_ID="$IDENTIFIER" | |
| else # Assume it's a workflow ID | |
| WORKFLOW_IDS+=("$IDENTIFIER") | |
| fi | |
| else # Not a UUID, check job or pipeline number | |
| # Check if it's a job number | |
| if [[ $(curl_circleci "https://circleci.com/api/v2/project/${PROJECT_SLUG}/job/${IDENTIFIER}" | jq -r '.number') == "$IDENTIFIER" ]]; then | |
| JOB_NUMBER="$IDENTIFIER" | |
| else # Retrieve pipeline ID from pipeline number | |
| PIPELINE_NUMBER="$IDENTIFIER" | |
| PIPELINE_ID=$(curl_circleci "https://circleci.com/api/v2/project/${PROJECT_SLUG}/pipeline/${PIPELINE_NUMBER}" | jq -r '.id') | |
| fi | |
| fi | |
| fi | |
| if [ -n "$JOB_NUMBER" ]; then | |
| JOB_NUMBERS+=("$JOB_NUMBER") | |
| else | |
| if [[ -n "$PIPELINE_ID" ]]; then | |
| log_info "Fetching workflow IDs for pipeline $PIPELINE_ID..." | |
| while IFS= read -r line; do | |
| if [ -n "$line" ] && [[ ${line//-/} =~ ^[A-F0-9a-f]+$ ]]; then | |
| WORKFLOW_IDS+=("$line") | |
| fi | |
| done < <(paginate "https://circleci.com/api/v2/pipeline/${PIPELINE_ID}/workflow" '.items[].id' -r) | |
| fi | |
| for WORKFLOW_ID in "${WORKFLOW_IDS[@]}"; do | |
| log_info "Fetching job IDs for workflow ${WORKFLOW_ID}..." | |
| while IFS= read -r line; do | |
| if [ -n "$line" ] && [[ "$line" =~ ^[0-9]+$ ]]; then | |
| JOB_NUMBERS+=("$line") | |
| fi | |
| done < <(paginate "https://circleci.com/api/v2/workflow/${WORKFLOW_ID}/job" '.items[].job_number' -r) | |
| done | |
| fi | |
| for JOB_NUMBER in "${JOB_NUMBERS[@]}"; do | |
| job_details=$(curl_circleci "https://circleci.com/api/v2/project/${PROJECT_SLUG}/job/${JOB_NUMBER}") | |
| JOB_NAME=$(echo "$job_details" | jq -r '.name') | |
| if [ -n "$FILTER_JOB_STATUS" ]; then | |
| job_status=$(echo "$job_details" | jq -r '.status') | |
| if [ -z "$job_status" ] || [ "$job_status" = "null" ]; then | |
| continue | |
| fi | |
| if [ "$job_status" != "$FILTER_JOB_STATUS" ]; then | |
| log_warn "Skipping job ${JOB_NUMBER} (${JOB_NAME}) because its status is '${job_status}'." | |
| continue | |
| fi | |
| fi | |
| log_info "Fetching artifacts for job ${JOB_NUMBER} (${JOB_NAME})..." | |
| while IFS= read -r line; do | |
| if [ -n "$line" ]; then | |
| ARTIFACT_PATH=$(echo "$line" | jq -r '.path') | |
| if ! glob_artifact "$ARTIFACT_PATH"; then | |
| continue | |
| fi | |
| item=$(echo "$line" | jq --arg job_name "$JOB_NAME" -c '{path: .path, url: .url, job_name: $job_name}') | |
| FILES_TO_DOWNLOAD+=("$item") | |
| fi | |
| done < <(paginate "https://circleci.com/api/v2/project/${PROJECT_SLUG}/${JOB_NUMBER}/artifacts" '.items[] | {path: .path, url: .url}' -c) | |
| done | |
| TOTAL_FILES=${#FILES_TO_DOWNLOAD[@]} | |
| FILE="file" | |
| if [ "$TOTAL_FILES" -ne 1 ]; then | |
| FILE="files" | |
| fi | |
| log_info "Found ${TOTAL_FILES} ${FILE}." | |
| if [ "$LIST_ONLY" = false ] && [ "$TOTAL_FILES" -gt 0 ]; then | |
| draw_progress 0 "$TOTAL_FILES" | |
| fi | |
| DOWNLOADED_FILES=0 | |
| for item in "${FILES_TO_DOWNLOAD[@]}"; do | |
| ARTIFACT_URL=$(echo "$item" | jq -r '.url') | |
| if [ "$LIST_ONLY" = true ]; then | |
| echo "$ARTIFACT_URL" | |
| else | |
| ARTIFACT_PATH=$(echo "$item" | jq -r '.path') | |
| JOB_NAME=$(echo "$item" | jq -r '.job_name') | |
| OUTPUT_PATH="${OUTPUT_DIR}/${JOB_NAME}/${ARTIFACT_PATH}" | |
| mkdir -p "$(dirname "$OUTPUT_PATH")" | |
| curl_circleci -L -o "$OUTPUT_PATH" "$ARTIFACT_URL" | |
| DOWNLOADED_FILES=$((DOWNLOADED_FILES + 1)) | |
| draw_progress "$DOWNLOADED_FILES" "$TOTAL_FILES" | |
| fi | |
| done | |
| if [ "$LIST_ONLY" = false ]; then | |
| log_info "Downloaded ${DOWNLOADED_FILES} ${FILE} to '${OUTPUT_DIR}'." | |
| fi |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment