summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.gitattributes8
-rw-r--r--.gitignore2
-rwxr-xr-xdevscripts/release.sh679
-rw-r--r--justfile13
4 files changed, 702 insertions, 0 deletions
diff --git a/.gitattributes b/.gitattributes
new file mode 100644
index 0000000..c1ed6dc
--- /dev/null
+++ b/.gitattributes
@@ -0,0 +1,8 @@
+# Exclude development/release tooling from source archives.
+devscripts/ export-ignore
+justfile export-ignore
+
+# Exclude Git/project metadata.
+.gitignore export-ignore
+.gitattributes export-ignore
+.gitmessage export-ignore
diff --git a/.gitignore b/.gitignore
index 28c0e4b..1c0f7fd 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,3 +1,5 @@
+dist/
+
# Created by https://www.toptal.com/developers/gitignore/api/windows,linux,macos,zsh,fish,vim,emacs,git
# Edit at https://www.toptal.com/developers/gitignore?templates=windows,linux,macos,zsh,fish,vim,emacs,git
diff --git a/devscripts/release.sh b/devscripts/release.sh
new file mode 100755
index 0000000..d95adb3
--- /dev/null
+++ b/devscripts/release.sh
@@ -0,0 +1,679 @@
+#!/usr/bin/env bash
+
+# SPDX-FileCopyrightText: 2026 Dennis Fink <me+coding@dennisfink.me>
+#
+# SPDX-License-Identifier: BSD-3-Clause
+
+###############################################################################
+# DESCRIPTION
+# Release helper for transcode.sh.
+#
+# This developer script prepares and publishes project releases. The prepare
+# command updates release metadata in the main script, man page, and README
+# markers. The release command verifies that metadata, commits the release,
+# creates an annotated git tag, and writes the source archive to dist/.
+#
+# NOTES
+# This script respects the NO_COLOR standard (https://no-color.org/).
+#
+# AUTHOR
+# Dennis Fink <me+coding@dennisfink.me>
+###############################################################################
+
+# TRACE enables bash execution tracing and is intended for debugging control
+# flow / expansions. DEBUG enables structured debug messages (see debug()) and
+# is intended for high-level state reporting.
+#
+# Recommended usage:
+# TRACE=1 scripts/release.sh ... # show every command bash executes
+# DEBUG=1 scripts/release.sh ... # show script-defined debug messages
+
+TRACE=${TRACE:-0}
+if [[ $TRACE -eq 1 ]]; then
+ set -o xtrace
+fi
+
+###############################################################################
+# SHELL SCRIPT HARDENING
+#
+# This script enables strict and predictable behavior for improved safety,
+# security, and debuggability. These measures help avoid common pitfalls
+# such as accidental globbing, unexpected alias expansion, silent failures,
+# or unintended word splitting.
+###############################################################################
+
+# Unalias everything to avoid unexpected alias expansion
+command unalias -a
+
+# Clear the shell command hash table to avoid stale command lookups
+hash -r
+
+# Set a safe IFS to prevent word-splitting vulnerabilities
+IFS=$'\n\t'
+
+# Set a secure default file creation mask (controls default permissions)
+umask 002
+
+# Ensure ERR traps are inherited by functions and subshells
+set -o errtrace
+# Treat use of undefined variables as an error and exit
+set -o nounset
+# Make pipelines fail if any command in the pipeline fails
+set -o pipefail
+
+###############################################################################
+# SCRIPT METADATA
+#
+# These variables describe the script and are used for the --version output
+# and for informational messages. They are marked readonly to prevent
+# accidental modification at runtime.
+###############################################################################
+readonly SCRIPTNAME=${0##*/}
+readonly DESCRIPTION="Release helper for transcode.sh."
+readonly DATE_OF_CREATION=2026-05-15
+readonly DATE_OF_REVISION=2026-05-15
+readonly VERSION=0.0.1
+readonly AUTHOR="Dennis Fink <me+coding@dennisfink.me>"
+readonly LICENSE="BSD-3-Clause"
+
+###############################################################################
+# GLOBAL VARIABLES
+###############################################################################
+
+ALL_OFF=""
+BOLD=""
+RED=""
+GREEN=""
+YELLOW=""
+BLUE=""
+MAGENTA=""
+CYAN=""
+
+DEBUG=${DEBUG:-0}
+DRY_RUN=0
+QUIET=0
+VERBOSE=0
+
+COMMAND=""
+RELEASE_VERSION=""
+
+readonly DIST_DIR="dist"
+readonly PROJECT_NAME="transcode-sh"
+readonly MAIN_SCRIPT="transcode.sh"
+readonly MAN_PAGE="transcode.sh.1"
+readonly README_FILE="README.md"
+
+###############################################################################
+# EXIT CODES
+###############################################################################
+
+EXIT_OK=0
+EXIT_RUNTIME_FAILURE=1
+EXIT_USAGE_ERROR=2
+EXIT_CONFIG_ERROR=3
+EXIT_MISSING_DEPENDENCY=127
+readonly EXIT_OK EXIT_RUNTIME_FAILURE EXIT_USAGE_ERROR EXIT_CONFIG_ERROR EXIT_MISSING_DEPENDENCY
+
+##############################################################################
+# FUNCTIONS
+#
+# This section defines the helper functions used throughout the script. They
+# handle formatted output, error reporting, version information, utility
+# functions and runtime behaviour such as displaying usage instructions.
+###############################################################################
+
+emit() {
+ local prefix="$1"
+ local color="$2"
+ shift 2
+
+ if [[ $# -eq 0 ]]; then
+ printf "%b%s%b\n" "${BOLD}${color}" "$prefix" "$ALL_OFF"
+ return
+ fi
+
+ local mesg="$1"
+ shift
+
+ local rest=""
+ if [[ $# -gt 0 ]]; then
+ printf -v rest ' %s' "$@"
+ rest="${rest# }"
+ fi
+
+ printf "%b%s%b %b%s%b" "${BOLD}${color}" "$prefix" "$ALL_OFF" "$BOLD" "$mesg" "$ALL_OFF"
+ if [[ -n "$rest" ]]; then
+ printf " %s" "$rest"
+ fi
+ printf "\n"
+}
+
+error() {
+ emit "==> ERROR:" "$RED" "$@" >&2
+}
+
+msg() {
+ if [[ $QUIET -eq 0 ]]; then
+ emit "==>" "$GREEN" "$@"
+ fi
+}
+
+warn() {
+ if [[ $QUIET -eq 0 ]]; then
+ emit "==>" "$YELLOW" "$@"
+ fi
+}
+
+verbose() {
+ if [[ $VERBOSE -eq 1 && $QUIET -eq 0 ]]; then
+ emit "==>" "$BLUE" "$@"
+ fi
+}
+
+debug() {
+ if [[ $DEBUG -eq 1 ]]; then
+ emit "==> DEBUG:" "$MAGENTA" "$@"
+ fi
+}
+
+print_help() {
+ printf "${BOLD}${MAGENTA}%s${ALL_OFF} - ${BOLD}${CYAN}%s${ALL_OFF} - ${BOLD}%s${ALL_OFF}
+
+${BOLD}${BLUE}Usage:${ALL_OFF} ${BOLD}${MAGENTA}%s${ALL_OFF} ${BOLD}${YELLOW}[OPTION]...${ALL_OFF} ${BOLD}${GREEN}COMMAND VERSION${ALL_OFF}
+
+${BOLD}${BLUE}Commands:${ALL_OFF}
+ ${BOLD}${GREEN}prepare${ALL_OFF} ${BOLD}${CYAN}VERSION${ALL_OFF} update release metadata and show the resulting diff
+ ${BOLD}${GREEN}release${ALL_OFF} ${BOLD}${CYAN}VERSION${ALL_OFF} verify metadata, commit, tag, and create archive
+
+${BOLD}${BLUE}Release options:${ALL_OFF}
+ ${BOLD}${YELLOW}-n, --dry-run${ALL_OFF} show what would be done without changing files, commits, tags, or archives
+
+${BOLD}${BLUE}Output options:${ALL_OFF}
+ ${BOLD}${YELLOW}-q, --quiet${ALL_OFF} suppress informational output
+ ${BOLD}${YELLOW}-v, --verbose${ALL_OFF} explain what is being done
+ ${BOLD}${YELLOW}--color${ALL_OFF} force colored output
+ ${BOLD}${YELLOW}--no-color${ALL_OFF} disable colored output
+
+${BOLD}${BLUE}General options:${ALL_OFF}
+ ${BOLD}${YELLOW}-h, --help${ALL_OFF} display this help and exit
+ ${BOLD}${YELLOW}--version${ALL_OFF} output version information and exit
+
+${BOLD}${BLUE}Examples:${ALL_OFF}
+ %s prepare 1.1.0
+ %s --dry-run prepare 1.1.0
+ %s release 1.1.0
+
+${BOLD}${BLUE}Notes:${ALL_OFF}
+ ${BOLD}${GREEN}prepare${ALL_OFF} must be run before ${BOLD}${GREEN}release${ALL_OFF}.
+ Archives are written below ${BOLD}${CYAN}%s/${ALL_OFF}.
+" \
+ "$SCRIPTNAME" "$VERSION" "$DESCRIPTION" \
+ "$SCRIPTNAME" \
+ "$SCRIPTNAME" "$SCRIPTNAME" "$SCRIPTNAME" "$DIST_DIR"
+}
+
+print_version() {
+ printf "${RED}${BOLD}Scriptname:${ALL_OFF} %s
+${GREEN}${BOLD}Version:${ALL_OFF} %s
+${YELLOW}${BOLD}Description:${ALL_OFF} %s
+${BLUE}${BOLD}Author:${ALL_OFF} %s
+${MAGENTA}${BOLD}Date of creation:${ALL_OFF} %s
+${CYAN}${BOLD}Date of revision:${ALL_OFF} %s
+${RED}${BOLD}License:${ALL_OFF} %s
+
+Copyright (c) 2026 Dennis Fink <me+coding@dennisfink.me>.
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are met:
+
+1. Redistributions of source code must retain the above copyright notice, this
+list of conditions and the following disclaimer.
+
+2. Redistributions in binary form must reproduce the above copyright notice,
+this list of conditions and the following disclaimer in the documentation
+and/or other materials provided with the distribution.
+
+3. Neither the name of the copyright holder nor the names of its contributors
+may be used to endorse or promote products derived from this software without
+specific prior written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\" AND
+ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
+FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
+CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
+OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.\n" \
+ "$SCRIPTNAME" "$VERSION" "$DESCRIPTION" "$AUTHOR" "$DATE_OF_CREATION" \
+ "$DATE_OF_REVISION" "$LICENSE"
+}
+
+###############################################################################
+# TERMINAL COLOR SETUP
+#
+# Colors and style escape sequences are configured dynamically through the
+# `setup_colors` function, which enables or disables color output based on
+# the value of ENABLE_COLOR. When enabled, terminal capabilities are detected
+# via `tput`, falling back to ANSI escapes if unavailable.
+#
+# This script respects the NO_COLOR standard (https://no-color.org/).
+# If the environment variable NO_COLOR is set (to any value), all color output
+# is disabled. If FORCE_COLOR is set, colors are always enabled.
+###############################################################################
+
+# NOTE: This block calls debug() and reads $DEBUG. Both must be defined before
+# this point. Do not move this block above the GLOBAL VARIABLES or FUNCTIONS
+# sections without adjusting those dependencies.
+if [[ -n "${NO_COLOR:-}" ]]; then
+ debug "Colors are disabled via NO_COLOR"
+ ENABLE_COLOR=0
+elif [[ -n "${FORCE_COLOR:-}" ]]; then
+ debug "Colors are forced via FORCE_COLOR"
+ ENABLE_COLOR=1
+elif [[ -t 1 ]]; then
+ debug "Colors are enabled via interactive terminal"
+ ENABLE_COLOR=1
+else
+ debug "Colors are disabled"
+ ENABLE_COLOR=0
+fi
+
+setup_colors() {
+ if [[ "$ENABLE_COLOR" -eq 1 ]]; then
+ if tput setaf 0 >/dev/null 2>&1; then
+ debug "Setting colors via tput"
+ ALL_OFF="$(tput sgr0)"
+ BOLD="$(tput bold)"
+ RED="$(tput setaf 1)"
+ GREEN="$(tput setaf 2)"
+ YELLOW="$(tput setaf 3)"
+ BLUE="$(tput setaf 4)"
+ MAGENTA="$(tput setaf 5)"
+ CYAN="$(tput setaf 6)"
+ else
+ # Hardcoded ANSI fallback: colors only (no bold embedded), matching the
+ # tput path where BOLD and color variables are kept separate.
+ debug "Using hardcoded color ANSI escape sequences"
+ ALL_OFF="\e[0m"
+ BOLD="\e[1m"
+ RED="\e[31m"
+ GREEN="\e[32m"
+ YELLOW="\e[33m"
+ BLUE="\e[34m"
+ MAGENTA="\e[35m"
+ CYAN="\e[36m"
+ fi
+ else
+ ALL_OFF=""
+ BOLD=""
+ RED=""
+ GREEN=""
+ YELLOW=""
+ BLUE=""
+ MAGENTA=""
+ CYAN=""
+ fi
+}
+setup_colors
+
+###############################################################################
+# RELEASE HELPERS
+###############################################################################
+
+require_command() {
+ local command_name="$1"
+
+ command -v "$command_name" >/dev/null 2>&1 || {
+ error "$command_name not found."
+ exit $EXIT_MISSING_DEPENDENCY
+ }
+}
+
+require_release_dependencies() {
+ require_command date
+ require_command git
+ require_command grep
+ require_command sed
+ require_command tar
+}
+
+validate_version() {
+ local version="$1"
+
+ if [[ ! "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
+ error "Version must use SemVer format: MAJOR.MINOR.PATCH" "$version"
+ exit $EXIT_USAGE_ERROR
+ fi
+}
+
+require_project_files() {
+ local file
+
+ for file in "$MAIN_SCRIPT" "$MAN_PAGE" "$README_FILE"; do
+ [[ -f "$file" ]] || {
+ error "Required project file not found:" "$file"
+ exit $EXIT_CONFIG_ERROR
+ }
+ done
+}
+
+require_clean_worktree() {
+ if ! git diff --quiet --; then
+ error "working tree has unstaged changes"
+ exit $EXIT_RUNTIME_FAILURE
+ fi
+
+ if ! git diff --cached --quiet --; then
+ error "index has staged changes"
+ exit $EXIT_RUNTIME_FAILURE
+ fi
+}
+
+require_tag_absent() {
+ local version="$1"
+
+ if git rev-parse -q --verify "refs/tags/v$version" >/dev/null; then
+ error "tag already exists:" "v$version"
+ exit $EXIT_RUNTIME_FAILURE
+ fi
+}
+
+# Replace a literal release metadata pattern in a file.
+# Keep all sed -i portability handling in this one function.
+replace_literal() {
+ local file="$1"
+ local pattern="$2"
+ local replacement="$3"
+
+ local expression="s|$pattern|$replacement|"
+
+ debug "Replacing in $file:" "$pattern -> $replacement"
+
+ if [[ $DRY_RUN -eq 1 ]]; then
+ local matching_lines replaced_lines
+
+ matching_lines="$(grep -E -n -- "$pattern" "$file" || true)"
+
+ if [[ -z "$matching_lines" ]]; then
+ warn "DRY RUN:" "No matching line found in $file for pattern: $pattern"
+ return $EXIT_OK
+ fi
+
+ replaced_lines="$(printf "%s\n" "$matching_lines" | sed -E "$expression")"
+
+ msg "DRY RUN:" "Would replace in $file"
+ printf "%bCurrent:%b\n%s\n" "$BOLD" "$ALL_OFF" "$matching_lines"
+ printf "%bUpdated:%b\n%s\n" "$BOLD" "$ALL_OFF" "$replaced_lines"
+ return $EXIT_OK
+ fi
+
+ if sed --version >/dev/null 2>&1; then
+ # GNU sed
+ sed -i -E "$expression" "$file"
+ else
+ # BSD/macOS sed
+ sed -i '' -E "$expression" "$file"
+ fi
+}
+
+update_version_and_date() {
+ local version="$1"
+ local revision_date="$2"
+
+ msg "Updating release metadata:" "$version ($revision_date)"
+
+ replace_literal \
+ "$MAIN_SCRIPT" \
+ 'readonly VERSION=[0-9]+\.[0-9]+\.[0-9]+' \
+ "readonly VERSION=$version"
+
+ replace_literal \
+ "$MAIN_SCRIPT" \
+ 'readonly DATE_OF_REVISION=[0-9]{4}-[0-9]{2}-[0-9]{2}' \
+ "readonly DATE_OF_REVISION=$revision_date"
+
+ replace_literal \
+ "$MAN_PAGE" \
+ '\.TH TRANSCODE\.SH 1 "[0-9]{4}-[0-9]{2}-[0-9]{2}" "[0-9]+\.[0-9]+\.[0-9]+"' \
+ ".TH TRANSCODE.SH 1 \"$revision_date\" \"$version\""
+
+ if grep -q '<!-- release-version -->' "$README_FILE"; then
+ replace_literal \
+ "$README_FILE" \
+ '<!-- release-version -->.*<!-- /release-version -->' \
+ "<!-- release-version -->$version<!-- /release-version -->"
+ else
+ verbose "README release-version marker not found; skipping README version update"
+ fi
+
+ if grep -q '<!-- revision-date -->' "$README_FILE"; then
+ replace_literal \
+ "$README_FILE" \
+ '<!-- revision-date -->.*<!-- /revision-date -->' \
+ "<!-- revision-date -->$revision_date<!-- /revision-date -->"
+ else
+ verbose "README revision-date marker not found; skipping README date update"
+ fi
+}
+
+verify_release_metadata() {
+ local version="$1"
+ local revision_date="$2"
+
+ msg "Verifying release metadata:" "$version ($revision_date)"
+
+ grep -q "readonly VERSION=$version" "$MAIN_SCRIPT" || {
+ error "Version metadata mismatch in:" "$MAIN_SCRIPT"
+ exit $EXIT_RUNTIME_FAILURE
+ }
+
+ grep -q "readonly DATE_OF_REVISION=$revision_date" "$MAIN_SCRIPT" || {
+ error "Revision date metadata mismatch in:" "$MAIN_SCRIPT"
+ exit $EXIT_RUNTIME_FAILURE
+ }
+
+ grep -q ".TH TRANSCODE.SH 1 \"$revision_date\" \"$version\"" "$MAN_PAGE" || {
+ error "Release metadata mismatch in:" "$MAN_PAGE"
+ exit $EXIT_RUNTIME_FAILURE
+ }
+
+ "./$MAIN_SCRIPT" --version | grep -q "Version:" || {
+ error "Could not read version output from:" "./$MAIN_SCRIPT"
+ exit $EXIT_RUNTIME_FAILURE
+ }
+
+ "./$MAIN_SCRIPT" --version | grep -q "$version" || {
+ error "Version output does not contain release version:" "$version"
+ exit $EXIT_RUNTIME_FAILURE
+ }
+
+ "./$MAIN_SCRIPT" --version | grep -q "$revision_date" || {
+ error "Version output does not contain revision date:" "$revision_date"
+ exit $EXIT_RUNTIME_FAILURE
+ }
+}
+
+read_revision_date() {
+ sed -n -E 's/^readonly DATE_OF_REVISION=([0-9]{4}-[0-9]{2}-[0-9]{2})$/\1/p' "$MAIN_SCRIPT"
+}
+
+show_release_diff() {
+ msg "Release metadata diff:"
+ git diff -- "$MAIN_SCRIPT" "$MAN_PAGE" "$README_FILE"
+}
+
+commit_release() {
+ local version="$1"
+
+ if [[ $DRY_RUN -eq 1 ]]; then
+ msg "DRY RUN:" "Would create relase commit: chore(release): prepare $version"
+ return $EXIT_OK
+ fi
+
+ msg "Creating release commit:" "$version"
+ git add "$MAIN_SCRIPT" "$MAN_PAGE" "$README_FILE"
+ git commit -m "chore(release): prepare $version"
+}
+
+tag_release() {
+ local version="$1"
+
+ if [[ $DRY_RUN -eq 1 ]]; then
+ msg "DRY RUN:" "Would create release tag: v$version"
+ return $EXIT_OK
+ fi
+
+ msg "Creating release tag:" "v$version"
+ git tag -a "v$version" -m "Release v$version"
+}
+
+create_archive() {
+ local version="$1"
+ local archive_name="$PROJECT_NAME-$version.tar.gz"
+ local archive_path="$DIST_DIR/$archive_name"
+
+ if [[ $DRY_RUN -eq 1 ]]; then
+ msg "DRY RUN:" "Would create archive: $archive_path"
+ return $EXIT_OK
+ fi
+
+ mkdir -p -- "$DIST_DIR"
+
+ msg "Creating archive:" "$archive_path"
+ git archive \
+ --format=tar.gz \
+ --prefix="$PROJECT_NAME-$version/" \
+ --worktree-attributes \
+ -o "$archive_path" \
+ "v$version"
+
+ msg "Created:" "$archive_path"
+ verbose "Archive contents:"
+ if [[ $VERBOSE -eq 1 && $QUIET -eq 0 ]]; then
+ tar -tzf "$archive_path"
+ fi
+}
+
+prepare_release() {
+ local version="$1"
+ local revision_date
+ revision_date="$(date +%F)"
+
+ require_clean_worktree
+ update_version_and_date "$version" "$revision_date"
+
+ if [[ $DRY_RUN -eq 1 ]]; then
+ msg "DRY RUN:" "Would verify release metadata after applying changes"
+ msg "DRY RUN:" "Would show release metadata diff"
+ return $EXIT_OK
+ fi
+
+ verify_release_metadata "$version" "$revision_date"
+ show_release_diff
+}
+
+release() {
+ local version="$1"
+ local revision_date
+
+ revision_date="$(read_revision_date)"
+ [[ -n "$revision_date" ]] || {
+ error "Could not determine revision date from:" "$MAIN_SCRIPT"
+ exit $EXIT_RUNTIME_FAILURE
+ }
+
+ verify_release_metadata "$version" "$revision_date"
+ require_tag_absent "$version"
+ commit_release "$version"
+ tag_release "$version"
+ create_archive "$version"
+}
+
+###############################################################################
+# MAIN EXECUTION
+###############################################################################
+
+if [[ $# -eq 0 ]]; then
+ debug "No parameters were specified"
+ printf "${BOLD}${BLUE}Usage:${ALL_OFF} ${BOLD}${MAGENTA}%s${ALL_OFF} ${BOLD}${YELLOW}[OPTION]...${ALL_OFF} ${BOLD}${GREEN}COMMAND VERSION${ALL_OFF}
+Run ${BOLD}${MAGENTA}%s${ALL_OFF} ${BOLD}${YELLOW}--help${ALL_OFF} for full usage information.\n" "$SCRIPTNAME" "$SCRIPTNAME" >&2
+ exit $EXIT_USAGE_ERROR
+fi
+
+while [[ $# -gt 0 ]]; do
+ case "$1" in
+ -h | --help | -\?)
+ print_help
+ exit $EXIT_OK
+ ;;
+ --version)
+ print_version
+ exit $EXIT_OK
+ ;;
+ -q | --quiet)
+ QUIET=1
+ shift
+ ;;
+ -v | --verbose)
+ VERBOSE=1
+ shift
+ ;;
+ --color)
+ ENABLE_COLOR=1
+ setup_colors
+ shift
+ ;;
+ --no-color)
+ ENABLE_COLOR=0
+ setup_colors
+ shift
+ ;;
+ -n | --dry-run)
+ DRY_RUN=1
+ shift
+ ;;
+ --)
+ shift
+ break
+ ;;
+ -*)
+ error "Unknown option:" "$1"
+ exit $EXIT_USAGE_ERROR
+ ;;
+ *)
+ break
+ ;;
+ esac
+done
+
+if [[ $# -ne 2 ]]; then
+ error "Expected COMMAND and VERSION."
+ print_help >&2
+ exit $EXIT_USAGE_ERROR
+fi
+
+COMMAND="$1"
+RELEASE_VERSION="$2"
+
+validate_version "$RELEASE_VERSION"
+require_release_dependencies
+require_project_files
+
+case "$COMMAND" in
+prepare)
+ prepare_release "$RELEASE_VERSION"
+ ;;
+release)
+ release "$RELEASE_VERSION"
+ ;;
+*)
+ error "Unknown command:" "$COMMAND"
+ print_help >&2
+ exit $EXIT_USAGE_ERROR
+ ;;
+esac
+
+exit $EXIT_OK
diff --git a/justfile b/justfile
new file mode 100644
index 0000000..7efa940
--- /dev/null
+++ b/justfile
@@ -0,0 +1,13 @@
+set shell := ["bash", "-eu", "-o", "pipefail", "-c"]
+
+default:
+ just --list
+
+prepare version:
+ ./devscripts/release.sh prepare "{{version}}"
+
+release version: (prepare version)
+ ./devscripts/release.sh release "{{version}}"
+
+clean:
+ rm -f dist/transcode-*.tar.gz