Skip to content

Commit b021f1b

Browse files
committed
Split release commits before tagging packages
Release through the shared split script so package tags cannot point at stale repository contents. Freeze the source commit, record each split result, and publish the framework tag only after all component tags succeed. Fetch full history for release splitting, push only the requested tag, and preserve explicit forced tag replacement. Add local Git regression tests and a publishing test workflow covering stale splits, concurrent pushes, failure paths, and repeat runs. Validation: 10 regression tests passed; shell syntax, workflow YAML, and diff checks passed.
1 parent 14a8493 commit b021f1b

5 files changed

Lines changed: 326 additions & 72 deletions

File tree

‎.github/scripts/release.test.py‎

Lines changed: 214 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,214 @@
1+
import os
2+
from pathlib import Path
3+
import re
4+
import shlex
5+
import subprocess
6+
import tempfile
7+
import unittest
8+
9+
10+
REPOSITORY = Path(__file__).resolve().parents[2]
11+
12+
13+
class ReleaseTest(unittest.TestCase):
14+
def setUp(self):
15+
temporary = tempfile.TemporaryDirectory(prefix="hypervel-release-test-")
16+
self.addCleanup(temporary.cleanup)
17+
self.directory = Path(temporary.name)
18+
self.source = self.directory / "source"
19+
self.source.mkdir()
20+
self.branch = re.search(
21+
r'^RELEASE_BRANCH="([^"]+)"',
22+
(REPOSITORY / "bin/release.sh").read_text(),
23+
re.MULTILINE,
24+
).group(1)
25+
self.version = f"v{self.branch}.0-test"
26+
configuration = self.directory / "gitconfig"
27+
configuration.write_text(
28+
'[user]\n name = Release Test\n email = release@example.test\n'
29+
f'[url "{self.directory.as_uri()}/"]\n'
30+
' insteadOf = git@github.com:hypervel/\n'
31+
)
32+
self.environment = {
33+
**os.environ,
34+
"GIT_CONFIG_GLOBAL": str(configuration),
35+
"GIT_CONFIG_NOSYSTEM": "1",
36+
"GIT_ALLOW_PROTOCOL": "file",
37+
"GIT_TERMINAL_PROMPT": "0",
38+
}
39+
self.run_command("git", "init", "-b", self.branch)
40+
(self.source / "bin").mkdir()
41+
for name in ("split.sh", "release.sh", "splitsh-lite"):
42+
self.run_command("cp", str(REPOSITORY / "bin" / name), str(self.source / "bin" / name))
43+
for package in ("alpha", "beta"):
44+
(self.source / "src" / package).mkdir(parents=True)
45+
self.run_command("git", "init", "--bare", str(self.remote(package)))
46+
self.run_command("git", "init", "--bare", str(self.remote("components")))
47+
self.run_command("git", "remote", "add", "origin", str(self.remote("components")))
48+
self.commit("initial")
49+
50+
def run_command(self, *arguments, check=True):
51+
result = subprocess.run(
52+
arguments, cwd=self.source, env=self.environment,
53+
text=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, timeout=30,
54+
)
55+
if check and result.returncode:
56+
self.fail(f"{arguments!r} failed:\n{result.stdout}")
57+
return result
58+
59+
def remote(self, package):
60+
return self.directory / f"{package}.git"
61+
62+
def remote_git(self, package, *arguments, check=True):
63+
return self.run_command("git", f"--git-dir={self.remote(package)}", *arguments, check=check)
64+
65+
def commit(self, contents, publish=True):
66+
for package in ("alpha", "beta"):
67+
(self.source / "src" / package / "contents").write_text(contents)
68+
self.run_command("git", "add", ".")
69+
self.run_command("git", "commit", "-m", contents)
70+
if publish:
71+
self.run_command("git", "push", "origin", self.branch)
72+
return self.run_command("git", "rev-parse", "HEAD").stdout.strip()
73+
74+
def release(self, *options, check=True):
75+
return self.run_command("bash", "bin/release.sh", self.version, *options, check=check)
76+
77+
def assert_release(self, commit, contents):
78+
self.assertEqual(
79+
self.remote_git("components", "rev-parse", f"refs/tags/{self.version}").stdout.strip(),
80+
commit,
81+
)
82+
for package in ("alpha", "beta"):
83+
self.assertEqual(
84+
self.remote_git(package, "show", f"refs/tags/{self.version}:contents").stdout,
85+
contents,
86+
)
87+
88+
def assert_no_tag(self, package):
89+
self.assertNotEqual(
90+
self.remote_git(package, "rev-parse", "--verify", f"refs/tags/{self.version}", check=False).returncode,
91+
0,
92+
)
93+
94+
def hook(self, package, name, script):
95+
path = self.remote(package) / "hooks" / name
96+
path.write_text("#!/bin/sh\nset -e\n" + script)
97+
path.chmod(0o755)
98+
99+
def test_release_refreshes_stale_splits_and_repeated_splits_are_unchanged(self):
100+
self.run_command("bash", "bin/split.sh")
101+
commit = self.commit("release")
102+
self.run_command("git", "tag", "unrelated-local-tag")
103+
self.release()
104+
self.assert_release(commit, "release")
105+
self.assertNotEqual(
106+
self.remote_git("components", "rev-parse", "--verify", "refs/tags/unrelated-local-tag", check=False).returncode,
107+
0,
108+
)
109+
self.run_command("bash", "bin/split.sh")
110+
for package in ("alpha", "beta"):
111+
self.assertEqual(
112+
self.remote_git(package, "rev-parse", f"refs/heads/{self.branch}").stdout,
113+
self.remote_git(package, "rev-parse", f"refs/tags/{self.version}").stdout,
114+
)
115+
116+
def test_source_push_during_splitting_does_not_change_the_release(self):
117+
commit = self.commit("release")
118+
later = self.commit("later")
119+
self.run_command("git", "reset", "--hard", commit)
120+
self.remote_git("components", "update-ref", f"refs/heads/{self.branch}", commit)
121+
self.hook("alpha", "post-receive", (
122+
f"git --git-dir={shlex.quote(str(self.remote('components')))} "
123+
f"update-ref refs/heads/{self.branch} {later}\n"
124+
))
125+
self.release()
126+
self.assert_release(commit, "release")
127+
self.assertEqual(
128+
self.remote_git("components", "rev-parse", f"refs/heads/{self.branch}").stdout.strip(), later,
129+
)
130+
131+
def test_source_push_before_fetch_does_not_change_the_release(self):
132+
commit = self.commit("release")
133+
self.commit("later")
134+
self.run_command("git", "reset", "--hard", commit)
135+
self.release()
136+
self.assert_release(commit, "release")
137+
138+
def test_tags_use_recorded_splits_when_a_destination_branch_moves(self):
139+
self.run_command("bash", "bin/split.sh")
140+
previous = self.remote_git("alpha", "rev-parse", f"refs/heads/{self.branch}").stdout.strip()
141+
commit = self.commit("release")
142+
self.hook("beta", "post-receive", (
143+
"while read previous current reference; do\n"
144+
f' if [ "$reference" = "refs/heads/{self.branch}" ]; then\n'
145+
f" git --git-dir={shlex.quote(str(self.remote('alpha')))} "
146+
f"update-ref refs/heads/{self.branch} {previous}\n"
147+
" fi\ndone\n"
148+
))
149+
self.release()
150+
self.assert_release(commit, "release")
151+
self.assertEqual(
152+
self.remote_git("alpha", "rev-parse", f"refs/heads/{self.branch}").stdout.strip(), previous,
153+
)
154+
155+
def test_failed_split_does_not_publish_any_release_tags(self):
156+
self.hook("beta", "pre-receive", "echo 'Rejecting beta split' >&2\nexit 1\n")
157+
result = self.release(check=False)
158+
self.assertNotEqual(result.returncode, 0)
159+
self.assertIn("Rejecting beta split", result.stdout)
160+
for package in ("components", "alpha", "beta"):
161+
self.assert_no_tag(package)
162+
self.assertEqual(self.run_command("git", "for-each-ref", "refs/hypervel-split/").stdout, "")
163+
164+
def test_failed_component_tag_does_not_publish_the_framework_tag(self):
165+
self.hook("beta", "pre-receive", (
166+
"while read previous current reference; do\n"
167+
' case "$reference" in refs/tags/*) echo "Rejecting beta tag" >&2; exit 1;; esac\n'
168+
"done\n"
169+
))
170+
result = self.release(check=False)
171+
self.assertNotEqual(result.returncode, 0)
172+
self.assertIn("Rejecting beta tag", result.stdout)
173+
self.assert_no_tag("components")
174+
self.assert_no_tag("beta")
175+
176+
def test_existing_tags_require_explicit_force_to_move(self):
177+
original = self.run_command("git", "rev-parse", "HEAD").stdout.strip()
178+
self.release()
179+
replacement = self.commit("replacement")
180+
self.assertNotEqual(self.release(check=False).returncode, 0)
181+
self.assert_release(original, "initial")
182+
self.release("-f")
183+
self.assert_release(replacement, "replacement")
184+
185+
def test_selected_package_split_and_explicit_source_commit(self):
186+
original = self.run_command("git", "rev-parse", "HEAD").stdout.strip()
187+
self.commit("later")
188+
self.run_command("bash", "bin/split.sh", "alpha", "--ref", original)
189+
self.assertEqual(
190+
self.remote_git("alpha", "show", f"refs/heads/{self.branch}:contents").stdout, "initial",
191+
)
192+
self.assertNotEqual(
193+
self.remote_git("beta", "rev-parse", "--verify", f"refs/heads/{self.branch}", check=False).returncode, 0,
194+
)
195+
196+
def test_unpublished_commit_is_rejected_before_splitting(self):
197+
self.commit("unpublished", publish=False)
198+
self.assertNotEqual(self.release(check=False).returncode, 0)
199+
self.assert_no_tag("components")
200+
for package in ("alpha", "beta"):
201+
self.assertEqual(self.remote_git(package, "for-each-ref").stdout, "")
202+
203+
def test_removed_package_cannot_publish_its_old_history(self):
204+
self.run_command("bash", "bin/split.sh")
205+
previous = self.remote_git("alpha", "rev-parse", f"refs/heads/{self.branch}").stdout
206+
self.run_command("git", "rm", "-r", "src/alpha")
207+
self.run_command("git", "commit", "-m", "Remove alpha")
208+
self.assertNotEqual(self.run_command("bash", "bin/split.sh", "alpha", check=False).returncode, 0)
209+
self.assertEqual(self.remote_git("alpha", "rev-parse", f"refs/heads/{self.branch}").stdout, previous)
210+
self.assertEqual(self.run_command("git", "for-each-ref", "refs/hypervel-split/").stdout, "")
211+
212+
213+
if __name__ == "__main__":
214+
unittest.main()
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
name: publishing tests
2+
3+
on:
4+
push:
5+
paths:
6+
- 'bin/split.sh'
7+
- 'bin/release.sh'
8+
- 'bin/splitsh-lite'
9+
- '.github/scripts/release.test.py'
10+
- '.github/workflows/publishing-tests.yml'
11+
- '.github/workflows/release.yml'
12+
- '.github/workflows/split.yml'
13+
pull_request:
14+
paths:
15+
- 'bin/split.sh'
16+
- 'bin/release.sh'
17+
- 'bin/splitsh-lite'
18+
- '.github/scripts/release.test.py'
19+
- '.github/workflows/publishing-tests.yml'
20+
- '.github/workflows/release.yml'
21+
- '.github/workflows/split.yml'
22+
23+
permissions:
24+
contents: read
25+
26+
jobs:
27+
publishing:
28+
runs-on: ubuntu-latest
29+
timeout-minutes: 5
30+
steps:
31+
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
32+
- name: Check shell syntax
33+
run: |
34+
bash -n bin/split.sh
35+
bash -n bin/release.sh
36+
- name: Test publishing with local repositories
37+
run: python3 .github/scripts/release.test.py

