diff --git a/docs/dev/github-release-process.md b/docs/dev/github-release-process.md index 8c54a6d4a..9aa9f9ce1 100644 --- a/docs/dev/github-release-process.md +++ b/docs/dev/github-release-process.md @@ -141,6 +141,53 @@ release rules as the generated GitLab release jobs: - `chore:`, `ci:`, `docs:`, `style:`, `refactor:`, `test:`, and `build:` do not create releases +## Java framework dependency releases + +`semantic-release-monorepo` scopes a service's commits to its own +subtree path. A change to the shared Java framework under +`src/libraries/java/` lands outside every service directory, so +semantic-release sees no commits for any dependent service and releases +nothing. CI still rebuilds and tests each dependent service, but the +rebuilt artifact never leaves the CI job. + +`tools/ci/github-release auto` closes that gap. It reads the +per-component `bazel-java-ci.json` descriptors, the same files +`.github/workflows/bazel.yml` reads to schedule its matrix, so the +framework-to-service edge is declared once: + +- `component_kind: java-framework` marks a shared framework path. +- `component_kind: java-service` marks a component that is rebuilt when + any framework path changes. + +For a registered subproject whose path matches a `java-service` +descriptor, the script cuts a dependency-triggered release when all of +the following hold: + +- semantic-release computed no version for that service on this run. If + the same push also touched the service, semantic-release owns the + version and nothing extra is tagged. +- The service already has a release tag to bump from. +- At least one release-worthy commit touching a framework path landed + since that tag. Release-worthiness uses the same rules listed above: a + framework `feat:`, `fix:`, `perf:`, or breaking `!` commit fans out; a + framework `docs:`, `chore:`, `ci:`, `style:`, `refactor:`, `test:`, or + `build:` commit releases nothing for the framework and so releases + nothing for its dependents either. + +The synthesized bump is always a patch, including when the framework +commit is a `feat:`. A framework feature adds no capability to a service +that has not adopted it, and the service's own changelog has nothing to +substantiate a minor. A service that does adopt a new framework API does +so in a commit under its own directory, which semantic-release turns +into the correct bump; the fan-out does not run in that case. + +The release notes state that the release is dependency-triggered and +list the framework commits, so a reader of a GitHub Release with no +changes in the service directory can see why the version moved. + +Dry-run mode prints the tag and notes it would create and creates +nothing, the same as every other release path in this script. + ## Release notes for pushed tags On tag pushes, the workflow validates the tag and creates lightweight diff --git a/tools/ci/github-release b/tools/ci/github-release index 887595954..7031e1d3a 100755 --- a/tools/ci/github-release +++ b/tools/ci/github-release @@ -29,6 +29,21 @@ VERSION_PLACEHOLDER = "${version}" INITIAL_RELEASE_VERSION = "0.1.0" INITIAL_RELEASE_FLOOR_VERSION = "0.0.0" +# Java component descriptors. These are the same per-component files +# `.github/workflows/bazel.yml` discovers to build its matrix and to apply the +# framework -> service reverse-dependency edge. The dependency graph is declared +# there and only there; this script reads it rather than restating it, so the +# two cannot drift. +JAVA_CI_DESCRIPTOR = "bazel-java-ci.json" +JAVA_FRAMEWORK_KIND = "java-framework" +JAVA_SERVICE_KIND = "java-service" +JAVA_COMPONENT_KINDS = (JAVA_FRAMEWORK_KIND, JAVA_SERVICE_KIND) +# Framework commits quoted in dependency-triggered release notes. A service that +# is many framework commits behind should still get readable notes. +MAX_QUOTED_FRAMEWORK_COMMITS = 20 +# Conventional Commit subject prefix, for example "fix(nv-boot)!: subject". +COMMIT_SUBJECT_PATTERN = re.compile(r"^(?P[a-zA-Z]+)(?:\([^)]*\))?(?P!)?:") + def bool_env(name, default=False): raw = os.environ.get(name) @@ -264,10 +279,10 @@ def latest_service_tag_for_prefix(prefix): return sorted(candidates, key=lambda item: semverish_sort_key(item[0]))[-1][1] -def latest_service_tag(service): +def latest_service_tag(service, root=None): candidates = [] for prefix in tag_prefixes(service): - raw = run(["git", "tag", "-l", f"{prefix}*"], capture=True) + raw = run(["git", "tag", "-l", f"{prefix}*"], cwd=root, capture=True) for line in raw.splitlines(): tag = line.strip() if tag: @@ -471,6 +486,207 @@ def publish_tag_for_version(root, service, version, dry_run, draft, reason): create_release(tag, tag, notes, draft, dry_run=False) +def java_ci_components(root): + """Java components declared by the per-component bazel-java-ci.json files. + + This is the single declaration of the Java dependency graph in the repo: + `.github/workflows/bazel.yml` reads the same descriptors to schedule every + `java-service` row when a `java-framework` path changes. Reading them here + keeps release fan-out and CI scheduling on one source of truth instead of a + second hardcoded list that can silently drift. + """ + components = [] + src = root / "src" + if not src.is_dir(): + return components + for manifest in sorted(src.rglob(JAVA_CI_DESCRIPTOR)): + try: + descriptor = json.loads(manifest.read_text()) + except json.JSONDecodeError as exc: + raise SystemExit(f"{manifest}: invalid {JAVA_CI_DESCRIPTOR}: {exc}") from exc + kind = descriptor.get("component_kind") + if kind not in JAVA_COMPONENT_KINDS: + raise SystemExit( + f"{manifest}: component_kind must be one of {', '.join(JAVA_COMPONENT_KINDS)}, got {kind!r}" + ) + components.append( + { + "id": descriptor.get("id", ""), + "path": manifest.parent.relative_to(root).as_posix(), + "component_kind": kind, + } + ) + return components + + +def java_framework_paths(components): + return [c["path"] for c in components if c["component_kind"] == JAVA_FRAMEWORK_KIND] + + +def is_java_service(components, service): + path = str(service.get("path") or "").strip("/") + return any(c["component_kind"] == JAVA_SERVICE_KIND and c["path"] == path for c in components) + + +def next_patch_version(version): + major, minor, patch = (int(part) for part in version.split(".")) + return f"{major}.{minor}.{patch + 1}" + + +def releases_a_version(subject): + """Whether a commit subject releases a version under RELEASE_RULES. + + Derived from RELEASE_RULES so the fan-out applies exactly the rules + semantic-release is configured with: `feat`, `fix`, and `perf` release, the + other declared types do not, and a `!` breaking marker always releases. A + subject that is not a Conventional Commit releases nothing, the same as it + would inside a service directory. + """ + match = COMMIT_SUBJECT_PATTERN.match(subject) + if not match: + return False + if match.group("breaking"): + return True + releasing = {rule["type"] for rule in RELEASE_RULES if rule["release"]} + return match.group("type").lower() in releasing + + +def release_worthy_framework_commits_since(root, framework_paths, since_tag): + """Framework commits since a tag that would have released, had they been in a service. + + A framework `docs:` or `chore:` commit releases nothing for the framework, + so it must not release anything for the framework's dependents either. + """ + raw = run( + ["git", "log", "--format=%h %s", f"{since_tag}..HEAD", "--", *framework_paths], + cwd=root, + capture=True, + ) + commits = [] + for line in raw.splitlines(): + commit = line.strip() + if not commit: + continue + _sha, _, subject = commit.partition(" ") + if releases_a_version(subject): + commits.append(commit) + return commits + + +def framework_dependency_reason(service, framework_paths, since_tag, commits): + quoted = commits[:MAX_QUOTED_FRAMEWORK_COMMITS] + lines = [ + "dependency-triggered release. This service has no release-worthy commits of its own " + f"under {service['path']} since {since_tag}.", + "", + "It is released because shared Java framework code it is built from changed:", + ] + lines.extend(f" - {path}" for path in framework_paths) + lines.append("") + lines.append(f"Framework commits since {since_tag}:") + lines.extend(f" - {commit}" for commit in quoted) + if len(commits) > len(quoted): + lines.append(f" - ... and {len(commits) - len(quoted)} more") + lines.append("") + lines.append( + "The service source is unchanged; this release ships it rebuilt against the " + "updated framework." + ) + return "\n".join(lines) + + +def publish_framework_dependency_release(root, service, components, dry_run, draft): + """Cut a patch release for a Java service that a framework change rebuilt. + + Fires only when the service would otherwise cut nothing, and only when the + framework actually changed since that service's last release tag. Always a + patch bump: the framework change adds no user-visible capability to this + service, and the service's own changelog has nothing to substantiate a + minor. A service that genuinely adopts a new framework API does so in a + commit under its own directory, which semantic-release turns into the right + bump, and this fan-out then does not run at all. + """ + if not is_java_service(components, service): + return False + framework_paths = java_framework_paths(components) + if not framework_paths: + return False + + last_tag = latest_service_tag(service, root) + if not last_tag: + print( + f"[github-release] {service['id']}: no existing release tag to bump from; " + "skipping Java framework dependency release" + ) + return False + + current_version = version_from_tag(service, last_tag) + if not re.fullmatch(STABLE_SEMVER_PATTERN, current_version): + print( + f"[github-release] {service['id']}: last tag {last_tag} is not a stable X.Y.Z; " + "skipping Java framework dependency release" + ) + return False + + commits = release_worthy_framework_commits_since(root, framework_paths, last_tag) + if not commits: + print( + f"[github-release] {service['id']}: no release-worthy Java framework commits since " + f"{last_tag}; nothing to release" + ) + return False + + version = next_patch_version(current_version) + print( + f"[github-release] {service['id']}: {len(commits)} release-worthy Java framework " + f"commit(s) since {last_tag} with no service release; cutting dependency patch " + f"release {version}" + ) + publish_tag_for_version( + root, + service, + version, + dry_run, + draft, + framework_dependency_reason(service, framework_paths, last_tag, commits), + ) + return True + + +def resolve_release_outcome(exit_code, output): + """Classify a semantic-release run: it released, it released nothing, or it is unreadable.""" + if exit_code != 0: + # A run that died is untrustworthy even if it printed a version before + # dying: the publish run may not reproduce it. Report it rather than + # previewing a tag that may never be created. + return "unknown" + if parse_next_version(output): + return "released" + if no_release_output(output): + return "no-release" + return "unknown" + + +def finish_semantic_release(root, service, components, exit_code, output, dry_run, draft): + """Apply the semantic-release result, falling back to dependency fan-out. + + semantic-release owns the version whenever it computed one, so a push that + touched both a framework and the service is never double-tagged. + """ + outcome = resolve_release_outcome(exit_code, output) + if outcome == "released": + version = parse_next_version(output) + if dry_run: + print(f"[github-release] {service['id']}: would create {tag_for_version(service, version)}") + else: + print(f"[github-release] {service['id']}: semantic-release created {tag_for_version(service, version)}") + return outcome + if outcome == "no-release": + print(f"[github-release] {service['id']}: no release-worthy commits") + publish_framework_dependency_release(root, service, components, dry_run=dry_run, draft=draft) + return outcome + + def publish_version_file_release(root, service, dry_run, draft): version = validate_version_file(root, service) existing = existing_tag_for_version(root, service, version) @@ -665,6 +881,7 @@ def auto_release(args): draft = bool_env("NVCF_GITHUB_RELEASE_DRAFT", False) service_filter = args.service or os.environ.get("NVCF_GITHUB_RELEASE_SERVICE", "") generated_paths = metadata.get("release_generated_paths", []) + java_components = java_ci_components(root) branch = current_branch(root) default_branch = os.environ.get("GITHUB_DEFAULT_BRANCH", "main") @@ -699,6 +916,13 @@ def auto_release(args): continue if only_generated_changes(root, service, generated_paths): + # A shared Java framework change lands outside every service + # directory, so semantic-release (scoped to the service path by + # semantic-release-monorepo) would see nothing and ship nothing. + # Fan the framework change out here instead. + publish_framework_dependency_release( + root, service, java_components, dry_run=dry_run, draft=draft + ) continue synthesize_current_prefix_anchor(service) synthesize_initial_version_anchor(root, service) @@ -714,21 +938,33 @@ def auto_release(args): if dry_run: code, output = stream(["npx", "semantic-release", "--dry-run", "--no-ci", "--debug"], cwd=service_dir) - next_version = parse_next_version(output) - if next_version: - print(f"[github-release] {service['id']}: would create {tag_for_version(service, next_version)}") - elif code == 0 and no_release_output(output): - print(f"[github-release] {service['id']}: no release-worthy commits") - else: + outcome = finish_semantic_release( + root, service, java_components, code, output, dry_run=True, draft=draft + ) + if outcome == "unknown": # Preview could not compute a version (for example the # semantic-release child was killed on a large history). # Do not fail the inert workflow on a preview; record it so # it is visible and gets resolved before the cutover. - reason = f"semantic-release dry-run exited {code} with no NEXT_VERSION" + reason = f"semantic-release dry-run exited {code} without a trustworthy release decision" print(f"[github-release] WARNING: {service['id']}: {reason}; continuing (dry-run preview)") dry_run_failures.append((service["id"], reason)) else: - run(["npx", "semantic-release", "--no-ci"], cwd=service_dir) + # Streamed rather than run() so the result is readable: a + # dependency fan-out must only fire when semantic-release itself + # released nothing. + cmd = ["npx", "semantic-release", "--no-ci"] + code, output = stream(cmd, cwd=service_dir) + if code != 0: + raise subprocess.CalledProcessError(code, cmd) + outcome = finish_semantic_release( + root, service, java_components, code, output, dry_run=False, draft=draft + ) + if outcome == "unknown": + print( + f"[github-release] WARNING: {service['id']}: semantic-release reported " + "neither a version nor a no-release result; not cutting a dependency release" + ) finally: print("::endgroup::") diff --git a/tools/ci/test-github-release.py b/tools/ci/test-github-release.py index 93263e660..e24cd89c0 100644 --- a/tools/ci/test-github-release.py +++ b/tools/ci/test-github-release.py @@ -66,6 +66,352 @@ def commit_all(self, root, message): git(root, "add", ".") git(root, "commit", "-m", message) + def write_java_component(self, root, path, component_id, component_kind): + component_dir = root / path + component_dir.mkdir(parents=True, exist_ok=True) + (component_dir / "bazel-java-ci.json").write_text( + json.dumps( + { + "ci_lane": "build-container", + "component_kind": component_kind, + "id": component_id, + "tests_skip": False, + }, + indent=2, + ) + + "\n" + ) + (component_dir / "README.md").write_text(f"{component_id}\n") + + JAVA_FRAMEWORK_PATH = "src/libraries/java/nv-boot-parent" + JAVA_SERVICES = ( + ("cloud-tasks", "src/control-plane-services/cloud-tasks", "1.6.2"), + ("notary", "src/control-plane-services/notary", "1.8.4"), + ) + + def java_service_metadata(self, service_id, path): + return { + "id": service_id, + "path": path, + "service_name": f"nvcf-{service_id}", + } + + def init_java_repo(self, root): + """Repo with one Java framework, two Java services, and a release tag per service.""" + self.init_repo(root) + self.write_java_component(root, self.JAVA_FRAMEWORK_PATH, "nv-boot-parent", "java-framework") + for service_id, path, _version in self.JAVA_SERVICES: + self.write_java_component(root, path, service_id, "java-service") + self.commit_all(root, "seed java components") + for _service_id, path, version in self.JAVA_SERVICES: + git(root, "tag", f"{path}/v{version}") + + def commit_framework_change(self, root, message="fix(nv-boot): bump shared framework"): + (root / self.JAVA_FRAMEWORK_PATH / "README.md").write_text(f"{message}\n") + self.commit_all(root, message) + + def fanout_dry_run(self, root, service): + components = self.github_release.java_ci_components(root) + output = io.StringIO() + with chdir(root), contextlib.redirect_stdout(output): + created = self.github_release.publish_framework_dependency_release( + root, service, components, dry_run=True, draft=False + ) + return created, output.getvalue() + + def test_java_ci_components_match_registered_subprojects(self): + root = SCRIPT_PATH.parents[2] + components = self.github_release.java_ci_components(root) + kinds = {component["path"]: component["component_kind"] for component in components} + self.assertEqual(kinds.get("src/libraries/java/nv-boot-parent"), "java-framework") + self.assertTrue(self.github_release.java_framework_paths(components)) + + metadata = json.loads(SCRIPT_PATH.with_name("github-release-subprojects.json").read_text()) + registered = {service["path"] for service in metadata["services"]} + services = [c for c in components if c["component_kind"] == "java-service"] + self.assertGreater(len(services), 0) + for component in services: + with self.subTest(component=component["id"]): + # A java-service that is not a registered subproject can never + # receive a dependency-triggered release. + self.assertIn(component["path"], registered) + self.assertTrue( + self.github_release.is_java_service( + components, {"id": component["id"], "path": component["path"]} + ) + ) + + def test_framework_change_fans_out_a_patch_release_to_every_dependent_service(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + self.init_java_repo(root) + self.commit_framework_change(root) + + for service_id, path, version in self.JAVA_SERVICES: + with self.subTest(service=service_id): + service = self.java_service_metadata(service_id, path) + created, text = self.fanout_dry_run(root, service) + self.assertTrue(created) + expected = self.github_release.next_patch_version(version) + self.assertIn(f"would create {path}/v{expected}", text) + self.assertIn("dependency-triggered release", text) + self.assertIn(self.JAVA_FRAMEWORK_PATH, text) + self.assertIn("fix(nv-boot): bump shared framework", text) + + def test_framework_fanout_skips_a_component_that_is_not_a_java_service(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + self.init_java_repo(root) + self.commit_framework_change(root) + + framework = { + "id": "nv-boot-parent", + "path": self.JAVA_FRAMEWORK_PATH, + "service_name": "nv-boot-parent", + } + created, _text = self.fanout_dry_run(root, framework) + self.assertFalse(created) + + go_service = { + "id": "ratelimiter", + "path": "src/invocation-plane-services/ratelimiter", + "service_name": "nvcf-ratelimiter", + } + created, _text = self.fanout_dry_run(root, go_service) + self.assertFalse(created) + + def test_no_framework_change_since_the_last_service_tag_releases_nothing(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + self.init_java_repo(root) + self.commit_framework_change(root) + # Release each service at the fanned-out patch, then assert a rerun + # over the same framework commit is a no-op. + for service_id, path, version in self.JAVA_SERVICES: + git(root, "tag", f"{path}/v{self.github_release.next_patch_version(version)}") + + for service_id, path, _version in self.JAVA_SERVICES: + with self.subTest(service=service_id): + service = self.java_service_metadata(service_id, path) + created, text = self.fanout_dry_run(root, service) + self.assertFalse(created) + self.assertIn("no release-worthy Java framework commits since", text) + self.assertNotIn("would create", text) + + def test_non_release_worthy_framework_commits_release_nothing(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + self.init_java_repo(root) + for message in ( + "docs(nv-boot): document the shared framework", + "chore(nv-boot): reformat", + "refactor(nv-boot): extract a helper", + "no conventional prefix at all", + ): + self.commit_framework_change(root, message) + + for service_id, path, _version in self.JAVA_SERVICES: + with self.subTest(service=service_id): + service = self.java_service_metadata(service_id, path) + created, text = self.fanout_dry_run(root, service) + self.assertFalse(created) + self.assertIn("no release-worthy Java framework commits since", text) + + # One release-worthy framework commit is enough to fan out, and the + # notes quote only the release-worthy ones. + self.commit_framework_change(root, "perf(nv-boot): trim startup work") + service_id, path, version = self.JAVA_SERVICES[0] + created, text = self.fanout_dry_run(root, self.java_service_metadata(service_id, path)) + self.assertTrue(created) + self.assertIn(f"would create {path}/v{self.github_release.next_patch_version(version)}", text) + self.assertIn("perf(nv-boot): trim startup work", text) + self.assertNotIn("chore(nv-boot): reformat", text) + + def test_releases_a_version_follows_the_configured_release_rules(self): + releases_a_version = self.github_release.releases_a_version + for subject in ("feat: x", "fix(scope): x", "perf: x", "chore(scope)!: x", "FIX: x"): + self.assertTrue(releases_a_version(subject), subject) + for subject in ("chore: x", "ci(scope): x", "docs: x", "style: x", "refactor: x", + "test: x", "build: x", "not a conventional commit"): + self.assertFalse(releases_a_version(subject), subject) + + def test_framework_fanout_needs_an_existing_service_release_tag(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + self.init_repo(root) + self.write_java_component(root, self.JAVA_FRAMEWORK_PATH, "nv-boot-parent", "java-framework") + self.write_java_component(root, "src/control-plane-services/notary", "notary", "java-service") + self.commit_all(root, "seed java components") + self.commit_framework_change(root) + + service = self.java_service_metadata("notary", "src/control-plane-services/notary") + created, text = self.fanout_dry_run(root, service) + self.assertFalse(created) + self.assertIn("no existing release tag to bump from", text) + + def test_framework_fanout_dry_run_creates_no_tag_and_no_release(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + self.init_java_repo(root) + self.commit_framework_change(root) + before = self.list_tags(root) + + releases = [] + self.github_release.create_release = lambda *a, **k: releases.append(a) + for service_id, path, _version in self.JAVA_SERVICES: + created, _text = self.fanout_dry_run(root, self.java_service_metadata(service_id, path)) + self.assertTrue(created) + + self.assertEqual(self.list_tags(root), before) + self.assertEqual(releases, []) + + def test_framework_fanout_publish_mode_tags_pushes_and_releases(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) / "repo" + remote = Path(tmp) / "remote.git" + root.mkdir() + subprocess.run( + ["git", "init", "--bare", "--initial-branch=main", str(remote)], + check=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + self.init_java_repo(root) + git(root, "remote", "add", "origin", str(remote)) + git(root, "push", "origin", "HEAD") + self.commit_framework_change(root) + + releases = [] + self.github_release.create_release = lambda tag, title, notes, draft, dry_run: releases.append( + (tag, notes, draft, dry_run) + ) + components = self.github_release.java_ci_components(root) + service_id, path, version = self.JAVA_SERVICES[0] + service = self.java_service_metadata(service_id, path) + with chdir(root), contextlib.redirect_stdout(io.StringIO()): + created = self.github_release.publish_framework_dependency_release( + root, service, components, dry_run=False, draft=False + ) + + expected_tag = f"{path}/v{self.github_release.next_patch_version(version)}" + self.assertTrue(created) + self.assertIn(expected_tag, self.list_tags(root)) + self.assertIn(expected_tag, self.list_tags(remote)) + self.assertEqual(len(releases), 1) + self.assertEqual(releases[0][0], expected_tag) + self.assertIn("dependency-triggered release", releases[0][1]) + + def test_semantic_release_version_wins_over_dependency_fanout(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + self.init_java_repo(root) + self.commit_framework_change(root) + service_id, path, version = self.JAVA_SERVICES[0] + (root / path / "README.md").write_text("service change\n") + self.commit_all(root, "fix(cloud-tasks): service change") + before = self.list_tags(root) + + releases = [] + self.github_release.create_release = lambda *a, **k: releases.append(a) + components = self.github_release.java_ci_components(root) + service = self.java_service_metadata(service_id, path) + semantic_release_output = ( + "[semantic-release] > Analyzing commit: fix(cloud-tasks): service change\n" + "[semantic-release] > The next release version is 1.6.3\n" + ) + + output = io.StringIO() + with chdir(root), contextlib.redirect_stdout(output): + outcome = self.github_release.finish_semantic_release( + root, service, components, 0, semantic_release_output, dry_run=False, draft=False + ) + + text = output.getvalue() + self.assertEqual(outcome, "released") + self.assertIn(f"semantic-release created {path}/v1.6.3", text) + self.assertNotIn("dependency-triggered", text) + self.assertNotIn("dependency patch release", text) + # No extra tag: the fan-out would have proposed the same patch line + # and double-tagged the push. + self.assertEqual(self.list_tags(root), before) + self.assertEqual(releases, []) + self.assertEqual( + self.github_release.next_patch_version(version), "1.6.3", "fan-out would collide" + ) + + def test_semantic_release_no_release_falls_through_to_dependency_fanout(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + self.init_java_repo(root) + self.commit_framework_change(root) + components = self.github_release.java_ci_components(root) + service_id, path, version = self.JAVA_SERVICES[0] + service = self.java_service_metadata(service_id, path) + no_release_output = "[semantic-release] > There are no relevant changes, so no new version is released.\n" + + output = io.StringIO() + with chdir(root), contextlib.redirect_stdout(output): + outcome = self.github_release.finish_semantic_release( + root, service, components, 0, no_release_output, dry_run=True, draft=False + ) + + text = output.getvalue() + self.assertEqual(outcome, "no-release") + expected = self.github_release.next_patch_version(version) + self.assertIn(f"would create {path}/v{expected}", text) + self.assertIn("dependency-triggered release", text) + + def test_resolve_release_outcome_classifies_semantic_release_runs(self): + self.assertEqual( + self.github_release.resolve_release_outcome(0, "The next release version is 2.4.0"), + "released", + ) + self.assertEqual( + self.github_release.resolve_release_outcome( + 0, "There are no relevant changes, so no new version is released." + ), + "no-release", + ) + self.assertEqual(self.github_release.resolve_release_outcome(1, "boom"), "unknown") + # A run that printed a version and then died is not trustworthy: the + # publish run may not reproduce it, so it must be reported rather than + # previewed as a tag. + self.assertEqual( + self.github_release.resolve_release_outcome(1, "The next release version is 2.4.0"), + "unknown", + ) + self.assertEqual( + self.github_release.resolve_release_outcome( + 137, "There are no relevant changes, so no new version is released." + ), + "unknown", + ) + + def test_failed_semantic_release_run_does_not_fan_out(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + self.init_java_repo(root) + self.commit_framework_change(root) + components = self.github_release.java_ci_components(root) + service_id, path, _version = self.JAVA_SERVICES[0] + service = self.java_service_metadata(service_id, path) + + output = io.StringIO() + with chdir(root), contextlib.redirect_stdout(output): + outcome = self.github_release.finish_semantic_release( + root, service, components, 137, "killed mid-run\n", dry_run=True, draft=False + ) + + self.assertEqual(outcome, "unknown") + self.assertNotIn("would create", output.getvalue()) + self.assertNotIn("dependency-triggered", output.getvalue()) + + def list_tags(self, root): + result = subprocess.run( + ["git", "tag", "-l"], cwd=root, check=True, stdout=subprocess.PIPE, text=True + ) + return sorted(line.strip() for line in result.stdout.splitlines() if line.strip()) + def test_version_file_release_skips_existing_current_tag_on_previous_commit(self): with tempfile.TemporaryDirectory() as tmp: root = Path(tmp)