conditional-builds.sh 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258
  1. #! /usr/bin/env bash
  2. # This script is copied from
  3. # https://github.com/labs42io/circleci-monorepo/blob/v2/.circleci/monorepo.sh
  4. set -e
  5. readonly REPO_TYPE=$( echo "${CIRCLE_REPOSITORY_URL}" | awk '{ match($0,/@github/) ? r="github" : r="bitbucket"; print r }' )
  6. readonly PROJECT_SLUG="${REPO_TYPE}/${CIRCLE_PROJECT_USERNAME}/${CIRCLE_PROJECT_REPONAME}"
  7. readonly SCRIPT_DIR=$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )
  8. readonly TMP_DIR=${SCRIPT_DIR}/temp
  9. readonly CONFIG_FILE=${SCRIPT_DIR}/groups.json
  10. readonly CONCURRENCY=8
  11. readonly TRIGGER_PARAM_NAME="trigger"
  12. readonly BUILDS_FILE=${TMP_DIR}/builds.json
  13. readonly DATA_FILE=${TMP_DIR}/data.json
  14. # Get the list of configured groups or default ones.
  15. function read_config_groups {
  16. c=$(jq --raw-output '(.groups // {}) | length' "$1")
  17. if [[ "${c}" == "0" ]]; then
  18. root_dir=$(jq --raw-output '.root // "groups"' "$1")
  19. find "${root_dir}/" -mindepth 1 -maxdepth 1 -type d -exec basename {} \; | awk -v d="${root_dir}" '{print $1 " " d "/" $1 "/"}'
  20. else
  21. jq -r '.groups | to_entries | map(([.key] + .value) | join(" ")) | join ("\n")' "$1"
  22. fi
  23. }
  24. # Download workflows status from CircleCI API (as JSON files).
  25. function get_workflows {
  26. seq 0 100 $((($1 - 1) * 100)) | \
  27. awk \
  28. -v api="https://circleci.com/api/v1.1/project/${PROJECT_SLUG}" \
  29. -v tree="/tree/${CIRCLE_BRANCH}" \
  30. -v token="${CIRCLE_API_TOKEN}" \
  31. -v dir="${TMP_DIR}/data." \
  32. '{ print $1 " " token " " api tree "?shallow=true&limit=100&offset=" $1 " " dir sprintf("%04d", $1) ".json" }' |\
  33. xargs -n4 -P${CONCURRENCY} bash -c 'curl -u "$1:" -L -Ss -o $3 -w "\tGET: [%{response_code}] %{url_effective}\n" $2'
  34. }
  35. # Creates a map of workflows and commit SHAs for which build passed.
  36. function map {
  37. # Group by (workflow, commit sha, job name) and select
  38. # those workflows for which each job group contains at least one passed job.
  39. jq '.? |
  40. group_by(.workflows.workflow_name) |
  41. map({
  42. (.[0].workflows.workflow_name):
  43. group_by(.vcs_revision) |
  44. map({
  45. commit: .[0].vcs_revision,
  46. queued_at: .[0].queued_at,
  47. jobs: group_by(.workflows.job_name) | map({ success: any(.status == "success") })
  48. }) |
  49. map(select(.jobs | all(.success))) |
  50. sort_by(.queued_at) |
  51. reverse |
  52. map(.commit)
  53. }) |
  54. add |
  55. select (. != null)'
  56. }
  57. # Get the nearest commit from which the current branch was created.
  58. function get_parent_commit {
  59. git_file="${TMP_DIR}/branches.txt"
  60. commit_sha=$1
  61. if [[ ! -f "${git_file}" ]]; then
  62. git show-branch --topo-order --sha1-name --current --remote > "${git_file}"
  63. fi
  64. remote_name=$(git remote show | head -n1)
  65. indents=$(\
  66. sed 's/].*/]/' "${git_file}" | # remove commit message
  67. awk '/^\-/ {exit} {print}' | # get lines until commits are listed
  68. awk -F '' -v b="[${remote_name}/${CIRCLE_BRANCH}]" 'match($0,/^ *\*/) || index($0, b)' | # get only current branch and remote
  69. awk -F '' '{ t = length($0); sub("^ *",""); print t - length($0) + 1 }') # calculate indentation level
  70. head_indent=$(\
  71. sed 's/].*/]/' "${git_file}" | # remove commit message
  72. awk '/^\-/ {exit} {print}' | # get lines until commits are listed
  73. awk -F '' -v b="[${remote_name}/HEAD]" 'index($0, b)' | # get origin/HEAD line
  74. awk -F '' '{ t = length($0); sub("^ *",""); print t - length($0) + 1 }') # calculate indentation level
  75. i1=$(echo "${indents}" | head -n1)
  76. i2=$(echo "${indents}" | tail -n1)
  77. i3=${head_indent:-$i1}
  78. sed 's/].*//' "${git_file}" | # remove commit message
  79. awk -F '[' -v c="${commit_sha}" \
  80. 'c == "null" || f; c!="null" && length($2) > 0 && index(c, $2) == 1 { f = 1; print }' | # skip until first commit in current branch
  81. awk -F '' -v i="${i1}" 'match(substr($0, i, 1), /[\+\-\*]/)' | # filter only commits (including merges) related to current branch
  82. awk -F '' -v i="${i1}" '{ print substr($0, 1, i - 1) " " substr($0, i + 1) }' | # excludes current branch
  83. awk -F '' -v i="${i2}" '{ print substr($0, 1, i - 1) " " substr($0, i + 1) }' | # excludes current remote branch
  84. awk -F '' -v i="${i3}" '{ print substr($0, 1, i - 1) " " substr($0, i + 1) }' | # excludes origin/HEAD branch
  85. awk -F '' 'gsub(/ /, "", $0)' | # remove white-space
  86. awk -F '' '/[\+\-]+\[/' | # match only lines with commit or merge
  87. head -n1 | # get the top most found commit
  88. sed 's/^.*\[//' # leave only the commit sha text
  89. }
  90. # GIT diff each package to calculate the number of changed files.
  91. function diff {
  92. parent_sha=$1
  93. builds_file=$2
  94. while read -r package paths; do
  95. last_build_sha=$(jq --raw-output --arg p "${package}" '.[$p][0]' "${builds_file}")
  96. if [[ "${last_build_sha}" != "null" && "x${last_build_sha}" != "x" ]]; then
  97. # diff changes since most recent successfull build for current workflow
  98. echo "$(git diff "${last_build_sha}"..HEAD --name-only -- ${paths} | wc -l)" "${last_build_sha:0:9}" built "${package}"
  99. elif [[ "x${parent_sha}" != "x" ]]; then
  100. # diff changes since parent branch commit sha
  101. echo "$(git diff "${parent_sha}"..HEAD --name-only -- ${paths} | wc -l)" "${parent_sha:0:9}" new "${package}"
  102. else
  103. # no builds and missing parent branch (detached?)
  104. echo 99999 - new "${package}"
  105. fi
  106. done
  107. }
  108. function print_status {
  109. echo -e "\nTrigger\tExists\tChanges\tParent\t\tPackage\n$(printf '=%0.s' {1..60})"
  110. echo "$1" | jq --raw-output '
  111. def colors:
  112. {
  113. "red": "\u001b[31m",
  114. "green": "\u001b[32m",
  115. "yellow": "\u001b[33m",
  116. "default": "\u001b[39m",
  117. "reset": "\u001b[0m",
  118. };
  119. def choose_color(a):
  120. if .changes == 99999 then colors.red
  121. elif .changes > 0 then colors.yellow
  122. elif .branch == "built" then colors.green
  123. else colors.default
  124. end;
  125. .[] | choose_color(.) +
  126. (if .changes > 0 then "[x]" else "[ ]" end) + "\t" +
  127. (if .branch == "built" then "[x]" else "[ ]" end) + "\t" +
  128. (.changes | tostring) + "\t" +
  129. .parent + "\t" +
  130. .package + colors.reset
  131. '
  132. }
  133. function create_request_body {
  134. echo "$1" |
  135. jq --raw-output --arg branch "${CIRCLE_BRANCH}" --arg trigger "${TRIGGER_PARAM_NAME}" --argjson params "${CI_PARAMETERS:-null}" '. |
  136. map(select(.changes > 0)) |
  137. reduce .[] as $i (($params // {}) * { ($trigger): false }; .[$i.package] = true) |
  138. { branch: $branch, parameters: . } |
  139. @json'
  140. }
  141. function create_pipeline {
  142. url="https://circleci.com/api/v2/project/${PROJECT_SLUG}/pipeline"
  143. echo -e "Trigger:\n\tUrl: ${url}\n\tData: $1"
  144. if [[ "${CI}" != "true" ]]; then
  145. echo "Not a CI environment. Skip pipeline trigger."
  146. exit 0
  147. fi;
  148. status_code=$(curl -s -u "${CIRCLE_API_TOKEN}:" -o response.json -w "%{http_code}" -X POST --header "Content-Type: application/json" -d "$1" "${url}")
  149. if [ "${status_code}" -ge "200" ] && [ "${status_code}" -lt "300" ]; then
  150. echo "API call succeeded [${status_code}]. Response: "
  151. cat response.json
  152. else
  153. echo "API call failed [${status_code}]. Response: "
  154. cat response.json
  155. exit 1
  156. fi
  157. }
  158. function init {
  159. if [[ "x${CIRCLE_API_TOKEN}" == "x" ]]; then
  160. echo "ENV variable CIRCLE_API_TOKEN is empty. Please provide a user token."
  161. exit 1
  162. fi
  163. mkdir -p "${TMP_DIR}"
  164. if [[ ! -f ${CONFIG_FILE} ]]; then
  165. echo "No config file found at ${CONFIG_FILE}. Using defaults."
  166. echo "{}" > "${CONFIG_FILE}"
  167. fi
  168. }
  169. function get_builds {
  170. echo "Getting workflow status:"
  171. get_workflows "$(jq '.pages // 1' "${CONFIG_FILE}")"
  172. wait
  173. cat "${TMP_DIR}"/data.*.json | jq --slurp 'reduce inputs as $i (.; . += $i) | flatten' > "${DATA_FILE}"
  174. map < "${DATA_FILE}" > "${BUILDS_FILE}"
  175. echo "Created build-commit map ${BUILDS_FILE}"
  176. }
  177. function debug {
  178. echo -e "\n\nDEBUG INFORMATION"
  179. echo -e "\n\n=== Branches ==="
  180. cat "${TMP_DIR}/branches.txt"
  181. echo -e "\n\n=== Builds ==="
  182. cat "${BUILDS_FILE}"
  183. }
  184. function get_parent {
  185. first_commit_in_branch=$(jq --raw-output 'map(select(.vcs_revision)) | last | .vcs_revision' "${DATA_FILE}")
  186. echo "First built commit in branch: ${first_commit_in_branch}" >&2
  187. parent_commit=$(get_parent_commit "${first_commit_in_branch}")
  188. if [[ "x${parent_commit}" == "x" ]]; then
  189. # This could happen when branch is force pushed
  190. # and the build commit is no longer part of the history
  191. echo -e "\tCould not find parent commit relative to first build commit." >&2
  192. echo -e "\tEither branch was force pushed or build commit too old." >&2
  193. parent_commit=$(get_parent_commit null)
  194. fi
  195. echo "Parent commit: ${parent_commit}" >&2
  196. echo ${parent_commit}
  197. }
  198. function main {
  199. init
  200. get_builds
  201. git_parent_commit=$( get_parent )
  202. statuses=$(\
  203. read_config_groups "${CONFIG_FILE}" |
  204. diff "${git_parent_commit}" "${BUILDS_FILE}" |
  205. jq --raw-input --slurp \
  206. 'split("\n") | map(select(. != "")) | map(split(" ")) | map({ package: .[3], parent: .[1], branch: .[2], changes: .[0] | tonumber })')
  207. print_status "${statuses}"
  208. changed_groups=$( echo "${statuses}" | jq '. | map(select(.changes > 0)) | length' )
  209. total_groups=$( echo "${statuses}" | jq '. | length' )
  210. echo "Number of groups changed: ${changed_groups} / ${total_groups}"
  211. if [[ "${changed_groups}" != "0" ]]; then
  212. create_pipeline "$( create_request_body "${statuses}" )"
  213. else
  214. echo "No changes in groups. Skip workflow trigger."
  215. fi
  216. if [[ "${CONDITIONAL_DEBUG}" == "true" ]]; then
  217. debug
  218. fi
  219. }
  220. main "${@}"