release-kit 0.9.14

A canonical release workflow: a technology-agnostic method, per-technology bindings, and the rk CLI that lands and serves them.
Documentation
#!/usr/bin/env sh
# Create the bot identity in the form the forge and the tier allow today.
#
# A self-managed or Dedicated instance, and a paid GitLab.com namespace,
# take a project access token, whose creation also creates its bot user.
# A Free GitLab.com group takes a group service account that is a member of
# this one project at Maintainer, with a personal access token of its own.
# A Free personal namespace has no form, so the step refuses before it
# writes anything. The tier is read here, on every run, and recorded nowhere.
#
# The create response is the only time the forge shows a token's value, so
# this step stores it as the masked CI variable in the same breath. The
# value travels on standard input; printf is a shell builtin, so no process
# argument list ever holds it. Idempotent: a live identity whose variable is
# stored is re-asserted, and one whose variable has gone missing gets a new
# token, because the old value is unrecoverable.
set -eu
: "${RK_REPO:?rk sets this; run this script through rk setup}"

project="$(printf '%s' "$RK_REPO" | sed 's|/|%2F|g')"
root="${RK_REPO%%/*}"

# The forge CLI may pretty-print JSON. Dropping the whitespace between
# tokens, and keeping the whitespace inside strings, makes every pattern
# below hold for the compact and the pretty form alike without making two
# different names equal.
compact() {
  awk '{ text = text $0 "\n" } END {
    out = ""; inside = 0; escaped = 0
    for (i = 1; i <= length(text); i++) {
      c = substr(text, i, 1)
      if (inside) {
        out = out c
        if (escaped) escaped = 0
        else if (c == "\\") escaped = 1
        else if (c == "\"") inside = 0
      } else if (c == "\"") { out = out c; inside = 1 }
      else if (c !~ /[ \t\r\n]/) out = out c
    }
    printf "%s", out
  }'
}
# The first value of one field in compact JSON on standard input.
text_of() { grep -o "\"$1\":\"[^\"]*\"" | head -n 1 | cut -d '"' -f 4; }
number_of() { grep -o "\"$1\":[0-9]*" | head -n 1 | cut -d : -f 2; }
flag_of() { grep -o "\"$1\":[a-z]*" | head -n 1 | cut -d : -f 2; }
# The first value of one field on each line, one per output line.
number_of_each() { while IFS= read -r line; do printf '%s' "$line" | number_of "$1"; done; }
# The lines of a one-object-per-line listing whose field equals a value
# exactly. setup.bot.name holds to the username shape, so a name carries no
# quote or backslash a fixed-string pattern would need escaped.
# No match is an empty answer, not a failure, so set -e lets it through.
where() { grep -F "\"$1\":$2" || true; }

# Every token this step mints carries the marker, so the live bot is found
# by what marks it rather than by how a default spelled its name.
marker='release-kit bot identity'

# A typed field sends a JSON array; a raw field would send a string.
scopes='scopes=["api","write_repository"]'
# Tokens require an expiry; the first of this month next year is always a
# valid date, inside the 365-day cap, and lands eleven to twelve months out.
expires="$(($(date -u +%Y) + 1))-$(date -u +%m)-01"

stored=0
if glab api "projects/$project/variables/RELEASE_BOT_TOKEN" >/dev/null 2>&1; then
  stored=1
fi
store() {
  if [ "$stored" = "1" ]; then
    printf '%s' "$1" | glab variable update RELEASE_BOT_TOKEN --masked --repo "$RK_REPO"
  else
    printf '%s' "$1" | glab variable set RELEASE_BOT_TOKEN --masked --repo "$RK_REPO"
  fi
}
token_of() { compact | text_of token; }

# The path: a self-managed or Dedicated instance answers plan "default"
# whatever its license, so only gitlab.com reads the namespace.
path=token
if [ "${RK_GITLAB_COM:-false}" = "true" ]; then
  if ! namespace="$(glab api "namespaces/$root")"; then
    echo "FAIL the namespace $root could not be read" >&2
    echo 'remediation: check that glab is authenticated against gitlab.com with access to the namespace, then rerun' >&2
    exit 1
  fi
  namespace="$(printf '%s' "$namespace" | compact)"
  plan="$(printf '%s' "$namespace" | text_of plan)"
  kind="$(printf '%s' "$namespace" | text_of kind)"
  trial="$(printf '%s' "$namespace" | flag_of trial)"
  group="$(printf '%s' "$namespace" | number_of id)"
  echo "note: the namespace $root reads plan ${plan:-unknown}, kind ${kind:-unknown}, trial ${trial:-false}"
  case "$plan" in
    premium* | ultimate* | silver* | gold* | opensource) path=token ;;
    free)
      case "$kind" in
        group) path=service-account ;;
        user)
          echo "FAIL the personal namespace $root has no bot identity form on GitLab.com Free" >&2
          echo 'remediation: move the project under a group, whose Owner can create a service account, or use a Premium or Ultimate tier, which allows a project access token' >&2
          exit 1
          ;;
        *)
          echo "FAIL the namespace $root reads kind ${kind:-unknown}, which names no identity form" >&2
          echo 'remediation: check that glab reads the namespace the project lives in, then rerun' >&2
          exit 1
          ;;
      esac
      ;;
    *)
      echo "FAIL the namespace $root reads plan ${plan:-unknown}, which names no identity form" >&2
      echo 'remediation: check that glab is authenticated against gitlab.com and reads the namespace, then rerun' >&2
      exit 1
      ;;
  esac
