Skip to content

Instantly share code, notes, and snippets.

@mrliptontea
Created March 12, 2026 11:15
Show Gist options
  • Select an option

  • Save mrliptontea/b928cea4dad0c9bfce98b840b3dfaab6 to your computer and use it in GitHub Desktop.

Select an option

Save mrliptontea/b928cea4dad0c9bfce98b840b3dfaab6 to your computer and use it in GitHub Desktop.
Download artifacts from CircleCI.
#!/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