#!/usr/bin/env bash # Woodpecker plugin "bump": derives the next version for the pushed branch and # pushes it as a tag on the triggering commit. No commit is made; the build # takes the version from the tag (CI_COMMIT_TAG). The tag event then starts # the build. # # release/X.Y[.Z], hotfix/X.Y.Z next vX.Y.Z-beta.N (D8) # production/* nothing: releases are retags (D9) # settings.release_branch next free vX.Y.Z (repos without majors) # any other branch vX.Y.Z-dev. set -euo pipefail die() { echo "bump: $*" >&2; exit 1; } BRANCH=${CI_COMMIT_BRANCH:?CI_COMMIT_BRANCH is missing (push event only)} SHA=${CI_COMMIT_SHA:?CI_COMMIT_SHA is missing} REMOTE=${PLUGIN_REMOTE:-${CI_REPO_CLONE_URL:?CI_REPO_CLONE_URL is missing}} GIT_USER=${PLUGIN_GIT_USER:-oumta-ci} RELEASE_LINE=${PLUGIN_RELEASE_LINE:-} RELEASE_BRANCH=${PLUGIN_RELEASE_BRANCH:-} [[ -n ${PLUGIN_GIT_TOKEN:-} ]] || die "settings.git_token is missing" case $BRANCH in production/*) die "no bump on $BRANCH: a release is a retag of a beta image (D9)" ;; esac # Token only via credential helper, never in a URL or config, so no log shows it export GIT_USER PLUGIN_GIT_TOKEN git config --global --add safe.directory "$PWD" git config credential.helper '!f() { echo "username=$GIT_USER"; echo "password=$PLUGIN_GIT_TOKEN"; }; f' git remote set-url origin "$REMOTE" # The Woodpecker clone is shallow and has no tags; the version rules need both. if [[ $(git rev-parse --is-shallow-repository) == true ]]; then git fetch --quiet --unshallow origin fi git fetch --quiet --tags --force origin "+refs/heads/*:refs/remotes/origin/*" # Tag exactly the commit that triggered the pipeline. A newer push on the # branch starts its own pipeline, so this one steps aside. head=$(git rev-parse -q --verify "origin/$BRANCH") || die "$BRANCH no longer exists on origin" if [[ $head != "$SHA" ]]; then echo "bump: $BRANCH moved past ${SHA:0:8}, the newer pipeline bumps" exit 0 fi git checkout --quiet "$SHA" tag_exists() { git rev-parse -q --verify "refs/tags/v$1" >/dev/null; } # "2.4" or "v2.4.1" -> "2.4.0" / "2.4.1"; fails for anything else parse_line() { [[ $1 =~ ^v?([0-9]+)\.([0-9]+)(\.([0-9]+))?$ ]] || return 1 echo "${BASH_REMATCH[1]}.${BASH_REMATCH[2]}.${BASH_REMATCH[4]:-0}" } # release/2.4 -> 2.4.0, hotfix/2.4.1 -> 2.4.1; a hotfix without a patch fails line_of_branch() { local kind name [[ $1 =~ ^(release|hotfix)/(.+)$ ]] || return 1 kind=${BASH_REMATCH[1]} name=${BASH_REMATCH[2]} [[ $kind == hotfix && ! $name =~ ^v?[0-9]+\.[0-9]+\.[0-9]+$ ]] && return 1 parse_line "$name" } # The release or hotfix branch whose merge base with HEAD is the newest, i.e. # the line this commit was branched from most recently. On a tie (a hotfix # forked from the same commit as the release) the lower line wins. line_of_nearest_branch() { local ref line base while read -r ref; do line=$(line_of_branch "${ref#origin/}") || continue base=$(git merge-base "$ref" HEAD 2>/dev/null) || continue echo "$(git show -s --format=%ct "$base") ${line//./ } $line" done < <(git for-each-ref --format='%(refname:short)' refs/remotes/origin/release/ refs/remotes/origin/hotfix/) | sort -k1,1nr -k2,2n -k3,3n -k4,4n | head -n 1 | { read -r _ _ _ _ line && echo "$line"; } } # major.minor.0 from the version in package.json, if there is one line_of_package() { local version [[ -f package.json ]] || return 1 version=$(sed -nE 's/^[[:space:]]*"version"[[:space:]]*:[[:space:]]*"([^"]+)".*/\1/p' package.json | head -n 1) version=$(parse_line "${version%%[-+]*}") || return 1 echo "${version%.*}.0" } # The line for this branch: setting, branch name, nearest release/hotfix # branch, else major.minor of package.json. resolve_line() { if [[ -n $RELEASE_LINE ]]; then parse_line "$RELEASE_LINE" || die "invalid settings.release_line '$RELEASE_LINE', expected .[.]" return fi line_of_branch "$BRANCH" && return line_of_nearest_branch && return line_of_package && return die "no release line for '$BRANCH': use settings.release_line or a semantic version in package.json" } # The next stable version of a line: highest existing vX.Y.Z plus one patch. next_stable() { local line=$1 highest highest=$(git tag --list "v${line%.*}.*" | sed -nE 's/^v[0-9]+\.[0-9]+\.([0-9]+)$/\1/p' | sort -n | tail -n 1) echo "${line%.*}.$(( ${highest:--1} + 1 ))" } # A line whose stable tag exists was released; the next patch is the open one. next_free_patch() { local line=$1 patch while tag_exists "$line"; do patch=${line##*.} line=${line%.*}.$((patch + 1)) done echo "$line" } next_beta() { local line=$1 highest highest=$(git tag --list "v$line-beta.*" | sed -nE 's/^.*-beta\.([0-9]+)$/\1/p' | sort -n | tail -n 1) echo "$line-beta.$(( ${highest:--1} + 1 ))" } next_dev() { local line=$1 stamp while :; do stamp=$(date -u +%Y%m%d%H%M%S) tag_exists "$line-dev.$stamp" || { echo "$line-dev.$stamp"; return; } sleep 1 done } case $BRANCH in release/* | hotfix/*) line=$(line_of_branch "$BRANCH") || die "unusable branch name '$BRANCH', expected release/X.Y[.Z] or hotfix/X.Y.Z" VERSION=$(next_beta "$(next_free_patch "$line")") ;; *) if [[ -n $RELEASE_BRANCH && $BRANCH == "$RELEASE_BRANCH" ]]; then VERSION=$(next_stable "$(resolve_line)") else VERSION=$(next_dev "$(next_free_patch "$(resolve_line)")") fi ;; esac TAG=v$VERSION # A re-run must not produce a second tag and a second build for the same commit. # Only tags of the same kind count: a dev tag on a commit that is then merged # fast-forward into a release branch must not block its beta tag. case $TAG in *-dev.*) same_kind='^v[0-9]+\.[0-9]+\.[0-9]+-dev\.' ;; *-beta.*) same_kind='^v[0-9]+\.[0-9]+\.[0-9]+-beta\.' ;; *) same_kind='^v[0-9]+\.[0-9]+\.[0-9]+$' ;; esac existing=$(git tag --points-at HEAD 'v*' | grep -E "$same_kind" | head -n 1 || true) if [[ -n $existing ]]; then echo "bump: ${SHA:0:8} already carries $existing, nothing to do" exit 0 fi git tag "$TAG" if ! git push --quiet origin "refs/tags/$TAG"; then echo "bump: push of $TAG rejected, the newer pipeline bumps" exit 0 fi echo "bump: $TAG on $BRANCH (${SHA:0:8})"