‎.github/workflows/release.yml‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ jobs:
2727
- name: Checkout repository
2828
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
2929
with:
30+
fetch-depth: 0
3031
# Version commits and framework tag pushes authenticate through origin.
3132
persist-credentials: true
3233

@@ -90,10 +91,12 @@ jobs:
9091
git config --global user.email "albert@hypervel.org"
9192
git config --global user.name "Hypervel"
9293
if [ ${{ inputs.force_delete }} = true ]; then
93-
bash ./bin/release.sh v${{ steps.version.outputs.version }} -f
94+
bash ./bin/release.sh "v$VERSION" -f
9495
else
95-
bash ./bin/release.sh v${{ steps.version.outputs.version }}
96+
bash ./bin/release.sh "v$VERSION"
9697
fi
98+
env:
99+
VERSION: ${{ steps.version.outputs.version }}
97100

98101
- name: Create release notes
99102
uses: softprops/action-gh-release@efb35369e0ad2afab669f228072c1b0d510eae64 # v3.0.3

‎bin/release.sh‎

Lines changed: 15 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -20,13 +20,15 @@ fi
2020
NOW=$(date +%s)
2121
RELEASE_BRANCH="0.4"
2222
CURRENT_BRANCH=$(git rev-parse --abbrev-ref HEAD)
23-
BASEPATH=$(cd `dirname $0`; cd ../src/; pwd)
23+
RELEASE_COMMIT=$(git rev-parse HEAD)
2424
VERSION=$1
25-
FORCE_DELETE=false
25+
SPLIT_OPTIONS=()
26+
TAG_OPTIONS=()
2627

