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.
- finaira_release-0.1.0/PKG-INFO +55 -0
- finaira_release-0.1.0/README.md +48 -0
- finaira_release-0.1.0/pyproject.toml +20 -0
- finaira_release-0.1.0/setup.cfg +4 -0
- finaira_release-0.1.0/src/fin_release/__init__.py +1 -0
- finaira_release-0.1.0/src/fin_release/cli.py +74 -0
- finaira_release-0.1.0/src/fin_release/scripts/release.sh +162 -0
- finaira_release-0.1.0/src/fin_release/scripts/release_common.sh +306 -0
- finaira_release-0.1.0/src/fin_release/scripts/sync-branch.sh +442 -0
- finaira_release-0.1.0/src/finaira_release.egg-info/PKG-INFO +55 -0
- finaira_release-0.1.0/src/finaira_release.egg-info/SOURCES.txt +13 -0
- finaira_release-0.1.0/src/finaira_release.egg-info/dependency_links.txt +1 -0
- finaira_release-0.1.0/src/finaira_release.egg-info/entry_points.txt +2 -0
- finaira_release-0.1.0/src/finaira_release.egg-info/top_level.txt +1 -0
- finaira_release-0.1.0/tests/test_cli.py +171 -0
|
@@ -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 @@
|
|
|
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
|
+
}
|