finaira-release 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,55 @@
1
+ Metadata-Version: 2.4
2
+ Name: finaira-release
3
+ Version: 0.1.0
4
+ Summary: Release tooling for trunk-based repositories: RC and final tags, Azure DevOps deploys, release branches and hotfixes. The repositories come from a .env.
5
+ Requires-Python: >=3.8
6
+ Description-Content-Type: text/markdown
7
+
8
+ # finaira-release
9
+
10
+ Release tooling for a trunk-based repository (GitHub as the source, Azure DevOps as the deploy target): RC and final tags, deploys to the Azure DevOps
11
+ `dev` and `prod` branches, release branches, fixes, hotfixes and ports. It packages two bash scripts behind one
12
+ command, `fin-release`. The package contains no URLs, hosts or credentials: the repositories come from your `.env`, so it works for any repo set up this way.
13
+
14
+ ## Install
15
+
16
+ ```bash
17
+ pip install finaira-release
18
+ ```
19
+
20
+ Needs WSL (or Linux), `bash`, `git`, `curl` and GNU `sort -V`, plus the VPN if your Azure DevOps needs one.
21
+ Windows PowerShell is not supported.
22
+
23
+ ## Settings
24
+
25
+ Create a `.env` (never commit it, `chmod 600 .env`) in your repository clone or in any parent folder of where you run
26
+ the command. `fin-release` uses the nearest `.env` going up from the current folder. Variables already set in the shell win.
27
+
28
+ ```
29
+ GITHUB_REPO=git@github.com:<org>/<repo>.git
30
+ AZDO_HOST_PATH=dev.azure.com/<org>/<project>/_git/<repo>
31
+ AZDO_USERNAME=your-azdo-username-or-email
32
+ AZDO_PASSWORD=your-personal-access-token
33
+ AZDO_DEV_BRANCH=dev
34
+ AZDO_PROD_BRANCH=prod
35
+ ```
36
+
37
+ `GITHUB_REPO` can be an SSH or an HTTPS URL, whichever your machine can reach without a prompt. `AZDO_PASSWORD` is your own PAT
38
+ with push permission on `dev` and `prod`. A missing variable stops the command and names it. `AZDO_DEV_BRANCH` and `AZDO_PROD_BRANCH` are optional: they name the Azure branches that deploy to Dev and Prod (defaults `dev` and `prod`). Put values on their own line, without trailing comments. The two branches must differ; `deploy-dev` and `deploy-prod` write only to them, and `mirror` refuses to write to them.
39
+
40
+ ## Use
41
+
42
+ ```bash
43
+ fin-release check # settings, PAT, VPN, tools, signing
44
+ fin-release status
45
+ fin-release deploy-dev --new-rc # cut the next RC and deploy it to Dev
46
+ fin-release deploy-prod --promote # final tag on the latest RC, deploy to Prod
47
+ fin-release branch 1.5.0 # release branches and fixes
48
+ fin-release fix PROJ-123 handle-null --release 1.4.0
49
+ fin-release --dry-run deploy-prod --promote
50
+ fin-release sync --help # full usage of deploys and tags
51
+ fin-release release --help # full usage of branches and fixes
52
+ ```
53
+
54
+ The arguments are exactly those of `sync-branch.sh` and `release.sh` in your release runbook.
55
+ Everything runs on remote refs in a temporary repository, so your clone and uncommitted work are never touched.
@@ -0,0 +1,48 @@
1
+ # finaira-release
2
+
3
+ Release tooling for a trunk-based repository (GitHub as the source, Azure DevOps as the deploy target): RC and final tags, deploys to the Azure DevOps
4
+ `dev` and `prod` branches, release branches, fixes, hotfixes and ports. It packages two bash scripts behind one
5
+ command, `fin-release`. The package contains no URLs, hosts or credentials: the repositories come from your `.env`, so it works for any repo set up this way.
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ pip install finaira-release
11
+ ```
12
+
13
+ Needs WSL (or Linux), `bash`, `git`, `curl` and GNU `sort -V`, plus the VPN if your Azure DevOps needs one.
14
+ Windows PowerShell is not supported.
15
+
16
+ ## Settings
17
+
18
+ Create a `.env` (never commit it, `chmod 600 .env`) in your repository clone or in any parent folder of where you run
19
+ the command. `fin-release` uses the nearest `.env` going up from the current folder. Variables already set in the shell win.
20
+
21
+ ```
22
+ GITHUB_REPO=git@github.com:<org>/<repo>.git
23
+ AZDO_HOST_PATH=dev.azure.com/<org>/<project>/_git/<repo>
24
+ AZDO_USERNAME=your-azdo-username-or-email
25
+ AZDO_PASSWORD=your-personal-access-token
26
+ AZDO_DEV_BRANCH=dev
27
+ AZDO_PROD_BRANCH=prod
28
+ ```
29
+
30
+ `GITHUB_REPO` can be an SSH or an HTTPS URL, whichever your machine can reach without a prompt. `AZDO_PASSWORD` is your own PAT
31
+ with push permission on `dev` and `prod`. A missing variable stops the command and names it. `AZDO_DEV_BRANCH` and `AZDO_PROD_BRANCH` are optional: they name the Azure branches that deploy to Dev and Prod (defaults `dev` and `prod`). Put values on their own line, without trailing comments. The two branches must differ; `deploy-dev` and `deploy-prod` write only to them, and `mirror` refuses to write to them.
32
+
33
+ ## Use
34
+
35
+ ```bash
36
+ fin-release check # settings, PAT, VPN, tools, signing
37
+ fin-release status
38
+ fin-release deploy-dev --new-rc # cut the next RC and deploy it to Dev
39
+ fin-release deploy-prod --promote # final tag on the latest RC, deploy to Prod
40
+ fin-release branch 1.5.0 # release branches and fixes
41
+ fin-release fix PROJ-123 handle-null --release 1.4.0
42
+ fin-release --dry-run deploy-prod --promote
43
+ fin-release sync --help # full usage of deploys and tags
44
+ fin-release release --help # full usage of branches and fixes
45
+ ```
46
+
47
+ The arguments are exactly those of `sync-branch.sh` and `release.sh` in your release runbook.
48
+ Everything runs on remote refs in a temporary repository, so your clone and uncommitted work are never touched.
@@ -0,0 +1,20 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "finaira-release"
7
+ version = "0.1.0"
8
+ description = "Release tooling for trunk-based repositories: RC and final tags, Azure DevOps deploys, release branches and hotfixes. The repositories come from a .env."
9
+ readme = "README.md"
10
+ requires-python = ">=3.8"
11
+ dependencies = []
12
+
13
+ [project.scripts]
14
+ fin-release = "fin_release.cli:main"
15
+
16
+ [tool.setuptools.packages.find]
17
+ where = ["src"]
18
+
19
+ [tool.setuptools.package-data]
20
+ fin_release = ["scripts/*.sh"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
@@ -0,0 +1,74 @@
1
+ import os
2
+ import shutil
3
+ import subprocess
4
+ import sys
5
+ from pathlib import Path
6
+
7
+ from fin_release import __version__
8
+
9
+ SCRIPTS = Path(__file__).resolve().parent / "scripts"
10
+ SYNC_COMMANDS = {"deploy-dev", "deploy-prod", "status", "verify", "mirror", "check"}
11
+ RELEASE_COMMANDS = {"branch", "fix", "hotfix", "port"}
12
+
13
+ HELP = f"""fin-release {__version__}
14
+
15
+ Usage: fin-release [--dry-run] [--yes] <command> ...
16
+
17
+ Deploys and tags (sync-branch.sh):
18
+ deploy-dev, deploy-prod, status, verify, mirror, check
19
+ Branches and fixes (release.sh):
20
+ branch, fix, hotfix, port
21
+
22
+ 'fin-release sync <command> --help' and 'fin-release release <command> --help'
23
+ show the full usage of each group.
24
+
25
+ Settings are read from the nearest .env in the current folder or any parent:
26
+ GITHUB_REPO, AZDO_HOST_PATH, AZDO_USERNAME, AZDO_PASSWORD
27
+ """
28
+
29
+
30
+ def find_env(start):
31
+ for folder in [start, *start.parents]:
32
+ candidate = folder / ".env"
33
+ if candidate.is_file():
34
+ return candidate
35
+ return None
36
+
37
+
38
+ def pick_script(args):
39
+ if args and args[0] in ("sync", "release"):
40
+ name = "sync-branch.sh" if args[0] == "sync" else "release.sh"
41
+ return name, args[1:]
42
+ for arg in args:
43
+ if arg in SYNC_COMMANDS:
44
+ return "sync-branch.sh", args
45
+ if arg in RELEASE_COMMANDS:
46
+ return "release.sh", args
47
+ return None, args
48
+
49
+
50
+ def main(argv=None):
51
+ args = list(sys.argv[1:] if argv is None else argv)
52
+ if not args or args == ["-h"] or args == ["--help"]:
53
+ print(HELP)
54
+ return 0
55
+ if args == ["--version"]:
56
+ print(__version__)
57
+ return 0
58
+ script, args = pick_script(args)
59
+ if script is None:
60
+ print(f"Unknown command: {' '.join(args)}\n\n{HELP}", file=sys.stderr)
61
+ return 1
62
+ bash = shutil.which("bash")
63
+ if bash is None:
64
+ print("bash is required. Run fin-release from WSL or Linux.", file=sys.stderr)
65
+ return 1
66
+ env = dict(os.environ)
67
+ env_file = find_env(Path.cwd())
68
+ if env_file is not None:
69
+ env.setdefault("FIN_RELEASE_ENV", str(env_file))
70
+ return subprocess.run([bash, str(SCRIPTS / script), *args], env=env).returncode
71
+
72
+
73
+ if __name__ == "__main__":
74
+ sys.exit(main())
@@ -0,0 +1,162 @@
1
+ #!/bin/bash
2
+ # GitHub helper for trunk-based development: release branches, fixes, hotfixes and ports.
3
+ # Run it with --help for usage.
4
+ #
5
+ # It only touches GitHub. Release tags and deployments to Azure DevOps are done by
6
+ # scripts/sync-branch.sh (deploy-dev --new-rc and deploy-prod --promote).
7
+ #
8
+ # Everything works on GitHub's remote refs through a temporary repository, so your local
9
+ # branches, working directory and uncommitted work are never touched. Tags are never moved,
10
+ # deleted or reused. Signing is left to your git config (tag.gpgsign and the org rules).
11
+
12
+ set -euo pipefail
13
+
14
+ source "$(dirname "${BASH_SOURCE[0]}")/release_common.sh"
15
+
16
+ RELEASE=""
17
+ FROM=""
18
+ TO=""
19
+
20
+ trap cleanup_scratch EXIT
21
+
22
+ usage() {
23
+ cat << 'EOF'
24
+ Usage: release.sh [--dry-run] [--yes] <command>
25
+
26
+ branch X.Y.Z create release/X.Y.Z from main
27
+ fix TICKET slug --release X.Y.Z fix/TICKET/slug branch from release/X.Y.Z
28
+ hotfix --from vX.Y.Z TICKET slug release/X.Y.(Z+1) from the production tag, plus hotfix/TICKET/slug
29
+ port <sha> TICKET --to main|X.Y.Z cherry-pick -x a commit into bug/main|release/TICKET
30
+
31
+ Every write asks y/N first; --yes answers yes. --dry-run explains each command and what it would do,
32
+ and changes nothing. Release tags and deployments: sync-branch.sh deploy-dev / deploy-prod.
33
+ EOF
34
+ }
35
+
36
+ create_branch() {
37
+ step "Create GitHub branch $2" git push "$GITHUB_REPO" "$1:refs/heads/$2"
38
+ }
39
+
40
+ cmd_branch() {
41
+ local tip
42
+ require_version "${1:-}"
43
+ require_branch_name "release/$1"
44
+ [[ -z "$(github_branch_sha "release/$1")" ]] || die "release/$1 already exists."
45
+ fetch_ref refs/heads/main
46
+ tip=$(git rev-parse refs/heads/main)
47
+ confirm "Create release/$1 from main @ ${tip:0:7}?"
48
+ create_branch "$tip" "release/$1"
49
+ }
50
+
51
+ cmd_fix() {
52
+ local ticket="${1:-}" slug="${2:-}" name
53
+ require_ticket "$ticket"
54
+ require_slug "$slug"
55
+ require_version "$RELEASE"
56
+ name="fix/$ticket/$slug"
57
+ require_branch_name "$name"
58
+ [[ -n "$(github_branch_sha "release/$RELEASE")" ]] || die "release/$RELEASE does not exist."
59
+ [[ -z "$(github_branch_sha "$name")" ]] || die "$name already exists."
60
+ fetch_ref "refs/heads/release/$RELEASE"
61
+ confirm "Create $name from release/$RELEASE?"
62
+ create_branch "refs/heads/release/$RELEASE" "$name"
63
+ echo "Open the PR with: gh pr create --base release/$RELEASE --head $name"
64
+ }
65
+
66
+ cmd_hotfix() {
67
+ local ticket="${1:-}" slug="${2:-}" version next base name
68
+ require_ticket "$ticket"
69
+ require_slug "$slug"
70
+ version="${FROM#v}"
71
+ require_version "$version"
72
+ github_tags | grep -qx -- "$FROM" || die "Production tag $FROM does not exist on GitHub."
73
+ next="${version%.*}.$((${version##*.} + 1))"
74
+ name="hotfix/$ticket/$slug"
75
+ require_branch_name "release/$next"
76
+ require_branch_name "$name"
77
+ [[ -z "$(github_branch_sha "$name")" ]] || die "$name already exists."
78
+ if [[ -z "$(github_branch_sha "release/$next")" ]]; then
79
+ base=$(tag_commit "$FROM")
80
+ confirm "Create release/$next from $FROM and $name from it?"
81
+ create_branch "$base" "release/$next"
82
+ else
83
+ fetch_ref "refs/heads/release/$next"
84
+ base=$(git rev-parse "refs/heads/release/$next")
85
+ confirm "Create $name from the existing release/$next?"
86
+ fi
87
+ create_branch "$base" "$name"
88
+ echo "Open the PR with: gh pr create --base release/$next --head $name"
89
+ }
90
+
91
+ cmd_port() {
92
+ local sha="${1:-}" ticket="${2:-}" target name worktree
93
+ [[ "$sha" =~ ^[0-9a-f]{7,40}$ ]] || die "'$sha' is not a commit sha."
94
+ require_ticket "$ticket"
95
+ if [[ "$TO" == "main" ]]; then
96
+ target="main"
97
+ name="bug/main/$ticket"
98
+ else
99
+ require_version "$TO"
100
+ target="release/$TO"
101
+ name="bug/release/$ticket"
102
+ fi
103
+ require_branch_name "$name"
104
+ [[ -n "$(github_branch_sha "$target")" ]] || die "$target does not exist."
105
+ [[ -z "$(github_branch_sha "$name")" ]] || die "$name already exists."
106
+ git fetch -q --no-tags "$GITHUB_REPO" '+refs/heads/*:refs/remotes/github/*'
107
+ git cat-file -e "$sha^{commit}" 2> /dev/null || die "Commit $sha is not on any GitHub branch."
108
+ confirm "Cherry-pick $sha onto $target as $name?"
109
+ worktree="$SCRATCH/worktree"
110
+ git worktree add -q -b "$name" "$worktree" "refs/remotes/github/$target"
111
+ if ! env -u GIT_DIR git -C "$worktree" cherry-pick -x "$sha" > /dev/null; then
112
+ env -u GIT_DIR git -C "$worktree" cherry-pick --abort || true
113
+ echo "Cherry-pick conflict: nothing was pushed. Resolve it by hand:" >&2
114
+ echo " git switch -c $name origin/$target && git cherry-pick -x $sha" >&2
115
+ exit 1
116
+ fi
117
+ create_branch "refs/heads/$name" "$name"
118
+ echo "Open the PR with: gh pr create --base $target --head $name"
119
+ }
120
+
121
+ dispatch() {
122
+ local command="${1:-}"
123
+ shift
124
+ load_github_settings
125
+ init_scratch
126
+ case "$command" in
127
+ branch) cmd_branch "$@" ;;
128
+ fix) cmd_fix "$@" ;;
129
+ hotfix) cmd_hotfix "$@" ;;
130
+ port) cmd_port "$@" ;;
131
+ *)
132
+ echo "Unknown command: $command" >&2
133
+ usage >&2
134
+ exit 1
135
+ ;;
136
+ esac
137
+ }
138
+
139
+ ARGS=()
140
+ while [[ $# -gt 0 ]]; do
141
+ case "$1" in
142
+ --dry-run) DRY_RUN=1; shift ;;
143
+ --yes | -y) ASSUME_YES=1; shift ;;
144
+ --release | --from | --to)
145
+ [[ -n "${2:-}" ]] || die "$1 requires a value."
146
+ case "$1" in
147
+ --release) RELEASE="$2" ;;
148
+ --from) FROM="$2" ;;
149
+ --to) TO="$2" ;;
150
+ esac
151
+ shift 2
152
+ ;;
153
+ -h | --help) usage; exit 0 ;;
154
+ *) ARGS+=("$1"); shift ;;
155
+ esac
156
+ done
157
+
158
+ if [[ ${#ARGS[@]} -eq 0 ]]; then
159
+ usage >&2
160
+ exit 1
161
+ fi
162
+ dispatch "${ARGS[@]}"
@@ -0,0 +1,306 @@
1
+ #!/bin/bash
2
+ # Shared helpers for scripts/sync-branch.sh and scripts/release.sh. Source this file, do not run it.
3
+
4
+ # Settings come from the environment or from a .env file (fin-release finds it and sets FIN_RELEASE_ENV).
5
+ GITHUB_REPO=""
6
+ AZDO_HOST_PATH=""
7
+ AZDO_DEV_BRANCH=""
8
+ AZDO_PROD_BRANCH=""
9
+ ENV_FILE="${FIN_RELEASE_ENV:-$PWD/.env}"
10
+
11
+ NUMBER='(0|[1-9][0-9]*)'
12
+ VERSION_RE="^${NUMBER}[.]${NUMBER}[.]${NUMBER}$"
13
+ RC_TAG_RE="^v${NUMBER}[.]${NUMBER}[.]${NUMBER}-rc[.][1-9][0-9]*$"
14
+ FINAL_TAG_RE="^v${NUMBER}[.]${NUMBER}[.]${NUMBER}$"
15
+ TICKET_RE='^[A-Z]+-[0-9]+$'
16
+ SLUG_RE='^[a-z0-9-]+$'
17
+ BRANCH_RE='^(main|(feat|fix|hotfix|integration|docs|ci|refactor|chore)/[A-Z]+-[0-9]+/[a-z0-9-]+|dependabot/[a-z0-9_-]+/[A-Za-z0-9._@/-]+|release/[0-9]+[.][0-9]+[.][0-9]+|bug/(main|release)/[A-Za-z]+-[0-9]+)$'
18
+
19
+ DRY_RUN=0
20
+ ASSUME_YES=0
21
+ SCRATCH=""
22
+
23
+ # Set by plan_rc_tag / plan_final_tag, read by apply_new_tag and the deploy flow.
24
+ TAG=""
25
+ TAG_COMMIT=""
26
+ TAG_MESSAGE=""
27
+ TAG_PENDING=0
28
+ BRANCH_MISSING=0
29
+
30
+ log() { echo "==> $*"; }
31
+ die() { echo "$*" >&2; exit 1; }
32
+
33
+ # ---- Running writes and asking for confirmation ----
34
+
35
+ mask() {
36
+ local text="$*"
37
+ text="${text//${AZDO_REPO:-@@none@@}/azure-devops}"
38
+ echo "${text//$GITHUB_REPO/github}"
39
+ }
40
+
41
+ # Runs a write command. With --dry-run it only says what the command would do.
42
+ step() {
43
+ local what="$1"
44
+ shift
45
+ if [[ $DRY_RUN -eq 1 ]]; then
46
+ echo "[dry-run] $what"
47
+ echo " \$ $(mask "$*")"
48
+ else
49
+ log "$what"
50
+ "$@"
51
+ fi
52
+ }
53
+
54
+ # Development actions: y/N prompt, answered by --yes, only described by --dry-run.
55
+ confirm() {
56
+ if [[ $DRY_RUN -eq 1 ]]; then
57
+ echo "[dry-run] would ask: $1 [y/N]"
58
+ return 0
59
+ fi
60
+ [[ $ASSUME_YES -eq 1 ]] && return 0
61
+ [[ -t 0 ]] || die "Not an interactive terminal. Re-run with --yes to confirm: $1"
62
+ read -r -p "$1 [y/N] " answer
63
+ [[ "$answer" =~ ^[Yy]$ ]] || die "Cancelled."
64
+ }
65
+
66
+ # Production actions: the tag must be typed. --yes is never accepted.
67
+ confirm_typed() {
68
+ local expected="$1" message="$2"
69
+ [[ $ASSUME_YES -eq 0 ]] || die "--yes is not accepted for production actions."
70
+ if [[ $DRY_RUN -eq 1 ]]; then
71
+ echo "[dry-run] would ask you to type '$expected': $message"
72
+ return 0
73
+ fi
74
+ [[ -t 0 ]] || die "Production actions need an interactive terminal."
75
+ echo "$message"
76
+ read -r -p "Type '$expected' to confirm: " answer
77
+ [[ "$answer" == "$expected" ]] || die "Cancelled."
78
+ }
79
+
80
+ # ---- Input checks ----
81
+
82
+ require_version() { [[ "${1:-}" =~ $VERSION_RE ]] || die "'${1:-}' is not a version like 1.4.0 (no leading zeros)."; }
83
+ require_ticket() { [[ "${1:-}" =~ $TICKET_RE ]] || die "'${1:-}' is not a ticket like CM-123."; }
84
+ require_slug() { [[ "${1:-}" =~ $SLUG_RE ]] || die "'${1:-}' is not a slug (lowercase letters, digits, dashes)."; }
85
+ require_branch_name() { [[ "$1" =~ $BRANCH_RE ]] || die "Branch name '$1' does not match the repository naming rule."; }
86
+
87
+ # ---- Tag names. Only vX.Y.Z and vX.Y.Z-rc.N are release tags; anything else is ignored. ----
88
+
89
+ # Reads ls-remote output on stdin, prints canonical release tag names.
90
+ canonical_tags() {
91
+ awk -v pattern="^refs/tags/v${NUMBER}[.]${NUMBER}[.]${NUMBER}(-rc[.]${NUMBER})?\$" \
92
+ '$2 ~ pattern {sub("^refs/tags/", "", $2); print $2}'
93
+ }
94
+
95
+ # Tag filters read tag names on stdin; highest prints the greatest by version.
96
+ highest() { LC_ALL=C sort -V | tail -n1; }
97
+ rc_tags() { grep -E -- '-rc[.]' || true; }
98
+ final_tags() { grep -vE -- '-rc[.]' || true; }
99
+ rc_tags_of() { grep -E -- "^v${1//./[.]}-rc[.][0-9]+\$" || true; }
100
+
101
+ # The release version a tag belongs to: v1.4.0-rc.2 and v1.4.0 are both 1.4.0.
102
+ tag_version() {
103
+ local version="${1#v}"
104
+ echo "${version%-rc.*}"
105
+ }
106
+
107
+ # ---- Azure DevOps access ----
108
+
109
+ env_value() {
110
+ local value="${!1:-}"
111
+ if [[ -z "$value" && -f "$ENV_FILE" ]]; then
112
+ value=$(grep -E "^$1=" "$ENV_FILE" | tail -n1 | cut -d '=' -f2- || true)
113
+ value="${value%\"}"; value="${value#\"}"
114
+ value="${value%\'}"; value="${value#\'}"
115
+ fi
116
+ echo "$value"
117
+ }
118
+
119
+ require_setting() {
120
+ local value
121
+ value=$(env_value "$1")
122
+ [[ -n "$value" ]] || die "$1 is missing. Add $1=... to $ENV_FILE (see the fin-release README)."
123
+ printf -v "$1" '%s' "$value"
124
+ }
125
+
126
+ load_github_settings() {
127
+ require_setting GITHUB_REPO
128
+ }
129
+
130
+ load_azdo_credentials() {
131
+ load_github_settings
132
+ require_setting AZDO_HOST_PATH
133
+ require_setting AZDO_USERNAME
134
+ require_setting AZDO_PASSWORD
135
+ AZDO_DEV_BRANCH=$(env_value AZDO_DEV_BRANCH); AZDO_DEV_BRANCH="${AZDO_DEV_BRANCH:-dev}"
136
+ AZDO_PROD_BRANCH=$(env_value AZDO_PROD_BRANCH); AZDO_PROD_BRANCH="${AZDO_PROD_BRANCH:-prod}"
137
+ git check-ref-format "refs/heads/$AZDO_DEV_BRANCH" || die "AZDO_DEV_BRANCH '$AZDO_DEV_BRANCH' is not a valid branch name."
138
+ git check-ref-format "refs/heads/$AZDO_PROD_BRANCH" || die "AZDO_PROD_BRANCH '$AZDO_PROD_BRANCH' is not a valid branch name."
139
+ [[ "$AZDO_DEV_BRANCH" != "$AZDO_PROD_BRANCH" ]] || die "AZDO_DEV_BRANCH and AZDO_PROD_BRANCH must be different branches."
140
+ AZDO_REPO="https://${AZDO_USERNAME}:${AZDO_PASSWORD}@${AZDO_HOST_PATH}"
141
+ }
142
+
143
+ check_vpn() {
144
+ log "Checking Azure DevOps reachability (VPN check)..."
145
+ local host="${AZDO_HOST_PATH%%/*}"
146
+ if ! curl -s --max-time 5 -o /dev/null -w "%{http_code}" "https://$host" | grep -qE "^(200|203|30[0-9])$"; then
147
+ die "Cannot reach $host. Are you connected to the VPN?"
148
+ fi
149
+ echo "Azure DevOps is reachable."
150
+ }
151
+
152
+ # ---- Remote reads. Everything works on a temporary bare repository, never on your clone. ----
153
+
154
+ init_scratch() {
155
+ SCRATCH=$(mktemp -d)
156
+ git init -q --bare "$SCRATCH/repo.git"
157
+ export GIT_DIR="$SCRATCH/repo.git"
158
+ }
159
+
160
+ cleanup_scratch() {
161
+ [[ -z "$SCRATCH" ]] && return 0
162
+ git -C "$SCRATCH/repo.git" worktree prune 2> /dev/null || true
163
+ rm -rf "$SCRATCH"
164
+ }
165
+
166
+ # Prints the sha of a ref on a remote, empty when the ref does not exist.
167
+ remote_sha() {
168
+ local refs
169
+ refs=$(git ls-remote --refs "$1" "$2") || die "Could not read '$2' from the remote. Check your credentials and the VPN."
170
+ awk -v ref="$2" '$2 == ref {print $1}' <<< "$refs"
171
+ }
172
+
173
+ github_tags() { git ls-remote --tags --refs "$GITHUB_REPO" | canonical_tags; }
174
+ azure_tags() { git ls-remote --tags --refs "$AZDO_REPO" | canonical_tags; }
175
+ github_branch_sha() { remote_sha "$GITHUB_REPO" "refs/heads/$1"; }
176
+ azure_branch() {
177
+ case "$1" in
178
+ dev) echo "$AZDO_DEV_BRANCH" ;;
179
+ prod) echo "$AZDO_PROD_BRANCH" ;;
180
+ *) echo "$1" ;;
181
+ esac
182
+ }
183
+ azure_branch_sha() { remote_sha "$AZDO_REPO" "refs/heads/$(azure_branch "$1")"; }
184
+
185
+ # Tags known anywhere, so a number is never reused. Azure is included once its credentials are loaded.
186
+ known_tags() {
187
+ { github_tags; [[ -z "${AZDO_REPO:-}" ]] || azure_tags; } | LC_ALL=C sort -u
188
+ }
189
+
190
+ fetch_ref() { git fetch -q --no-tags "$GITHUB_REPO" "+$1:$1"; }
191
+ fetch_azure_branch() { git fetch -q "$AZDO_REPO" "+refs/heads/$(azure_branch "$1"):refs/heads/azdo-$1"; }
192
+
193
+ # Fetches a GitHub tag and prints the commit it points at.
194
+ tag_commit() {
195
+ fetch_ref "refs/tags/$1" || die "Tag $1 does not exist on GitHub."
196
+ git rev-parse "refs/tags/$1^{commit}"
197
+ }
198
+
199
+ # Prints the tag named in the Deploy-Tag trailer of a commit, empty when there is none.
200
+ deployed_tag_of() {
201
+ git log -1 --format=%B "$1" | sed -n 's/^Deploy-Tag: *//p' | head -n1
202
+ }
203
+
204
+ # ---- Release branch and tag planning. Planning only reads; apply_new_tag writes. ----
205
+
206
+ # The highest RC tag known on GitHub or Azure; empty when there is none.
207
+ latest_rc() { known_tags | rc_tags | highest; }
208
+
209
+ # The highest release version known on GitHub or Azure (RC or final); empty when there is none.
210
+ latest_version() { known_tags | sed -E 's/^v//; s/-rc[.][0-9]+$//' | highest; }
211
+
212
+ # Says how a tag relates to its release/X.Y.Z branch.
213
+ describe_release_branch() {
214
+ local tag="$1" branch commit tip ahead
215
+ branch="release/$(tag_version "$tag")"
216
+ tip=$(github_branch_sha "$branch")
217
+ if [[ -z "$tip" ]]; then
218
+ echo " Branch: $branch does not exist on GitHub"
219
+ return
220
+ fi
221
+ fetch_ref "refs/heads/$branch"
222
+ commit=$(tag_commit "$tag")
223
+ if [[ "$commit" == "$tip" ]]; then
224
+ echo " Branch: $branch @ ${tip:0:7} is exactly this tag"
225
+ elif git merge-base --is-ancestor "$commit" "refs/heads/$branch"; then
226
+ ahead=$(git rev-list --count "$commit..refs/heads/$branch")
227
+ echo " Branch: $branch has $ahead commit(s) after this tag"
228
+ else
229
+ echo " Branch: WARNING this tag's commit is not on $branch"
230
+ fi
231
+ }
232
+
233
+ # Plans the next vX.Y.Z-rc.N on release/X.Y.Z, asking first when the branch has to be created from main.
234
+ # An optional second argument is the tag the user asked for; it must be exactly the next RC.
235
+ plan_rc_tag() {
236
+ local version="$1" wanted="${2:-}" known last last_commit
237
+ require_version "$version"
238
+ require_branch_name "release/$version"
239
+ known=$(known_tags)
240
+ if printf '%s\n' "$known" | grep -qx -- "v$version"; then
241
+ die "v$version is already released; start a new version."
242
+ fi
243
+ last=$(printf '%s\n' "$known" | rc_tags_of "$version" | highest)
244
+ TAG="v$version-rc.1"
245
+ [[ -z "$last" ]] || TAG="v$version-rc.$((${last##*.} + 1))"
246
+ if [[ -n "$wanted" && "$wanted" != "$TAG" ]]; then
247
+ die "$wanted is not the next RC of $version. The latest is ${last:-none}, so the next one is $TAG."
248
+ fi
249
+ BRANCH_MISSING=0
250
+ if [[ -n "$(github_branch_sha "release/$version")" ]]; then
251
+ fetch_ref "refs/heads/release/$version"
252
+ TAG_COMMIT=$(git rev-parse "refs/heads/release/$version")
253
+ else
254
+ fetch_ref refs/heads/main
255
+ TAG_COMMIT=$(git rev-parse refs/heads/main)
256
+ fi
257
+ if [[ -n "$last" ]]; then
258
+ last_commit=$(tag_commit "$last")
259
+ if [[ "$last_commit" == "$TAG_COMMIT" ]]; then
260
+ die "release/$version @ ${TAG_COMMIT:0:7} is already tagged $last. Merge a fix before cutting a new RC."
261
+ fi
262
+ fi
263
+ if [[ -z "$(github_branch_sha "release/$version")" ]]; then
264
+ confirm "release/$version does not exist. Create it from main @ ${TAG_COMMIT:0:7}?"
265
+ BRANCH_MISSING=1
266
+ fi
267
+ TAG_MESSAGE="Release candidate $TAG"
268
+ TAG_PENDING=1
269
+ }
270
+
271
+ # Plans the final vX.Y.Z on the exact commit of the latest RC. The branch must exist and be at that RC.
272
+ plan_final_tag() {
273
+ local version="$1" known rc rc_commit
274
+ require_version "$version"
275
+ known=$(known_tags)
276
+ if printf '%s\n' "$known" | grep -qx -- "v$version"; then
277
+ die "v$version already exists."
278
+ fi
279
+ rc=$(printf '%s\n' "$known" | rc_tags_of "$version" | highest)
280
+ [[ -n "$rc" ]] || die "No RC exists for $version. Create one first: sync-branch.sh deploy-dev --new-rc $version"
281
+ [[ -n "$(github_branch_sha "release/$version")" ]] || die "release/$version does not exist."
282
+ fetch_ref "refs/heads/release/$version"
283
+ TAG_COMMIT=$(git rev-parse "refs/heads/release/$version")
284
+ rc_commit=$(tag_commit "$rc")
285
+ if [[ "$rc_commit" != "$TAG_COMMIT" ]]; then
286
+ die "release/$version has commits after $rc. Cut a new RC and test it first."
287
+ fi
288
+ [[ -z "${AZDO_REPO:-}" ]] || azure_tags | grep -qx -- "$rc" ||
289
+ echo "Note: $rc was never deployed to Azure (its tag is not there), so it may not have been tested on Dev."
290
+ BRANCH_MISSING=0
291
+ TAG="v$version"
292
+ TAG_MESSAGE="Release $TAG"
293
+ TAG_PENDING=1
294
+ }
295
+
296
+ # Creates the missing release branch and the planned tag on GitHub. Call it after confirming.
297
+ apply_new_tag() {
298
+ local gpg=()
299
+ if [[ $BRANCH_MISSING -eq 1 ]]; then
300
+ step "Create GitHub branch release/$(tag_version "$TAG") from main" \
301
+ git push "$GITHUB_REPO" "$TAG_COMMIT:refs/heads/release/$(tag_version "$TAG")"
302
+ fi
303
+ [[ $DRY_RUN -eq 0 ]] || gpg=(-c tag.gpgsign=false)
304
+ git ${gpg[@]+"${gpg[@]}"} tag -a "$TAG" "$TAG_COMMIT" -m "$TAG_MESSAGE"
305
+ step "Push tag $TAG to GitHub" git push "$GITHUB_REPO" "refs/tags/$TAG"
306
+ }