2728
# Check if -f flag is provided as second argument
28-
if [[ "$2" == "-f" ]]; then
29-
FORCE_DELETE=true
29+
if [[ "${2:-}" == "-f" ]]; then
30+
SPLIT_OPTIONS=(--force-tag)
31+
TAG_OPTIONS=(--force)
3032
fi
3133

3234
# Make sure current branch and release branch match.
@@ -48,10 +50,9 @@ fi
4850
# Make sure latest changes are fetched first.
4951
git fetch origin
5052

51-
# Make sure that release branch is in sync with origin.
52-
if [[ $(git rev-parse HEAD) != $(git rev-parse origin/$RELEASE_BRANCH) ]]
53-
then
54-
echo "Your branch is out of date with its upstream. Did you forget to pull or push any changes before releasing?"
53+
# The release commit must be published; later commits do not change this release.
54+
if ! git merge-base --is-ancestor "$RELEASE_COMMIT" "origin/$RELEASE_BRANCH"; then
55+
echo "The release commit is not on origin/$RELEASE_BRANCH. Did you forget to push before releasing?"
5556

5657
exit 1
5758
fi
@@ -62,48 +63,12 @@ then
6263
VERSION="v$VERSION"
6364
fi
6465

65-
REMOTES=$(ls $BASEPATH)
66-
67-
# Delete the old release tag only if -f flag is provided
68-
if [ $FORCE_DELETE = true ]; then
69-
echo "Forcing delete of existing tags..."
70-
git push --delete origin $VERSION 2>/dev/null || true
71-
git tag --delete $VERSION 2>/dev/null || true
72-
fi
73-
74-
# Tag Framework
75-
git tag $VERSION
76-
git push origin --tags
77-
78-
# Tag Components
79-
for REMOTE in $REMOTES
80-
do
81-
echo ""
82-
echo ""
83-
echo "Cloning $REMOTE";
84-
85-
TMP_DIR="/tmp/hypervel-split"
86-
REMOTE_URL="git@github.com:hypervel/${REMOTE}.git"
87-
88-
rm -rf $TMP_DIR;
89-
mkdir $TMP_DIR;
90-
91-
(
92-
cd $TMP_DIR;
93-
94-
git clone $REMOTE_URL .
95-
git checkout "$RELEASE_BRANCH";
66+
# Refresh every component and tag its exact split before publishing the framework tag.
67+
bash ./bin/split.sh --ref "$RELEASE_COMMIT" --tag "$VERSION" "${SPLIT_OPTIONS[@]}"
9668

97-
echo "Releasing $REMOTE";
98-
if [ "$FORCE_DELETE" = true ]; then
99-
git push --delete origin $VERSION 2>/dev/null || true
100-
git tag --delete $VERSION 2>/dev/null || true
101-
fi
102-
git tag $VERSION
103-
git push origin --tags
104-
)
105-
done
69+
git tag "${TAG_OPTIONS[@]}" "$VERSION" "$RELEASE_COMMIT"
70+
git push "${TAG_OPTIONS[@]}" origin "refs/tags/$VERSION"
10671

107-
TIME=$(echo "$(date +%s) - $NOW" | bc)
72+
TIME=$(( $(date +%s) - NOW ))
10873

109-
printf "Execution time: %f seconds" $TIME
74+
printf 'Execution time: %s seconds\n' "$TIME"

0 commit comments

Comments
 (0)