Skip to content
Closed
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
10 changes: 10 additions & 0 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ updates:
- package-ecosystem: github-actions
directory: "/"
open-pull-requests-limit: 2
cooldown:
semver-major-days: 30
schedule:
interval: weekly
labels:
Expand All @@ -22,6 +24,8 @@ updates:
- package-ecosystem: uv
directory: "/"
open-pull-requests-limit: 2
cooldown:
semver-major-days: 30
schedule:
interval: weekly
labels:
Expand All @@ -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:
Expand All @@ -56,6 +62,8 @@ updates:
- package-ecosystem: docker
directory: "/"
open-pull-requests-limit: 2
cooldown:
semver-major-days: 30
schedule:
interval: weekly
labels:
Expand All @@ -79,6 +87,8 @@ updates:
- package-ecosystem: docker
directory: "/docker"
open-pull-requests-limit: 2
cooldown:
semver-major-days: 30
schedule:
interval: weekly
labels:
Expand Down
9 changes: 6 additions & 3 deletions docs/tooling/artifact-inputs.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down
59 changes: 56 additions & 3 deletions odoo_devkit/local_runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down
40 changes: 36 additions & 4 deletions tests/test_runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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")
Expand Down
Loading