else
  echo 'note: a self-managed or Dedicated instance takes a project access token with any license'
fi

if [ "$path" = "token" ]; then
  name="${RK_BOT_NAME:-release-bot}"
  if ! tokens="$(glab api --paginate "projects/$project/access_tokens?state=active&per_page=100")"; then
    echo 'FAIL the access tokens could not be listed' >&2
    echo 'remediation: check the caller has Maintainer access or above, then rerun' >&2
    exit 1
  fi
  # One token object per line: the objects nest nothing but flat arrays, so
  # splitting on the closing brace isolates each, and fields match in any
  # order within the line.
  tokens="$(printf '%s' "$tokens" | compact | tr '}' '\n' | where active true)"
  active="$(printf '%s\n' "$tokens" | where name "\"$name\"" | number_of id)"
  # The live bot under another name: a marked token, or one named
  # release-bot from before the marker existed.
  other="$( { printf '%s\n' "$tokens" | where description "\"$marker\""; printf '%s\n' "$tokens" | where name '"release-bot"'; } | grep -F -v "\"name\":\"$name\"" | text_of name)"
  # Checked whether or not a token under the requested name exists: a
  # second identity beside the live bot is a rename either way.
  if [ -n "$other" ]; then
    echo "FAIL the release-kit bot $other serves this project, and setup.bot.name asks for $name" >&2
    echo "remediation: revoke $other by hand before the rename, or set setup.bot.name back to $other; a rename would strand the first identity" >&2
    exit 1
  fi
  if [ -n "$active" ] && [ "$stored" = "1" ]; then
    echo "ok an active token named $name exists and its variable is stored"
  else
    if [ -n "$active" ]; then
      glab api -X DELETE "projects/$project/access_tokens/$active" >/dev/null
      echo "note: revoked an active $name token whose stored variable was missing"
    fi
    if ! response="$(glab api -X POST "projects/$project/access_tokens" \
      -f "name=$name" \
      -f "description=$marker" \
      -F "$scopes" \
      -F access_level=40 \
      -f "expires_at=$expires")"; then
      echo "FAIL the project access token $name could not be created" >&2
      echo 'remediation: the caller needs the Maintainer role on the project; on GitLab.com the namespace needs Premium or Ultimate' >&2
      exit 1
    fi
    token="$(printf '%s' "$response" | token_of)"
    if [ -z "$token" ]; then
      echo 'FAIL the create response carried no token value' >&2
      echo 'remediation: check the caller has Maintainer access or above, then rerun' >&2
      exit 1
    fi
    store "$token"
    echo "ok created $name and stored RELEASE_BOT_TOKEN"
  fi
  echo "check: prints one active token named $name and the stored variable"
  glab api --paginate "projects/$project/access_tokens?state=active&per_page=100" | compact | tr '}' '\n' | where name "\"$name\"" | grep -o '"active":true' | head -n 1
  glab api "projects/$project/variables/RELEASE_BOT_TOKEN" | compact | grep -o '"key":"RELEASE_BOT_TOKEN"'
  exit 0
fi

# The service-account path. A username is unique across the whole
# instance, so the default carries the project id, which only the forge
# answers; a last path segment repeats across groups.
project_id="$(glab api "projects/$project" | compact | number_of id)"
if [ -z "$project_id" ] || [ -z "$group" ]; then
  echo 'FAIL the project id or the group id could not be read' >&2
  echo 'remediation: check that glab reads the project and its top-level group, then rerun' >&2
  exit 1
fi
default="release-bot-$project_id"
name="${RK_BOT_NAME:-$default}"
if ! accounts="$(glab api --paginate "groups/$group/service_accounts?per_page=100")"; then
  echo "FAIL the service accounts of $root could not be listed" >&2
  echo "remediation: the caller needs the Owner role on the top-level group $root, then rerun" >&2
  exit 1
fi
accounts="$(printf '%s' "$accounts" | compact | tr '}' '\n' | where username '')"
account_of() { printf '%s\n' "$accounts" | where username "\"$1\"" | number_of id; }
level_of() { glab api "projects/$project/members/all/$1" 2>/dev/null | compact | number_of access_level; }
# Whether an account holds an active token this step marked, or
# "unreadable" where the listing failed: a failed read is no absence.
marked() {
  if ! listing="$(glab api --paginate "groups/$group/service_accounts/$1/personal_access_tokens?state=active&per_page=100")"; then
    echo unreadable
    return 0
  fi
  printf '%s' "$listing" | compact | tr '}' '\n' | where active true | where description "\"$marker\"" | head -n 1
}
# The release-kit bot that already serves this project: an account of the
# group that is a member here and either holds a marked token or carries
# the default username from before the marker existed. An unrelated
# service account that is a member here is neither.
if ! members="$(glab api --paginate "projects/$project/members/all?per_page=100")"; then
  echo 'FAIL the members of this project could not be read, so the live bot is unknown' >&2
  echo 'remediation: check that glab reads the project members, then rerun; nothing was written' >&2
  exit 1
fi
members="$(printf '%s' "$members" | compact | tr '}' '\n' | number_of_each id)"
live_bot() {
  printf '%s\n' "$accounts" | while IFS= read -r line; do
    id="$(printf '%s' "$line" | number_of id)"
    user="$(printf '%s' "$line" | text_of username)"
    [ -n "$id" ] || continue
    printf '%s\n' "$members" | grep -qx "$id" || continue
    if [ "$user" = "$default" ]; then
      printf '%s\n' "$user"
      continue
    fi
    mark="$(marked "$id")"
    if [ "$mark" = "unreadable" ]; then
      printf 'unreadable %s\n' "$user"
    elif [ -n "$mark" ]; then
      printf '%s\n' "$user"
    fi
  done
}
mint() {
  if ! response="$(glab api -X POST "groups/$group/service_accounts/$1/personal_access_tokens" \
    -f "name=$name" \
    -f "description=$marker" \
    -F "$scopes" \
    -f "expires_at=$expires")"; then
    echo "FAIL a token could not be minted for the service account $name" >&2
    echo "remediation: the caller needs the Owner role on the top-level group $root, then rerun" >&2
    exit 1
  fi
  token="$(printf '%s' "$response" | token_of)"
  if [ -z "$token" ]; then
    echo 'FAIL the mint response carried no token value' >&2
    echo "remediation: check the caller is an Owner of $root, then rerun" >&2
    exit 1
  fi
  store "$token"
}

account="$(account_of "$name")"
# Checked whether or not an account under the requested name exists: a
# second identity beside the live bot is a rename either way.
bots="$(live_bot)"
unreadable="$(printf '%s\n' "$bots" | grep '^unreadable ' | head -n 1 | cut -d ' ' -f 2)"
if [ -n "$unreadable" ]; then
  echo "FAIL the tokens of the service account $unreadable could not be read, so the live bot is unknown" >&2
  echo "remediation: check the caller is an Owner of $root, then rerun; nothing was written" >&2
  exit 1
fi
previous="$(printf '%s\n' "$bots" | grep -F -v -x "$name" | head -n 1)"
if [ -n "$previous" ]; then
  echo "FAIL the release-kit bot $previous serves this project, and setup.bot.name asks for $name" >&2
  echo "remediation: remove $previous from the project by hand before the rename, or set setup.bot.name back to $previous; a rename would strand the first identity" >&2
  exit 1
fi

if [ -z "$account" ]; then
  # No email: GitLab generates the noreply address the commit links by.
  if ! created="$(glab api -X POST "groups/$group/service_accounts" \
    -f "name=$name" \
    -f "username=$name")"; then
    echo "FAIL the service account $name could not be created in $root" >&2
    echo "remediation: creating a service account needs the Owner role on the top-level group $root, whose Owner must have verified their identity on a trial; a Free group holds up to 100" >&2
    exit 1
  fi
  account="$(printf '%s' "$created" | compact | number_of id)"
  if [ -z "$account" ]; then
    echo 'FAIL the create response carried no account id' >&2
    echo "remediation: check the caller is an Owner of $root, then rerun" >&2
    exit 1
  fi
  glab api -X POST "projects/$project/members" -F "user_id=$account" -F access_level=40 >/dev/null
  mint "$account"
  echo "ok created the service account $name, granted it this project at Maintainer, and stored RELEASE_BOT_TOKEN"
else
  level="$(level_of "$account")"
  if [ -z "$level" ]; then
    echo "FAIL the service account $name exists in $root and is not a member of this project" >&2
    echo "remediation: grant it this project at Maintainer by hand if it is this project's own account from an interrupted run, or set another setup.bot.name; granting it here could make one identity reach two projects" >&2
    exit 1
  fi
  if [ "$level" -lt 40 ]; then
    echo "FAIL the service account $name is a member of this project below Maintainer (level $level)" >&2
    echo 'remediation: raise its role on the project to Maintainer by hand, then rerun' >&2
    exit 1
  fi
  if [ "$stored" = "1" ]; then
    echo "ok a service account named $name is a Maintainer of this project and its variable is stored"
  else
    mint "$account"
    echo "ok rotated: minted a new token on the service account $name and stored RELEASE_BOT_TOKEN"
  fi
fi

echo "check: prints the account's Maintainer level and the stored variable"
glab api "projects/$project/members/all/$account" | compact | grep -o '"access_level":[0-9]*'
glab api "projects/$project/variables/RELEASE_BOT_TOKEN" | compact | grep -o '"key":"RELEASE_BOT_TOKEN"'