#!/usr/bin/env bash # SPDX-FileCopyrightText: 2026 Dennis Fink # # 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 ############################################################################### # 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 " 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 ############################################################################### # OUTPUT HELPERS ############################################################################### 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 } ############################################################################### # HELP AND VERSION OUTPUT ############################################################################### 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 . 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 HANDLING # # Colors and style escape sequences are configured dynamically through the # setup_colors function. Runtime color detection happens in MAIN EXECUTION so # all function definitions stay together before the executable flow. # # 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. ############################################################################### 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 } ############################################################################### # 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 '' "$README_FILE"; then replace_literal \ "$README_FILE" \ '.*' \ "$version" else verbose "README release-version marker not found; skipping README version update" fi if grep -q '' "$README_FILE"; then replace_literal \ "$README_FILE" \ '.*' \ "$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 ############################################################################### # 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 [[ $# -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