Last active
March 31, 2019 02:00
-
-
Save jfoster/44f94bfb9c23a5b45a4edae8c08520c9 to your computer and use it in GitHub Desktop.
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 | |
readonly program="$(basename "${0}")" | |
export readonly MACOS_VERSION='10.14' # Latest macOS version, so commands like `fetch` are not dependent on the contributor’s OS | |
readonly submit_pr_to='homebrew:master' | |
readonly caskroom_origin_remote_regex='(https://|git@)github.com[/:]Homebrew/homebrew-cask' | |
readonly caskroom_taps=(jfoster) | |
readonly caskroom_taps_dir="$(brew --repository)/Library/Taps/jfoster" | |
readonly user_agent=(--user-agent 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10) https://brew.sh') | |
readonly hub_config="${HOME}/.config/hub" | |
readonly github_username="${GITHUB_USER:-$(awk '/user:/{print $(NF)}' "${hub_config}" 2>/dev/null | head -1)}" | |
readonly cask_repair_remote_name="${github_username}" | |
readonly cask_repair_branch_prefix='cask_repair_update' | |
readonly submission_error_log="$(mktemp)" | |
show_home='false' # By default, do not open the cask's homepage | |
show_appcast='false' # By default, do not open the cask's appcast | |
warning_messages=() | |
has_errors='' | |
function color_message { | |
local color="${1}" | |
local message="${2}" | |
readonly local all_colors=('black' 'red' 'green' 'yellow' 'blue' 'magenta' 'cyan' 'white') | |
for i in "${!all_colors[@]}"; do | |
if [[ "${all_colors[${i}]}" == "${color}" ]]; then | |
local color_index="${i}" | |
echo -e "$(tput setaf "${i}")${message}$(tput sgr0)" | |
break | |
fi | |
done | |
if [[ -z "${color_index}" ]]; then | |
echo "${FUNCNAME[0]}: '${color}' is not a valid color." | |
exit 1 | |
fi | |
} | |
function failure_message { | |
color_message 'red' "${1}" >&2 | |
} | |
function success_message { | |
color_message 'green' "${1}" | |
} | |
function warning_message { | |
color_message 'yellow' "${1}" | |
} | |
function syntax_error { | |
abort "${program}: ${1}\nTry \`${program} --help\` for more information." | |
} | |
function push_failure_message { | |
warning_message 'There were errors while pushing:' | |
echo "${1}" | |
abort 'Please fix the errors and try again. If the issue persists, open a bug report on the repo for this script (https://github.com/vitorgalvao/tiny-scripts).' | |
} | |
function require_hub { | |
if ! command -v 'hub' &>/dev/null; then | |
warning_message '`hub` was not found. Installing it…' | |
brew install hub | |
fi | |
if [[ -z "${github_username}" ]] || [[ -z "${GITHUB_TOKEN}" && ! $(grep 'oauth_token:' "${hub_config}" 2>/dev/null) ]]; then | |
abort '`hub` is not configured.\nTo do it, run `(cd $(brew --repository) && hub issue)`. Your Github password will be required, but is never stored.' | |
fi | |
} | |
function usage { | |
echo " | |
Usage: | |
${program} [options] <cask_name> | |
Options: | |
-o, --open-home Open the homepage for the given cask. | |
-a, --open-appcast Open the appcast for the given cask. | |
-v, --cask-version Give a version directly, instead of being prompted for it. | |
-u, --cask-url Give a URL directly, instead of being prompted for it. | |
-e, --edit-cask Opens cask for editing before trying first download. | |
-c <number>, --closes-issue <number> Adds 'Closes #<number>.' to the commit message. | |
-m <message>, --message <message> Adds '<message>' to the pull request. | |
-b, --blind-submit Submit cask without asking for confirmation, if there are no errors. | |
-f, --fail-on-error If there are any errors with the submission, abort. | |
-w, --fail-on-warning If there are any warnings or errors with the submission, abort. | |
-i, --install-cask Installs your updated cask after submission. | |
-d, --delete-branches Deletes all local and remote branches named like ${cask_repair_branch_prefix}-<word>. | |
-h, --help Show this help. | |
" | sed -E 's/^ {4}//' | |
} | |
function current_origin { | |
git remote get-url origin | |
} | |
function current_tap { | |
basename "$(current_origin)" '.git' | |
} | |
function ensure_caskroom_repos { | |
local current_caskroom_taps | |
current_caskroom_taps=($(HOMEBREW_NO_AUTO_UPDATE=1 brew tap | grep '^homebrew/cask' | sed 's|^homebrew/|homebrew-|')) | |
for repo in "${caskroom_taps[@]}"; do | |
if grep --silent "${repo}" <<< "${current_caskroom_taps[@]}"; then | |
continue | |
else | |
warning_message "\`homebrew/${repo}\` not tapped. Tapping…" | |
HOMEBREW_NO_AUTO_UPDATE=1 brew tap "homebrew/${repo}" | |
fi | |
done | |
} | |
function cd_to_cask_tap { | |
local cask_file cask_file_location | |
cask_file="${1}" | |
cask_file_location="$(find "${caskroom_taps_dir}" -path "*/Casks/${cask_file}")" | |
[[ -z "${cask_file_location}" ]] && abort "No such cask was found in any official repo (${cask_name})." | |
cd "$(dirname "${cask_file_location}")" || abort "Failed to change to directory of ${cask_file}." | |
} | |
function require_correct_origin { | |
local origin_remote | |
origin_remote="$(current_origin)" | |
grep --silent --ignore-case --extended-regexp "^${caskroom_origin_remote_regex}" <<< "${origin_remote}" || abort "\`origin\` is pointing to an incorrect remote (${origin_remote}). Its beginning must match ${caskroom_origin_remote_regex}." | |
} | |
function ensure_cask_repair_remote { | |
if ! git remote | grep --silent "${cask_repair_remote_name}"; then | |
warning_message "A \`${cask_repair_remote_name}\` remote does not exist. Creating it now…" | |
hub fork | |
fi | |
} | |
function http_status_code { | |
local url follow_redirects | |
url="${1}" | |
[[ "${2}" == 'follow_redirects' ]] && follow_redirects='--location' || follow_redirects='--no-location' | |
curl --silent --head "${follow_redirects}" "${user_agent[@]}" --write-out '%{http_code}' "${url}" --output '/dev/null' | |
} | |
function has_interpolation? { | |
local version="${1}" | |
[[ "${version}" =~ \#{version.*} ]] | |
} | |
function is_version_latest? { | |
local cask_file="${1}" | |
[[ "$(brew cask _stanza version "${cask_file}")" == 'latest' ]] | |
} | |
function has_block_url? { | |
local cask_file="${1}" | |
grep --silent 'url do' "${cask_file}" | |
} | |
function has_language_stanza? { | |
local cask_file="${1}" | |
brew cask _stanza language "${cask_file}" 2>/dev/null | |
} | |
function modify_stanza { | |
local stanza_to_modify new_stanza_value cask_file stanza_match_regex last_stanza_match stanza_start ending_comma | |
stanza_to_modify="${1}" | |
new_stanza_value="${2}" | |
cask_file="${3}" | |
stanza_match_regex="^\s*${stanza_to_modify} " | |
last_stanza_match="$(grep "${stanza_match_regex}" "${cask_file}" | tail -1)" | |
stanza_start="$(/usr/bin/perl -pe "s/(${stanza_match_regex}).*/\1/" <<< "${last_stanza_match}")" | |
if grep --quiet ',$' <<< "${last_stanza_match}"; then | |
ending_comma=',' | |
fi | |
/usr/bin/perl -0777 -i -e' | |
$last_stanza_match = shift(@ARGV); | |
$stanza_start = shift(@ARGV); | |
$new_stanza_value = shift(@ARGV); | |
$ending_comma = shift(@ARGV); | |
print <> =~ s|\Q$last_stanza_match\E|$stanza_start$new_stanza_value$ending_comma|r; | |
' "${last_stanza_match}" "${stanza_start}" "${new_stanza_value}" "${ending_comma}" "${cask_file}" | |
} | |
function modify_url { | |
local url cask_file | |
url="${1}" | |
cask_file="${2}" | |
[[ $(has_interpolation? "${url}") ]] && modify_stanza 'url' "\"${url}\"" "${cask_file}" || modify_stanza 'url' "'${url}'" "${cask_file}" # Use appropriate quotes depending on if a url with interpolation was given | |
} | |
function appcast_url { | |
local cask_file="${1}" | |
brew cask _stanza appcast "${cask_file}" | |
} | |
function has_appcast? { | |
local cask_file="${1}" | |
[[ -n "$(appcast_url "${cask_file}" 2>/dev/null)" ]] | |
} | |
function sha_change { | |
local cask_sha_deliberatedly_unchecked downloaded_file package_sha cask_file | |
cask_file="${1}" | |
cask_sha_deliberatedly_unchecked="$(grep 'sha256 :no_check # required as upstream package is updated in-place' "${cask_file}")" | |
[[ -n "${cask_sha_deliberatedly_unchecked}" ]] && return # Abort function if cask deliberately uses :no_check with a version | |
# Set sha256 as :no_check temporarily, to prevent mismatch errors when fetching | |
modify_stanza 'sha256' ':no_check' "${cask_file}" | |
if ! brew cask fetch --force "${cask_file}"; then | |
clean | |
abort "There was an error fetching ${cask_file}. Please check your connection and try again." | |
fi | |
downloaded_file=$(brew cask fetch "${cask_file}" 2>/dev/null | tail -1 | sed 's/==> Success! Downloaded to -> //') | |
package_sha=$(shasum --algorithm 256 "${downloaded_file}" | awk '{ print $1 }') | |
modify_stanza 'sha256' "'${package_sha}'" "${cask_file}" | |
} | |
function delete_created_branches { | |
local local_branches remote_branches | |
for dir in "${caskroom_taps_dir}/homebrew-cask"*; do | |
cd "${dir}" || abort "Failed to delete branches. ${dir} does not exist." | |
if git remote | grep --silent "${cask_repair_remote_name}"; then # Proceed only if the correct remote exists | |
# Delete local branches | |
local_branches=$(git branch --all | grep --extended-regexp "^ *${cask_repair_branch_prefix}-.+$" | /usr/bin/perl -pe 's|^ *||;s|\n| |') | |
[[ -n "${local_branches}" ]] && git branch -D ${local_branches} | |
# Delete remote branches | |
git fetch --prune "${cask_repair_remote_name}" | |
remote_branches=$(git branch --all | grep --extended-regexp "remotes/${cask_repair_remote_name}/${cask_repair_branch_prefix}-.+$" | /usr/bin/perl -pe 's|.*/||;s|\n| |') | |
[[ -n "${remote_branches}" ]] && git push "${cask_repair_remote_name}" --delete ${remote_branches} | |
fi | |
cd .. | |
done | |
} | |
function edit_cask { | |
local cask_file found_editor | |
cask_file="${1}" | |
echo 'Opening cask in default editor. If it is a GUI editor, you will need to completely quit it (⌘Q) before the script can continue.' | |
for text_editor in {"${HOMEBREW_EDITOR}","${EDITOR}","${GIT_EDITOR}"}; do | |
if [[ -n "${text_editor}" ]]; then | |
eval "${text_editor}" "${cask_file}" | |
found_editor='true' | |
break | |
fi | |
done | |
[[ -n "${found_editor}" ]] || open -W "${cask_file}" | |
} | |
function add_warning { | |
local message severity color | |
severity="${1}" | |
message="$(sed '/./,$!d' <<< "${2}")" # Remove leading blank lines, so audit errors related to ruby still show | |
if [[ "${severity}" == 'warning' ]]; then | |
color="$(tput setaf 3)•$(tput sgr0)" | |
else | |
color="$(tput setaf 1)•$(tput sgr0)" | |
has_errors='true' | |
fi | |
warning_messages+=("${color} ${message}") | |
} | |
function show_warnings { | |
if [[ "${#warning_messages[@]}" -gt 0 ]]; then | |
printf '%s\n' "${warning_messages[@]}" >&2 | |
divide | |
fi | |
} | |
function clear_warnings { | |
warning_messages=() | |
unset has_errors | |
} | |
function lock { | |
local lock_file action | |
readonly lock_file='/tmp/cask-repair.lock' | |
readonly action="${1}" | |
if [[ "${action}" == 'create' ]]; then | |
touch "${lock_file}" | |
elif [[ "${action}" == 'exists?' ]]; then | |
[[ -f "${lock_file}" ]] && return 0 || return 1 | |
elif [[ "${action}" == 'remove' ]]; then | |
[[ -f "${lock_file}" ]] && rm "${lock_file}" | |
fi | |
} | |
function clean { | |
local current_branch | |
lock 'remove' | |
[[ "$(dirname "$(dirname "${PWD}")")" == "${caskroom_taps_dir}" ]] || return # Do not try to clean if not in a tap dir (e.g. if script was manually aborted too fast) | |
current_branch="$(git rev-parse --abbrev-ref HEAD)" | |
git reset HEAD --hard --quiet | |
git checkout master --quiet | |
git branch -D "${current_branch}" --quiet | |
[[ -f "${submission_error_log}" ]] && rm "${submission_error_log}" | |
unset given_cask_version given_cask_url cask_updated | |
} | |
function skip { | |
clean | |
echo -e "${1}" | |
} | |
function abort { | |
clean | |
failure_message "\n${1}\n" | |
exit 1 | |
} | |
trap 'abort "You aborted."' SIGINT | |
function divide { | |
command -v 'hr' &>/dev/null && hr - || echo '--------------------' | |
} | |
# Available flags | |
args=() | |
while [[ "${1}" ]]; do | |
case "${1}" in | |
-h | --help) | |
usage | |
exit 0 | |
;; | |
-o | --open-home) | |
show_home='true' | |
;; | |
-a | --open-appcast) | |
show_appcast='true' | |
;; | |
-v | --cask-version) | |
given_cask_version="${2}" | |
shift | |
;; | |
-u | --cask-url) | |
given_cask_url="${2}" | |
shift | |
;; | |
-e | --edit-cask) | |
edit_on_start='true' | |
;; | |
-c | --closes-issue) | |
issue_to_close="${2}" | |
shift | |
;; | |
-m | --message) | |
extra_message="${2}" | |
shift | |
;; | |
-b | --blind-submit) | |
updated_blindly='true' | |
;; | |
-f | --fail-on-error) | |
abort_on_error='true' | |
;; | |
-w | --fail-on-warning) | |
abort_on_error='true' | |
abort_on_warning='true' | |
;; | |
-i | --install-cask) | |
install_now='true' | |
;; | |
-d | --delete-branches) | |
can_run_without_arguments='true' | |
delete_created_branches='true' | |
;; | |
--) | |
shift | |
args+=("${@}") | |
break | |
;; | |
-*) | |
syntax_error "unrecognized option: ${1}" | |
;; | |
*) | |
args+=("${1}") | |
;; | |
esac | |
shift | |
done | |
set -- "${args[@]}" | |
# Exit if no argument or more than one argument was given | |
if [[ -z "${1}" && "${can_run_without_arguments}" != 'true' ]]; then | |
usage | |
exit 1 | |
fi | |
if [[ "${delete_created_branches}" == 'true' ]]; then | |
delete_created_branches | |
exit 0 | |
fi | |
# Only allow one instance at a time | |
if lock 'exists?'; then | |
# We want this to be different from abort, so as to not remove the lock file | |
failure_message "Only one ${program} instance can be run at once." | |
exit 1 | |
else | |
lock 'create' | |
fi | |
require_hub | |
ensure_caskroom_repos | |
echo -n 'Updating taps… ' | |
brew update | |
for cask in "${@}"; do | |
# Clean the cask's name, and check if it is valid | |
cask_name="${cask%.rb}" # Remove '.rb' extension, if present | |
cask_file="./${cask_name}.rb" | |
cask_branch="${cask_repair_branch_prefix}-${cask_name}" | |
cd_to_cask_tap "${cask_name}.rb" | |
require_correct_origin | |
ensure_cask_repair_remote | |
has_language_stanza? "${cask_file}" && abort "${cask_name} has a language stanza. It cannot be updated via this script. Try update_multilangual_casks: https://github.com/Homebrew/homebrew-cask/blob/master/developer/bin/update_multilangual_casks" | |
git rev-parse --verify "${cask_branch}" &>/dev/null && git checkout "${cask_branch}" --quiet || git checkout -b "${cask_branch}" --quiet # Create branch or checkout if it already exists | |
# Open home and appcast | |
[[ "${show_home}" == 'true' ]] && brew cask home "${cask_file}" | |
if has_appcast? "${cask_file}"; then | |
cask_appcast_url="$(appcast_url "${cask_file}")" | |
if [[ "${show_appcast}" == 'true' ]]; then | |
[[ "${cask_appcast_url}" =~ ^https://github.com.*releases.atom$ ]] && open "${cask_appcast_url%.atom}" || open "${cask_appcast_url}" # if appcast is from github releases, open the page instead of the feed | |
fi | |
fi | |
# Show cask's current state | |
divide | |
cat "${cask_file}" | |
divide | |
# Save old cask version | |
old_cask_version="$(brew cask _stanza version "${cask_file}")" | |
# Set cask version | |
if [[ -z "${given_cask_version}" ]]; then | |
read -rp $'Type the new version (or leave blank to use current one, or use `s` to skip)\n> ' given_cask_version # Ask for cask version, if not given previously | |
if [[ "${given_cask_version}" == 's' ]]; then | |
skip 'Skipping…' | |
continue | |
fi | |
[[ -z "${given_cask_version}" ]] && given_cask_version=$(brew cask _stanza version "${cask_file}") | |
fi | |
if [[ "${given_cask_version}" == ':latest' || "${given_cask_version}" == 'latest' ]]; then # Allow both ':latest' and 'latest' to be given | |
modify_stanza 'version' ':latest' "${cask_file}" | |
else | |
modify_stanza 'version' "'${given_cask_version}'" "${cask_file}" | |
fi | |
if [[ -n "${given_cask_url}" ]]; then | |
[[ $(has_block_url? "${cask_file}") ]] && warning_message 'Cask has block url, so it can only be modified manually (choose `[e]dit` when prompted).' || modify_url "${given_cask_url}" "${cask_file}" | |
else | |
# If url does not use interpolation and is not block, ask for it | |
cask_bare_url=$(grep "url ['\"].*['\"]" "${cask_file}" | sed -E "s|.*url ['\"](.*)['\"].*|\1|") | |
if ! has_interpolation? "${cask_bare_url}" && ! has_block_url? "${cask_file}"; then | |
read -rp $'Paste the new URL (or leave blank to use the current one)\n> ' given_cask_url | |
[[ -n "${given_cask_url}" ]] && modify_url "${given_cask_url}" "${cask_file}" | |
fi | |
cask_url=$(brew cask _stanza url "${cask_file}") | |
# Check if the URL sends a 200 HTTP code, else abort | |
cask_url_status=$(http_status_code "${cask_url}" 'follow_redirects') | |
[[ "${cask_url}" =~ (github.com|bitbucket.org) ]] && cask_url_status='200' # If the download URL is from github or bitbucket, fake the status code | |
if [[ "${cask_url_status}" != '200' ]]; then | |
[[ -z "${cask_url_status}" ]] && add_warning warning 'you need to use a valid URL' || add_warning warning "url is probably incorrect, as a non-200 (OK) HTTP response code was returned (${cask_url_status})" | |
fi | |
fi | |
[[ "${edit_on_start}" == 'true' ]] && edit_cask "${cask_file}" | |
if is_version_latest? "${cask_file}"; then | |
modify_stanza 'sha256' ':no_check' "${cask_file}" | |
else | |
sha_change "${cask_file}" | |
fi | |
# Check if everything is alright, else abort | |
[[ -z "${cask_updated}" ]] && cask_updated='false' | |
until [[ "${cask_updated}" =~ ^[yne]$ ]]; do | |
# fix style errors and check for style and audit errors | |
style_message=$(brew cask style --fix "${cask_file}" 2>/dev/null) | |
style_result="${?}" | |
[[ "${style_result}" -ne 0 ]] && add_warning error "${style_message}" | |
audit_message=$(brew cask audit "${cask_file}" 2>/dev/null) | |
audit_result="${?}" | |
[[ "${audit_result}" -ne 0 ]] && add_warning error "${audit_message}" | |
git --no-pager diff | |
divide | |
show_warnings | |
[[ -n "${abort_on_error}" && "${has_errors}" == 'true' ]] && abort 'The submission has errors and you elected to abort on those cases.' | |
[[ -n "${abort_on_warning}" && "${#warning_messages[@]}" -gt 0 ]] && abort 'The submission has warnings and you elected to abort on those cases.' | |
if [[ -n "${updated_blindly}" && "${#warning_messages[@]}" -eq 0 ]]; then | |
cask_updated='y' | |
else | |
read -rn1 -p 'Is everything correct? ([y]es / [n]o / [e]dit) ' cask_updated | |
echo # Add an empty line | |
fi | |
if [[ "${cask_updated}" == 'y' ]]; then | |
if [[ "${style_result}" -ne 0 || "${audit_result}" -ne 0 ]]; then | |
cask_updated='false' | |
else | |
break | |
fi | |
elif [[ "${cask_updated}" == 'e' ]]; then | |
edit_cask "${cask_file}" | |
if ! is_version_latest? "${cask_file}"; then # Recheck sha256 values if version isn't :latest | |
sha_change "${cask_file}" | |
fi | |
cask_updated='false' | |
clear_warnings | |
elif [[ "${cask_updated}" == 'n' ]]; then | |
abort 'You decided to abort.' | |
fi | |
done | |
# Skip if no changes were made, submit otherwise | |
if git diff-index --quiet HEAD --; then | |
skip 'No changes made to the cask. Skipping…' | |
continue | |
else | |
echo 'Submitting…' | |
fi | |
# Grab version as it ended up in the cask | |
cask_version="$(brew cask _stanza version "${cask_file}")" | |
# Commit, push, submit pull request, clean | |
[[ "${old_cask_version}" == "${cask_version}" ]] && commit_message="Update ${cask_name}" || commit_message="Update ${cask_name} from ${old_cask_version} to ${cask_version}" | |
pr_message="${commit_message}\n\nAfter making all changes to the cask:\n\n- [x] \`brew cask audit --download {{cask_file}}\` is error-free.\n- [x] \`brew cask style --fix {{cask_file}}\` left no offenses.\n- [x] The commit message includes the cask’s name and version." | |
[[ -n "${issue_to_close}" ]] && pr_message+="\n\nCloses #${issue_to_close}." | |
[[ -n "${extra_message}" ]] && pr_message+="\n\n${extra_message}" | |
submit_pr_from="${github_username}:${cask_branch}" | |
git commit "${cask_file}" --message "${commit_message}" --quiet | |
git push --force "${cask_repair_remote_name}" "${cask_branch}" --quiet 2> "${submission_error_log}" | |
if [[ "${?}" -ne 0 ]]; then | |
# Fix common push errors | |
if grep --quiet 'shallow update not allowed' "${submission_error_log}"; then | |
echo 'Push failed due to shallow repo. Unshallowing…' | |
HOMEBREW_NO_AUTO_UPDATE=1 brew tap --full "homebrew/$(current_tap)" | |
git push --force "${cask_repair_remote_name}" "${cask_branch}" --quiet 2> "${submission_error_log}" | |
[[ "${?}" -ne 0 ]] && push_failure_message "$(< "${submission_error_log}")" | |
else | |
push_failure_message "$(< "${submission_error_log}")" | |
fi | |
fi | |
pr_link=$(hub pull-request -b "${submit_pr_to}" -h "${submit_pr_from}" -m "$(echo -e "${pr_message}")") | |
if [[ -n "${pr_link}" ]]; then | |
if [[ -n "${install_now}" ]]; then | |
success_message 'Updating cask locally…' | |
brew cask reinstall "${cask_file}" | |
else | |
echo -e "\nYou can upgrade the cask right now from your personal branch:\n brew cask reinstall https://raw.githubusercontent.com/${github_username}/$(current_tap)/${cask_branch}/Casks/${cask_name}.rb" | |
fi | |
clean | |
success_message "\nSubmitted (${pr_link})\n" | |
else | |
abort 'There was an error submitting the pull request. Please open a bug report on the repo for this script (https://github.com/vitorgalvao/tiny-scripts).' | |
fi | |
done |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment