From f0b6769aa19b5ed958dd4b0e178f069645ec3ea7 Mon Sep 17 00:00:00 2001 From: shiny-code-bot Date: Tue, 29 Sep 2026 22:35:01 -0400 Subject: [PATCH 1/3] Add Dependabot cooldown to every update entry Green patch and minor updates are about to merge through the merge train without an owner click, so every ecosystem waits 30 days before offering a new major version. Security updates are not delayed by cooldown. Refs cbusillo/launchplane#2627 --- .github/dependabot.yml | 10 ++++++++++ 1 file changed, 10 insertions(+) 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: From de796591cee4ec2822c7964ac0c2cb11e59cc073 Mon Sep 17 00:00:00 2001 From: shiny-code-bot Date: Tue, 29 Sep 2026 22:45:51 -0400 Subject: [PATCH 2/3] Accept a source commit a branch or tag contains Publish required each source commit to be a ref tip, so every merge to devkit main broke the next artifact build of every tenant pinned to the previous tip. A commit that is not a tip now passes when a branch or tag of its origin contains it, found by fetching only commit history. The commit itself is never fetched by id, so a fork-network commit still fails. Fixes #139 --- docs/tooling/artifact-inputs.md | 9 ++++-- odoo_devkit/local_runtime.py | 57 +++++++++++++++++++++++++++++++-- tests/test_runtime.py | 40 ++++++++++++++++++++--- 3 files changed, 96 insertions(+), 10 deletions(-) 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..5d6999d 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,59 @@ 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. + """ + 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=execution_env, + ) + + if git("init", "--quiet", "--bare").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 "") + ) + if git("cat-file", "-e", f"{commit}^{{commit}}").returncode != 0: + return False + 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") From 766045923011f74488ce9f08b4eca182c5060aef Mon Sep 17 00:00:00 2001 From: shiny-code-bot Date: Tue, 29 Sep 2026 22:48:17 -0400 Subject: [PATCH 3/3] Harden the source history check Initialize the verification repository with an empty template and disable lazy fetching, so neither an inherited template nor a missing-object lookup can supply a commit. for-each-ref --contains already rejects an unknown commit, so the separate existence probe is gone. From an OpenAI gpt-6.1-sol review. --- odoo_devkit/local_runtime.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/odoo_devkit/local_runtime.py b/odoo_devkit/local_runtime.py index 5d6999d..68276a6 100644 --- a/odoo_devkit/local_runtime.py +++ b/odoo_devkit/local_runtime.py @@ -2853,6 +2853,8 @@ def remote_branch_or_tag_contains_commit(*, remote_url: str, commit: str, execut 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) @@ -2862,10 +2864,11 @@ def git(*arguments: str) -> subprocess.CompletedProcess[str]: cwd=history_path, capture_output=True, text=True, - env=execution_env, + env=history_env, ) - if git("init", "--quiet", "--bare").returncode != 0: + # 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", @@ -2882,8 +2885,7 @@ def git(*arguments: str) -> subprocess.CompletedProcess[str]: f"Artifact publish could not read branch and tag history from {remote_url}." + (f"\nGit reported: {details}" if details else "") ) - if git("cat-file", "-e", f"{commit}^{{commit}}").returncode != 0: - return False + # 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())