diff --git a/scripts/publish_github_release.sh b/scripts/publish_github_release.sh index eec1a16..49ff9fa 100755 --- a/scripts/publish_github_release.sh +++ b/scripts/publish_github_release.sh @@ -286,7 +286,7 @@ refresh_verified_release_plan() { if [[ "$line_count" != "3" \ || ! "$prerelease_line" =~ ^prerelease=(true|false)$ \ || ! "$latest_line" =~ ^latest=(true|false)$ \ - || ! "$notes_start_tag_line" =~ ^notes_start_tag=(|v[0-9A-Za-z._!-]+)$ ]]; then + || ! "$notes_start_tag_line" =~ ^notes_start_tag=(v[0-9A-Za-z._!-]+)?$ ]]; then echo "The verified release plan returned malformed output." >&2 return 1 fi @@ -365,20 +365,28 @@ publish_github_release() ( refresh_release_state verify_loaded_draft_release "Draft" else - local release_notes_flags=() if [[ -n "$notes_start_tag" ]]; then - release_notes_flags=(--notes-start-tag "$notes_start_tag") + gh release create "$tag_name" "${release_artifacts[@]}" \ + --repo "$repository" \ + --draft \ + --verify-tag \ + --generate-notes \ + --notes "$RELEASE_AUTOMATION_MARKER" \ + --notes-start-tag "$notes_start_tag" \ + --title "$tag_name" \ + --prerelease=false \ + --latest=false + else + gh release create "$tag_name" "${release_artifacts[@]}" \ + --repo "$repository" \ + --draft \ + --verify-tag \ + --generate-notes \ + --notes "$RELEASE_AUTOMATION_MARKER" \ + --title "$tag_name" \ + --prerelease=false \ + --latest=false fi - gh release create "$tag_name" "${release_artifacts[@]}" \ - --repo "$repository" \ - --draft \ - --verify-tag \ - --generate-notes \ - --notes "$RELEASE_AUTOMATION_MARKER" \ - "${release_notes_flags[@]}" \ - --title "$tag_name" \ - --prerelease=false \ - --latest=false refresh_release_state verify_loaded_draft_release "New draft" fi diff --git a/src/crewplane/cli/run/context.py b/src/crewplane/cli/run/context.py index 2811ebb..3949d18 100644 --- a/src/crewplane/cli/run/context.py +++ b/src/crewplane/cli/run/context.py @@ -36,9 +36,9 @@ def print_artifact_locations( console.print(f"Workflow: {workflow_name}", style="dim") console.print(f"Run ID: {output.run_id}", style="dim") console.print(f"Artifact key: {output.task_name}", style="dim") - console.print(f"Stages: {output.stages_dir}", style="dim") - console.print(f"Results: {output.results_dir}", style="dim") - console.print(f"Logs: {output.logs_dir}", style="dim") + console.print(f"Stages: {output.stages_dir}", style="dim", soft_wrap=True) + console.print(f"Results: {output.results_dir}", style="dim", soft_wrap=True) + console.print(f"Logs: {output.logs_dir}", style="dim", soft_wrap=True) def fallback_workflow_name(tasks_file: Path) -> str: diff --git a/src/crewplane/runtime/workspace/worktree/result_validation.py b/src/crewplane/runtime/workspace/worktree/result_validation.py index af181d7..b8dd7b9 100644 --- a/src/crewplane/runtime/workspace/worktree/result_validation.py +++ b/src/crewplane/runtime/workspace/worktree/result_validation.py @@ -1,6 +1,7 @@ from __future__ import annotations import unicodedata +from collections.abc import Iterable from pathlib import Path from ..git import git @@ -19,7 +20,7 @@ def validate_result_tree( "--full-tree", tree, ) - collision_paths: dict[str, str] = {} + paths: list[str] = [] for record in records: header, separator, path = record.partition("\t") if separator != "\t": @@ -31,6 +32,17 @@ def validate_result_tree( raise RuntimeError( "Workspace result tree contains reserved runtime artifact paths." ) + paths.append(path) + validate_portable_path_collisions(paths) + + +def _collision_key(path: str) -> str: + return unicodedata.normalize("NFC", path).casefold() + + +def validate_portable_path_collisions(paths: Iterable[str]) -> None: + collision_paths: dict[str, str] = {} + for path in paths: folded_path = _collision_key(path) existing_path = collision_paths.setdefault(folded_path, path) if existing_path != path: @@ -38,7 +50,3 @@ def validate_result_tree( "Workspace result tree contains paths that collide under " f"case or Unicode normalization: {existing_path}, {path}." ) - - -def _collision_key(path: str) -> str: - return unicodedata.normalize("NFC", path).casefold() diff --git a/tests/integration/cli/test_run_manifest_dedupe.py b/tests/integration/cli/test_run_manifest_dedupe.py index 14ce1b3..1219808 100644 --- a/tests/integration/cli/test_run_manifest_dedupe.py +++ b/tests/integration/cli/test_run_manifest_dedupe.py @@ -144,8 +144,10 @@ async def fake_execute_workflow(plan, output, **kwargs): # type: ignore[no-unty output_text = stream.getvalue() self.assertIn("Workflow: Review apps", output_text) self.assertIn("Artifact key: review-apps", output_text) - stages_root = str(tmp_path / ".crewplane" / "execution-stages") - results_root = str(tmp_path / ".crewplane" / "execution-results") + stages_root = str((tmp_path / ".crewplane" / "execution-stages").resolve()) + results_root = str( + (tmp_path / ".crewplane" / "execution-results").resolve() + ) self.assertRegex( output_text, re.escape(stages_root) diff --git a/tests/integration/runtime/agent/test_invocation_loop.py b/tests/integration/runtime/agent/test_invocation_loop.py index b650abb..6cb1ae9 100644 --- a/tests/integration/runtime/agent/test_invocation_loop.py +++ b/tests/integration/runtime/agent/test_invocation_loop.py @@ -102,7 +102,7 @@ async def runner( self.assertEqual(child_environment.set["GIT_CONFIG_GLOBAL"], os.devnull) self.assertEqual( child_environment.set["GIT_CEILING_DIRECTORIES"], - tmp_path.parent.as_posix(), + tmp_path.resolve().parent.as_posix(), ) self.assertIn("GIT_DIR", child_environment.unset) self.assertIn("GIT_CONFIG_KEY_0", child_environment.unset) @@ -177,7 +177,7 @@ async def runner( assert child_environment is not None self.assertEqual( child_environment.set["GIT_CEILING_DIRECTORIES"], - checkout_root.parent.as_posix(), + checkout_root.resolve().parent.as_posix(), ) async def test_workspace_retry_reset_runs_before_next_attempt(self) -> None: diff --git a/tests/unit/core/test_workspace_git_policy.py b/tests/unit/core/test_workspace_git_policy.py index bbc8887..4c1d1d6 100644 --- a/tests/unit/core/test_workspace_git_policy.py +++ b/tests/unit/core/test_workspace_git_policy.py @@ -105,7 +105,7 @@ def test_workspace_git_base_environment_supports_known_variants( assert env["GIT_NO_LAZY_FETCH"] == "1" assert env["GIT_TERMINAL_PROMPT"] == "0" assert env["GIT_OPTIONAL_LOCKS"] == "0" - assert env["GIT_CEILING_DIRECTORIES"] == tmp_path.as_posix() + assert env["GIT_CEILING_DIRECTORIES"] == tmp_path.resolve().as_posix() def test_sanitized_workspace_git_environment_injects_temporary_index( @@ -159,7 +159,7 @@ def test_workspace_child_environment_uses_shared_unset_and_ceiling_policy( assert "GIT_PROTOCOL_FROM_USER" not in child.unset assert "GIT_ALLOW_PROTOCOL" not in child.unset assert child.set["GIT_CONFIG_NOSYSTEM"] == "1" - assert child.set["GIT_CEILING_DIRECTORIES"] == tmp_path.as_posix() + assert child.set["GIT_CEILING_DIRECTORIES"] == tmp_path.resolve().as_posix() assert "GIT_OPTIONAL_LOCKS" not in child.set assert int(child.set["GIT_CONFIG_COUNT"]) > 0 diff --git a/tests/unit/runtime/workspace/test_service_worktree_guards.py b/tests/unit/runtime/workspace/test_service_worktree_guards.py index 179e47b..3711d27 100644 --- a/tests/unit/runtime/workspace/test_service_worktree_guards.py +++ b/tests/unit/runtime/workspace/test_service_worktree_guards.py @@ -18,6 +18,29 @@ ) +def supports_distinct_names( + root: Path, + first_name: str, + second_name: str, +) -> bool: + probe = root / "collision-probe" + try: + probe.mkdir() + except OSError: + return False + try: + try: + (probe / first_name).write_text("first\n", encoding="utf-8") + (probe / second_name).write_text("second\n", encoding="utf-8") + return len(list(probe.iterdir())) == 2 + except OSError: + return False + finally: + for path in probe.iterdir(): + path.unlink() + probe.rmdir() + + def test_worktree_retry_reset_rejects_own_protected_ref_drift( tmp_path: Path, ) -> None: @@ -339,6 +362,8 @@ def test_worktree_capture_rejects_case_colliding_result_paths( assert prepared.workspace_path is not None source = plan.workspace_source assert source is not None + if not supports_distinct_names(prepared.cwd, "Case.txt", "case.txt"): + pytest.skip("filesystem does not support distinct case variants") (prepared.cwd / "Case.txt").write_text("upper\n", encoding="utf-8") (prepared.cwd / "case.txt").write_text("lower\n", encoding="utf-8") @@ -372,6 +397,8 @@ def test_worktree_capture_rejects_unicode_colliding_result_paths( assert prepared.workspace_path is not None source = plan.workspace_source assert source is not None + if not supports_distinct_names(prepared.cwd, "Cafe\u0301.txt", "Café.txt"): + pytest.skip("filesystem does not support distinct Unicode-normalized variants") (prepared.cwd / "Cafe\u0301.txt").write_text("decomposed\n", encoding="utf-8") (prepared.cwd / "Café.txt").write_text("composed\n", encoding="utf-8") diff --git a/tests/unit/runtime/workspace/test_worktree_result_validation.py b/tests/unit/runtime/workspace/test_worktree_result_validation.py index 7c29c6a..a179b7f 100644 --- a/tests/unit/runtime/workspace/test_worktree_result_validation.py +++ b/tests/unit/runtime/workspace/test_worktree_result_validation.py @@ -6,6 +6,7 @@ import pytest from crewplane.runtime.workspace.worktree.result_validation import ( + validate_portable_path_collisions, validate_result_tree, ) from tests.helpers.workspace_service import create_git_repo, run_git_text @@ -25,3 +26,18 @@ def test_validate_result_tree_rejects_reserved_paths_under_project_root( with pytest.raises(RuntimeError, match="reserved runtime artifact paths"): validate_result_tree(repo, tree, "packages/app") + + +@pytest.mark.parametrize( + ("left_path", "right_path"), + ( + ("Case.txt", "case.txt"), + ("Cafe\u0301.txt", "Café.txt"), + ), +) +def test_validate_portable_path_collisions( + left_path: str, + right_path: str, +) -> None: + with pytest.raises(RuntimeError, match="case or Unicode normalization"): + validate_portable_path_collisions((left_path, right_path))