diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 85bbdd9..5da84f4 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -5,6 +5,8 @@ updates: - package-ecosystem: github-actions directory: "/" open-pull-requests-limit: 2 + cooldown: + semver-major-days: 30 schedule: interval: weekly labels: @@ -22,6 +24,8 @@ updates: - package-ecosystem: uv directory: "/" open-pull-requests-limit: 2 + cooldown: + semver-major-days: 30 schedule: interval: weekly labels: @@ -39,6 +43,8 @@ updates: - package-ecosystem: uv directory: "/docker/runtime-python" open-pull-requests-limit: 2 + cooldown: + semver-major-days: 30 schedule: interval: weekly labels: @@ -56,6 +62,8 @@ updates: - package-ecosystem: docker directory: "/" open-pull-requests-limit: 2 + cooldown: + semver-major-days: 30 schedule: interval: weekly labels: @@ -79,6 +87,8 @@ updates: - package-ecosystem: docker directory: "/docker" open-pull-requests-limit: 2 + cooldown: + semver-major-days: 30 schedule: interval: weekly labels: diff --git a/docs/tooling/artifact-inputs.md b/docs/tooling/artifact-inputs.md index 2266030..ef029c3 100644 --- a/docs/tooling/artifact-inputs.md +++ b/docs/tooling/artifact-inputs.md @@ -73,9 +73,12 @@ support lock and tenant lock catalog. When the manifest includes the devkit repo, `platform dependencies check` reports that build-tool mismatch before the publish workflow reaches Buildx. Devkit alone writes those markers from the verified Git snapshots used for the -build. Each recorded source commit must also be advertised by a ref in its -normalized GitHub origin; changing only `.git/config` cannot reattribute a -local commit to another repository. +build. Each recorded source commit must also be published in its normalized +GitHub origin: either a ref points at it, or a branch or tag there contains it. +A pin keeps working after its branch moves on. Changing only `.git/config` +cannot reattribute a local commit to another repository, and a commit reachable +only through the fork network does not count, because the check never fetches +a commit by id. The artifact build uses two explicit uv roots: diff --git a/odoo_devkit/local_runtime.py b/odoo_devkit/local_runtime.py index 05c6d44..68276a6 100644 --- a/odoo_devkit/local_runtime.py +++ b/odoo_devkit/local_runtime.py @@ -2801,7 +2801,7 @@ def require_remote_source_commit( label: str, github_token: str | None = None, ) -> None: - remote_url = f"https://github.com/{repository}.git" + remote_url = source_repository_remote_url(repository) execution_env = artifact_git_command_env() normalized_token = clean_optional_value(github_token) if normalized_token: @@ -2833,8 +2833,61 @@ def require_remote_source_commit( for line in remote_result.stdout.splitlines() if "\t" in line and GIT_SHA_PATTERN.fullmatch(line.split("\t", 1)[0].strip()) } - if commit not in advertised_commits: - raise RuntimeCommandError(f"Artifact publish requires {label} commit {commit} to be advertised by a ref in {repository}.") + if commit in advertised_commits: + return + if not remote_branch_or_tag_contains_commit(remote_url=remote_url, commit=commit, execution_env=execution_env): + raise RuntimeCommandError( + f"Artifact publish requires {label} commit {commit} to be advertised by a ref in {repository}, " + "or reachable from one of its branches or tags." + ) + + +def source_repository_remote_url(repository: str) -> str: + return f"https://github.com/{repository}.git" + + +def remote_branch_or_tag_contains_commit(*, remote_url: str, commit: str, execution_env: dict[str, str]) -> bool: + """Whether a branch or tag of the remote contains the commit. + + Fetch only the commit history of branches and tags, then look for the commit + there. The commit is never fetched by id: GitHub serves any commit in a fork + network that way, which would let another repository's commit pass as this one's. + """ + # Never lazily fetch a missing object by id from the promisor remote. + history_env = {**execution_env, "GIT_NO_LAZY_FETCH": "1"} + with tempfile.TemporaryDirectory(prefix="odoo-devkit-source-history-") as temporary_directory: + history_path = Path(temporary_directory) + + def git(*arguments: str) -> subprocess.CompletedProcess[str]: + return subprocess.run( + ["git", *arguments], + cwd=history_path, + capture_output=True, + text=True, + env=history_env, + ) + + # An empty template: an inherited template must not seed refs or objects. + if git("init", "--quiet", "--bare", "--template=").returncode != 0: + return False + fetch_result = git( + "fetch", + "--quiet", + "--no-tags", + "--filter=tree:0", + remote_url, + "+refs/heads/*:refs/remotes/source/*", + "+refs/tags/*:refs/tags/*", + ) + if fetch_result.returncode != 0: + details = clean_optional_value(fetch_result.stderr) or clean_optional_value(fetch_result.stdout) + raise RuntimeCommandError( + f"Artifact publish could not read branch and tag history from {remote_url}." + + (f"\nGit reported: {details}" if details else "") + ) + # An unknown commit makes this fail, which counts as not contained. + containing_refs = git("for-each-ref", "--contains", commit, "--format=%(refname)", "refs/remotes/source", "refs/tags") + return containing_refs.returncode == 0 and bool(containing_refs.stdout.strip()) def require_artifact_git_sources_unchanged(sources: tuple[GitSourceSnapshot | None, ...]) -> None: diff --git a/tests/test_runtime.py b/tests/test_runtime.py index 724a359..241259a 100644 --- a/tests/test_runtime.py +++ b/tests/test_runtime.py @@ -438,10 +438,13 @@ def test_artifact_git_reads_ignore_and_reject_replace_refs(self) -> None: def test_remote_source_commit_must_be_advertised_by_origin_ref(self) -> None: commit = "a" * 40 - with mock.patch( - "odoo_devkit.local_runtime.subprocess.run", - return_value=mock.Mock(returncode=0, stdout=f"{'b' * 40}\trefs/heads/main\n", stderr=""), - ) as run_mock: + with ( + mock.patch( + "odoo_devkit.local_runtime.subprocess.run", + return_value=mock.Mock(returncode=0, stdout=f"{'b' * 40}\trefs/heads/main\n", stderr=""), + ) as run_mock, + mock.patch("odoo_devkit.local_runtime.remote_branch_or_tag_contains_commit", return_value=False), + ): with self.assertRaisesRegex(ValueError, "advertised by a ref"): self.remote_source_commit_verifier( repository="example/source-repo", @@ -455,6 +458,35 @@ def test_remote_source_commit_must_be_advertised_by_origin_ref(self) -> None: self.assertEqual(execution_environment["GIT_CONFIG_GLOBAL"], os.devnull) self.assertEqual(execution_environment["ODOO_DEVKIT_GITHUB_TOKEN"], "secret-token") + def test_remote_source_commit_accepts_an_ancestor_of_a_branch_and_refuses_an_orphan(self) -> None: + with tempfile.TemporaryDirectory() as temporary_directory: + origin_path = self._create_git_repo(Path(temporary_directory) / "source-repo") + git_environment = { + **os.environ, + "GIT_AUTHOR_NAME": "t", + "GIT_AUTHOR_EMAIL": "t@example.invalid", + "GIT_COMMITTER_NAME": "t", + "GIT_COMMITTER_EMAIL": "t@example.invalid", + } + + def git(*arguments: str) -> str: + return subprocess.run( + ["git", *arguments], cwd=origin_path, check=True, capture_output=True, text=True, env=git_environment + ).stdout.strip() + + earlier_commit = git("rev-parse", "HEAD") + git("commit", "--allow-empty", "--quiet", "-m", "newer tip") + git("checkout", "--quiet", "-b", "abandoned") + git("commit", "--allow-empty", "--quiet", "-m", "never merged") + orphan_commit = git("rev-parse", "HEAD") + git("checkout", "--quiet", "-") + git("branch", "--quiet", "-D", "abandoned") + + with mock.patch("odoo_devkit.local_runtime.source_repository_remote_url", return_value=str(origin_path)): + self.remote_source_commit_verifier(repository="example/source-repo", commit=earlier_commit, label="source-repo") + with self.assertRaisesRegex(ValueError, "reachable from one of its branches or tags"): + self.remote_source_commit_verifier(repository="example/source-repo", commit=orphan_commit, label="source-repo") + def test_clean_git_source_verifies_normalized_origin_and_commit(self) -> None: with tempfile.TemporaryDirectory() as temporary_directory: repo_path = self._create_git_repo(Path(temporary_directory) / "source-repo")