Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 21 additions & 13 deletions scripts/publish_github_release.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
6 changes: 3 additions & 3 deletions src/crewplane/cli/run/context.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
18 changes: 13 additions & 5 deletions src/crewplane/runtime/workspace/worktree/result_validation.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from __future__ import annotations

import unicodedata
from collections.abc import Iterable
from pathlib import Path

from ..git import git
Expand All @@ -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":
Expand All @@ -31,14 +32,21 @@ 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:
raise RuntimeError(
"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()
6 changes: 4 additions & 2 deletions tests/integration/cli/test_run_manifest_dedupe.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
4 changes: 2 additions & 2 deletions tests/integration/runtime/agent/test_invocation_loop.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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:
Expand Down
4 changes: 2 additions & 2 deletions tests/unit/core/test_workspace_git_policy.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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

Expand Down
27 changes: 27 additions & 0 deletions tests/unit/runtime/workspace/test_service_worktree_guards.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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")

Expand Down Expand Up @@ -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")

Expand Down
16 changes: 16 additions & 0 deletions tests/unit/runtime/workspace/test_worktree_result_validation.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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))