build: enforce ifttt-lint in pre-commit and CI (#4460)

Ad-hoc "Keep in sync" comments drift silently because no automated
check verifies that paired sections are updated together.

Configure `ifttt-lint` in `.pre-commit-config.yaml` and
`.github/workflows/ci.yaml` (extending `download-tool` to extract
archive members) and convert existing sync comments to
`LINT.IfChange` and `LINT.ThenChange` directives.
diff --git a/.bazelci/presubmit.yml b/.bazelci/presubmit.yml
index d00d3e8..0420712 100644
--- a/.bazelci/presubmit.yml
+++ b/.bazelci/presubmit.yml
@@ -13,17 +13,19 @@
 # limitations under the License.
 
 ---
+# LINT.IfChange(buildifier)
 buildifier:
-  # keep these arguments in sync with .pre-commit-config.yaml
   # Use a specific version to avoid skew issues when new versions are released.
   version: 8.2.1
   warnings: "all"
+# LINT.ThenChange(//.pre-commit-config.yaml:buildifier)
 # NOTE: Minimum supported version is 7.x
+# LINT.IfChange(minimum_supported_version)
 .minimum_supported_version: &minimum_supported_version
   # For testing minimum supported version.
-  # NOTE: Keep in sync with //:version.bzl
   bazel: 7.x
   skip_in_bazel_downstream_pipeline: "Bazel 7 required"
+# LINT.ThenChange(//version.bzl:minimum_bazel_version)
 .reusable_config: &reusable_config
   # USE_BAZEL_DIFF is disabled because target_compatible_with is used heavily,
   # but BazelCI's bazel-diff integration errors when handling them.
@@ -117,7 +119,7 @@
     - //tests:version_3_14_test
     - //tests:version_default_test
 
-# Keep in sync with .bcr/gazelle/presubmit.yml
+# LINT.IfChange(gazelle_common_bcr)
 .gazelle_common_bcr: &gazelle_common_bcr
     bazel: ${{ bazel }}
     working_directory: gazelle/examples/bzlmod_build_file_generation
@@ -134,20 +136,21 @@
       - ":modules_map"
     test_targets:
       - "//..."
+# LINT.ThenChange(//.bcr/gazelle/presubmit.yml:tasks)
 
 
 matrix:
-  # Keep in sync with .bcr/presubmit.yml
+  # LINT.IfChange(bcr_matrix)
   platform:
     - ubuntu2204
     - debian13
     - macos_arm64
     - windows
-  # Keep in sync with .bcr/presubmit.yml
   bazel: [7.x, 8.x, 9.x]
+  # LINT.ThenChange(//.bcr/presubmit.yml:matrix)
 
 tasks:
-  # Keep in sync with .bcr/presubmit.yml
+  # LINT.IfChange(bcr_test)
   bcr_test:
     name: "BCR: Bazel {bazel}"
     platform: ${{ platform }}
@@ -161,8 +164,9 @@
       - "//..."
     test_targets:
       - "//..."
+  # LINT.ThenChange(//.bcr/presubmit.yml:tasks)
 
-  # Keep in sync with .bcr/gazelle/presubmit.yml
+  # LINT.IfChange(gazelle_bcr)
   gazelle_bcr_ubuntu:
     <<: *gazelle_common_bcr
     name: "Gazelle: BCR, Bazel {bazel}"
@@ -175,6 +179,7 @@
     <<: *gazelle_common_bcr
     name: "Gazelle: BCR, Bazel {bazel}"
     platform: macos_arm64
+  # LINT.ThenChange(//.bcr/gazelle/presubmit.yml:matrix)
 
   gazelle_extension_min:
     <<: *common_workspace_flags_min_bazel
diff --git a/.bcr/gazelle/presubmit.yml b/.bcr/gazelle/presubmit.yml
index ef1b0c7..6995a5d 100644
--- a/.bcr/gazelle/presubmit.yml
+++ b/.bcr/gazelle/presubmit.yml
@@ -14,6 +14,7 @@
 
 bcr_test_module:
   module_path: "examples/bzlmod_build_file_generation"
+  # LINT.IfChange(matrix)
   matrix:
     platform: [
       "debian13",
@@ -21,6 +22,8 @@
       "ubuntu2204",
     ]
     bazel: [7.*, 8.*, 9.*]
+  # LINT.ThenChange(//.bazelci/presubmit.yml:gazelle_bcr)
+  # LINT.IfChange(tasks)
   tasks:
     run_tests:
       name: "Run test module"
@@ -39,3 +42,4 @@
         - ":modules_map"
       test_targets:
         - "//..."
+  # LINT.ThenChange(//.bazelci/presubmit.yml:gazelle_common_bcr)
diff --git a/.bcr/presubmit.yml b/.bcr/presubmit.yml
index 2787306..314973e 100644
--- a/.bcr/presubmit.yml
+++ b/.bcr/presubmit.yml
@@ -14,9 +14,12 @@
 
 bcr_test_module:
   module_path: "examples/bzlmod"
+  # LINT.IfChange(matrix)
   matrix:
     platform: ["debian13", "macos", "ubuntu2204", "windows"]
     bazel: [7.x, 8.x, 9.x]
+  # LINT.ThenChange(//.bazelci/presubmit.yml:bcr_matrix)
+  # LINT.IfChange(tasks)
   tasks:
     run_tests:
       name: "Run test module"
@@ -31,3 +34,4 @@
         - "//..."
       test_targets:
         - "//..."
+  # LINT.ThenChange(//.bazelci/presubmit.yml:bcr_test)
diff --git a/.github/actions/download-tool/action.yaml b/.github/actions/download-tool/action.yaml
index 165b7f5..235635f 100644
--- a/.github/actions/download-tool/action.yaml
+++ b/.github/actions/download-tool/action.yaml
@@ -11,6 +11,12 @@
   sha256:
     description: "Expected hex SHA-256 digest of the tool binary"
     required: true
+  archive-member:
+    description: >-
+      Optional archive member path to extract when the downloaded payload is a
+      tar or zip archive
+    required: false
+    default: ""
 
 outputs:
   path:
@@ -46,6 +52,7 @@
       if: steps.cache.outputs.cache-hit != 'true'
       shell: bash
       env:
+        TOOL_ARCHIVE_MEMBER: ${{ inputs.archive-member }}
         TOOL_PATH: ${{ steps.tool_path.outputs.path }}
         TOOL_SHA256: ${{ inputs.sha256 }}
         TOOL_URLS: ${{ inputs.urls }}
@@ -53,5 +60,6 @@
         "${GITHUB_ACTION_PATH}/../../../dev/verified_download.py"
         --urls "${TOOL_URLS}"
         --sha256 "${TOOL_SHA256}"
+        --archive-member "${TOOL_ARCHIVE_MEMBER}"
         --executable
         --output "${TOOL_PATH}"
diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml
index b5a547a..048d848 100644
--- a/.github/workflows/ci.yaml
+++ b/.github/workflows/ci.yaml
@@ -21,15 +21,16 @@
     runs-on: ubuntu-latest
     steps:
       - uses: actions/checkout@v7
+      # LINT.IfChange(ruff)
       - uses: astral-sh/ruff-action@v4.1.0
         with:
-          # Keep in sync with .pre-commit-config.yaml
           version: 0.15.14
           args: check --extend-exclude testdata
       - uses: astral-sh/ruff-action@v4.1.0
         with:
           version: 0.15.14
           args: format --check --exclude testdata
+      # LINT.ThenChange(//.pre-commit-config.yaml:ruff)
 
   keep_sorted:
     name: keep-sorted
@@ -41,15 +42,48 @@
         uses: ./.github/actions/download-tool
         with:
           name: keep-sorted
-          # Keep in sync with .pre-commit-config.yaml
+          # LINT.IfChange(keep_sorted)
           urls: |
             https://github.com/google/keep-sorted/releases/download/v0.10.0/keep-sorted_linux_amd64
           sha256: 079710c01619cc3dc11c9ddce18ac8db8d86701e7d12f9c5b62b8c4c4a664cca
+          # LINT.ThenChange(//.pre-commit-config.yaml:keep_sorted)
       - name: Check keep-sorted
         env:
           KEEP_SORTED_BIN: ${{ steps.keep-sorted.outputs.path }}
         run: ./.github/workflows/check_keep_sorted.py -- "$KEEP_SORTED_BIN"
 
+  ifttt_lint:
+    name: ifttt-lint
+    runs-on: ubuntu-latest
+    steps:
+      - uses: actions/checkout@v7
+        with:
+          fetch-depth: 0
+      - name: Download ifttt-lint
+        id: ifttt-lint
+        uses: ./.github/actions/download-tool
+        with:
+          name: ifttt-lint
+          archive-member: ifttt-lint
+          # LINT.IfChange(ifttt_lint)
+          urls: |
+            https://github.com/simonepri/ifttt-lint/releases/download/v0.11.3/ifttt-lint-x86_64-unknown-linux-gnu.tar.gz
+          sha256: 1dd2071f17d5f5c11d698ae60e81c0ed1d419ca90a2d64f5d3810bdf28ddd4fe
+          # LINT.ThenChange(//.pre-commit-config.yaml:ifttt_lint)
+      - name: Check ifttt-lint
+        env:
+          IFTTT_LINT_BIN: ${{ steps.ifttt-lint.outputs.path }}
+        run: >-
+          "$IFTTT_LINT_BIN" --vcs git '**/*'
+      - name: Check ifttt-lint diff
+        if: github.event_name == 'pull_request'
+        env:
+          BASE_SHA: ${{ github.event.pull_request.base.sha }}
+          HEAD_SHA: ${{ github.event.pull_request.head.sha }}
+          IFTTT_LINT_BIN: ${{ steps.ifttt-lint.outputs.path }}
+        run: >-
+          "$IFTTT_LINT_BIN" --vcs git --diff "${BASE_SHA}...${HEAD_SHA}"
+
   build_release_artifacts:
     name: Build release artifacts
     uses: ./.github/workflows/release_build_artifacts.yaml
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index cd11aa5..aa09867 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -21,21 +21,30 @@
     hooks:
       - id: check-merge-conflict
   - repo: https://github.com/google/keep-sorted
-    # Keep in sync with .github/workflows/ci.yaml
+    # LINT.IfChange(keep_sorted)
     rev: v0.10.0
+    # LINT.ThenChange(//.github/workflows/ci.yaml:keep_sorted)
     hooks:
       - id: keep-sorted
+  - repo: https://github.com/simonepri/ifttt-lint
+    # LINT.IfChange(ifttt_lint)
+    rev: v0.11.3
+    # LINT.ThenChange(//.github/workflows/ci.yaml:ifttt_lint)
+    hooks:
+      - id: ifttt-lint
+      - id: ifttt-lint-diff
+  # LINT.IfChange(buildifier)
   - repo: https://github.com/keith/pre-commit-buildifier
     rev: 8.2.1
     hooks:
       - id: buildifier
         args: &args
-          # Keep this argument in sync with .bazelci/presubmit.yaml
           - --warnings=all
       - id: buildifier-lint
         args: *args
+  # LINT.ThenChange(//.bazelci/presubmit.yml:buildifier)
   - repo: https://github.com/astral-sh/ruff-pre-commit
-    # Keep in sync with .github/workflows/ruff.yaml
+    # LINT.IfChange(ruff)
     rev: v0.15.14
     hooks:
       - id: ruff-check
@@ -43,6 +52,7 @@
         exclude: testdata
       - id: ruff-format
         exclude: testdata
+    # LINT.ThenChange(//.github/workflows/ci.yaml:ruff)
   - repo: local
     hooks:
       - id: update-deleted-packages
diff --git a/MODULE.bazel b/MODULE.bazel
index 75087fd..24f68a7 100644
--- a/MODULE.bazel
+++ b/MODULE.bazel
@@ -486,7 +486,7 @@
     dev_dependency = True,
 )
 
-# Keep in sync with //:version.bzl
+# LINT.IfChange(bazel_binaries)
 bazel_binaries.local(
     name = "self",
     path = "tests/integration/bazel_from_env",
@@ -508,6 +508,7 @@
     # "build_bazel_bazel_rolling",
     "build_bazel_bazel_self",
 )
+# LINT.ThenChange(//version.bzl:supported_bazel_versions)
 
 # TODO @aignas 2025-01-27: should this be moved to `//python/extensions:uv.bzl` or should
 # it stay as it is? I think I may prefer to move it.
@@ -516,11 +517,15 @@
 # Here is how we can define platforms for the `uv` binaries - this will affect
 # all of the downstream callers because we are using the extension without
 # `dev_dependency = True`.
+# LINT.IfChange(uv_defaults)
 uv.default(
     base_url = "https://github.com/astral-sh/uv/releases/download",
     manifest_filename = "dist-manifest.json",
     version = "0.13.5",
 )
+
+# LINT.ThenChange(//python/uv/private/uv_defaults.bzl:uv_defaults)
+# LINT.IfChange(uv_platforms)
 uv.default(
     compatible_with = [
         "@platforms//os:macos",
@@ -577,6 +582,8 @@
     ],
     platform = "x86_64-unknown-linux-gnu",
 )
+
+# LINT.ThenChange(//python/uv/private/uv_defaults.bzl:uv_platforms)
 use_repo(uv, "uv")
 
 register_toolchains("@uv//:all")
diff --git a/dev/verified_download.py b/dev/verified_download.py
index d7c25c5..baf4510 100755
--- a/dev/verified_download.py
+++ b/dev/verified_download.py
@@ -53,6 +53,14 @@
         help="Expected lowercase or uppercase hex SHA-256 digest.",
     )
     parser.add_argument(
+        "--archive-member",
+        default="",
+        help=(
+            "Optional archive member path to extract when the downloaded "
+            "payload is a zip or tar archive."
+        ),
+    )
+    parser.add_argument(
         "--output",
         type=Path,
         required=True,
@@ -275,7 +283,7 @@
     """Async CLI entry point."""
     args = _parse_args(argv)
     urls = parse_url_list(args.urls)
-    sources = [(url, args.sha256, "") for url in urls]
+    sources = [(url, args.sha256, args.archive_member) for url in urls]
     await download_verified_file(
         args.output,
         sources,
diff --git a/docs/conf.py b/docs/conf.py
index 750e344..bc58082 100644
--- a/docs/conf.py
+++ b/docs/conf.py
@@ -199,8 +199,9 @@
     "current_version": os.environ.get("READTHEDOCS_VERSION", ""),
 }
 
-# Keep this in sync with the stardoc templates
+# LINT.IfChange(permalink_icon)
 html_permalinks_icon = "¶"
+# LINT.ThenChange(//sphinxdocs/sphinxdocs/private/proto_to_markdown.py:permalink_icon)
 
 # These folders are copied to the documentation's HTML output
 html_static_path = ["_static"]
diff --git a/python/private/local_runtime_repo.bzl b/python/private/local_runtime_repo.bzl
index f5597db..9a69803 100644
--- a/python/private/local_runtime_repo.bzl
+++ b/python/private/local_runtime_repo.bzl
@@ -169,7 +169,7 @@
     # appropriate path.
     interpreter_path = info["base_executable"]
 
-    # NOTE: Keep in sync with recursive glob in define_local_runtime_toolchain_impl
+    # LINT.IfChange(include_path)
     include_path = rctx.path(info["include"])
 
     # The reported include path may not exist, and watching a non-existant
@@ -185,6 +185,7 @@
     # appear as part of this repo.
     logger.debug(lambda: "Symlinking {} to include".format(include_path))
     rctx.symlink(include_path, "include")
+    # LINT.ThenChange(//python/private/local_runtime_repo_setup.bzl:includes)
 
     rctx.report_progress("Symlinking external Python shared libraries")
 
diff --git a/python/private/local_runtime_repo_setup.bzl b/python/private/local_runtime_repo_setup.bzl
index 05ca622..c35f834 100644
--- a/python/private/local_runtime_repo_setup.bzl
+++ b/python/private/local_runtime_repo_setup.bzl
@@ -99,6 +99,7 @@
             system_provided = True,
         )
 
+    # LINT.IfChange(includes)
     native.filegroup(
         name = "includes",
         srcs = native.glob(
@@ -107,6 +108,7 @@
             allow_empty = True,  # A Python install may not have C headers
         ),
     )
+    # LINT.ThenChange(//python/private/local_runtime_repo.bzl:include_path)
 
     # header libraries.
     cc_library(
diff --git a/python/private/python_bootstrap_template.txt b/python/private/python_bootstrap_template.txt
index 206c473..8d95f9d 100644
--- a/python/private/python_bootstrap_template.txt
+++ b/python/private/python_bootstrap_template.txt
@@ -331,11 +331,13 @@
 
 # Create the runfiles tree by extracting the zip file
 def create_runfiles_root():
+  # LINT.IfChange(runfiles_root_structure)
   temp_dir = tempfile.mkdtemp('', 'Bazel.runfiles_')
   extract_zip(os.path.dirname(__file__), temp_dir)
   # IMPORTANT: Later code does `rm -fr` on dirname(runfiles_root) -- it's
   # important that deletion code be in sync with this directory structure
   return join(temp_dir, 'runfiles')
+  # LINT.ThenChange(:runfiles_root_cleanup)
 
 def _symlink_tree(link_from, link_to):
     # Ensure the source is an absolute path to simplify symlinking
@@ -659,11 +661,13 @@
   delete_dirs = []
 
   if IS_ZIPFILE:
+    # LINT.IfChange(runfiles_root_cleanup)
     runfiles_root = create_runfiles_root()
     # NOTE: dirname() is called because create_runfiles_root() creates a
     # sub-directory within a temporary directory, and we want to remove the
     # whole temporary directory.
     delete_dirs.append(dirname(runfiles_root))
+    # LINT.ThenChange(:runfiles_root_structure)
   else:
     runfiles_root = find_runfiles_root(main_rel_path)
 
diff --git a/python/private/zipapp/zip_main_template.py b/python/private/zipapp/zip_main_template.py
index c93f21b..7c965fc 100644
--- a/python/private/zipapp/zip_main_template.py
+++ b/python/private/zipapp/zip_main_template.py
@@ -228,11 +228,13 @@
     else:
         extract_root = tempfile.mkdtemp("", "Bazel.runfiles_")
 
+    # LINT.IfChange(runfiles_root_structure)
     extract_zip(dirname(__file__), extract_root)
     print_verbose("extracted to:", extract_root)
     # IMPORTANT: Later code does `rm -fr` on dirname(runfiles_root) -- it's
     # important that deletion code be in sync with this directory structure
     return join(extract_root, "runfiles")
+    # LINT.ThenChange(:runfiles_root_cleanup)
 
 
 def run_subprocess(subprocess_argv, env, cwd):
@@ -330,6 +332,7 @@
         sys.exit(ret_code)
     finally:
         if not EXTRACT_ROOT:
+            # LINT.IfChange(runfiles_root_cleanup)
             # NOTE: dirname() is called because create_runfiles_root() creates a
             # sub-directory within a temporary directory, and we want to remove the
             # whole temporary directory.
@@ -340,6 +343,7 @@
                 shutil.rmtree(extract_root, onerror=remove_readonly)
             else:
                 shutil.rmtree(extract_root, True)
+            # LINT.ThenChange(:runfiles_root_structure)
 
 
 def finish_venv_setup(runfiles_root):
diff --git a/python/uv/private/uv_defaults.bzl b/python/uv/private/uv_defaults.bzl
index d50cec9..b7fce2b 100644
--- a/python/uv/private/uv_defaults.bzl
+++ b/python/uv/private/uv_defaults.bzl
@@ -4,11 +4,11 @@
 `MODULE.bazel`. Keep the values here in sync with them.
 """
 
-# Keep in sync with `uv.default(version = ...)` in MODULE.bazel.
+# LINT.IfChange(uv_defaults)
 DEFAULT_VERSION = "0.13.5"
 
-# Keep in sync with `uv.default(base_url = ...)` in MODULE.bazel.
 DEFAULT_BASE_URL = "https://github.com/astral-sh/uv/releases/download"
+# LINT.ThenChange(//MODULE.bazel:uv_defaults)
 
 def _platform(platform, *, os, cpu):
     # Same shape as the platform structs `set_config` creates.
@@ -21,7 +21,7 @@
         target_settings = [],
     )
 
-# Keep in sync with the `uv.default(platform = ...)` calls in MODULE.bazel.
+# LINT.IfChange(uv_platforms)
 DEFAULT_PLATFORMS = {
     platform: _platform(platform, os = os, cpu = cpu)
     for platform, os, cpu in [
@@ -35,3 +35,4 @@
         ("x86_64-unknown-linux-gnu", "linux", "x86_64"),
     ]
 }
+# LINT.ThenChange(//MODULE.bazel:uv_platforms)
diff --git a/sphinxdocs/sphinxdocs/private/proto_to_markdown.py b/sphinxdocs/sphinxdocs/private/proto_to_markdown.py
index aeecc35..0e26012 100644
--- a/sphinxdocs/sphinxdocs/private/proto_to_markdown.py
+++ b/sphinxdocs/sphinxdocs/private/proto_to_markdown.py
@@ -58,7 +58,9 @@
 def _link_here_icon(anchor: str) -> str:
     # The headerlink class activates some special logic to show/hide
     # text upon mouse-over; it's how headings show a clickable link.
+    # LINT.IfChange(permalink_icon)
     return _link("¶", anchor, classes=".headerlink")
+    # LINT.ThenChange(//docs/conf.py:permalink_icon)
 
 
 def _inline_anchor(anchor: str) -> str:
diff --git a/tests/workflows/check_keep_sorted_test.py b/tests/workflows/check_keep_sorted_test.py
index a55da46..a5dba8b 100644
--- a/tests/workflows/check_keep_sorted_test.py
+++ b/tests/workflows/check_keep_sorted_test.py
@@ -9,6 +9,7 @@
 import subprocess
 from collections.abc import Iterator
 from pathlib import Path
+from typing import Any
 
 import check_keep_sorted
 import pytest
@@ -37,41 +38,52 @@
     return path
 
 
-def test_ci_workflow_uses_download_tool_in_sync_with_pre_commit() -> None:
+def _assert_ci_download_step_in_sync(
+    *,
+    repo_url: str,
+    expected_hooks: list[str],
+    job_id: str,
+    step_id: str,
+    url_template: str,
+    archive_member: str = "",
+) -> list[dict[str, Any]]:
     pre_commit = yaml.safe_load(
         _repo_file(".pre-commit-config.yaml").read_text(encoding="utf-8")
     )
-    repos = pre_commit["repos"]
-    matching = [
-        repo
-        for repo in repos
-        if repo.get("repo") == "https://github.com/google/keep-sorted"
-    ]
+    matching = [repo for repo in pre_commit["repos"] if repo.get("repo") == repo_url]
     assert len(matching) == 1
-    entry = matching[0]
-    rev = entry["rev"]
-    hook_ids = [hook["id"] for hook in entry["hooks"]]
-    assert hook_ids == ["keep-sorted"]
+    rev = matching[0]["rev"]
+    assert [hook["id"] for hook in matching[0]["hooks"]] == expected_hooks
 
     ci = yaml.safe_load(
         _repo_file(".github/workflows/ci.yaml").read_text(encoding="utf-8")
     )
-    job = ci["jobs"]["keep_sorted"]
+    job = ci["jobs"][job_id]
     assert job["runs-on"] == "ubuntu-latest"
     steps = job["steps"]
 
-    download_step = next(s for s in steps if s.get("id") == "keep-sorted")
+    download_step = next(s for s in steps if s.get("id") == step_id)
     assert download_step["uses"] == "./.github/actions/download-tool"
-    assert download_step["with"]["name"] == "keep-sorted"
+    assert download_step["with"]["name"] == step_id
+    assert download_step["with"].get("archive-member", "") == archive_member
     urls = [
         line.strip()
         for line in download_step["with"]["urls"].splitlines()
         if line.strip()
     ]
-    assert urls == [
-        f"https://github.com/google/keep-sorted/releases/download/{rev}/keep-sorted_linux_amd64"
-    ]
+    assert urls == [url_template.format(rev=rev)]
     assert len(download_step["with"]["sha256"]) == 64
+    return steps
+
+
+def test_ci_workflow_uses_download_tool_in_sync_with_pre_commit() -> None:
+    steps = _assert_ci_download_step_in_sync(
+        repo_url="https://github.com/google/keep-sorted",
+        expected_hooks=["keep-sorted"],
+        job_id="keep_sorted",
+        step_id="keep-sorted",
+        url_template="https://github.com/google/keep-sorted/releases/download/{rev}/keep-sorted_linux_amd64",
+    )
 
     check_step = next(s for s in steps if s.get("name") == "Check keep-sorted")
     assert check_step["env"] == {
@@ -83,6 +95,39 @@
     )
 
 
+def test_ci_workflow_ifttt_lint_in_sync_with_pre_commit() -> None:
+    steps = _assert_ci_download_step_in_sync(
+        repo_url="https://github.com/simonepri/ifttt-lint",
+        expected_hooks=["ifttt-lint", "ifttt-lint-diff"],
+        job_id="ifttt_lint",
+        step_id="ifttt-lint",
+        url_template="https://github.com/simonepri/ifttt-lint/releases/download/{rev}/ifttt-lint-x86_64-unknown-linux-gnu.tar.gz",
+        archive_member="ifttt-lint",
+    )
+
+    checkout_step = steps[0]
+    assert checkout_step["uses"].startswith("actions/checkout@")
+    assert checkout_step["with"]["fetch-depth"] == 0
+
+    check_step = next(s for s in steps if s.get("name") == "Check ifttt-lint")
+    assert check_step["env"] == {
+        "IFTTT_LINT_BIN": "${{ steps.ifttt-lint.outputs.path }}"
+    }
+    assert check_step["run"] == "\"$IFTTT_LINT_BIN\" --vcs git '**/*'"
+
+    diff_step = next(s for s in steps if s.get("name") == "Check ifttt-lint diff")
+    assert diff_step["if"] == "github.event_name == 'pull_request'"
+    assert diff_step["env"] == {
+        "BASE_SHA": "${{ github.event.pull_request.base.sha }}",
+        "HEAD_SHA": "${{ github.event.pull_request.head.sha }}",
+        "IFTTT_LINT_BIN": "${{ steps.ifttt-lint.outputs.path }}",
+    }
+    assert (
+        diff_step["run"]
+        == '"$IFTTT_LINT_BIN" --vcs git --diff "${BASE_SHA}...${HEAD_SHA}"'
+    )
+
+
 def test_lint_files_formats_findings_across_concurrent_batches(
     tmp_path: Path, monkeypatch: pytest.MonkeyPatch
 ) -> None:
diff --git a/tests/workflows/verified_download_test.py b/tests/workflows/verified_download_test.py
index 5b279e2..e64fa10 100644
--- a/tests/workflows/verified_download_test.py
+++ b/tests/workflows/verified_download_test.py
@@ -39,7 +39,8 @@
     assert bool(script.stat().st_mode & stat.S_IXUSR)
 
     action = yaml.safe_load((action_dir / "action.yaml").read_text(encoding="utf-8"))
-    assert set(action["inputs"].keys()) == {"name", "urls", "sha256"}
+    assert set(action["inputs"].keys()) == {"name", "urls", "sha256", "archive-member"}
+    assert action["inputs"]["archive-member"]["default"] == ""
     assert action["outputs"]["path"]["value"] == "${{ steps.tool_path.outputs.path }}"
 
     steps = action["runs"]["steps"]
@@ -55,8 +56,10 @@
         s for s in steps if s.get("name") == "Download and verify tool"
     )
     assert download_step["if"] == "steps.cache.outputs.cache-hit != 'true'"
+    assert download_step["env"]["TOOL_ARCHIVE_MEMBER"] == "${{ inputs.archive-member }}"
     assert download_step["env"]["TOOL_URLS"] == "${{ inputs.urls }}"
     assert '--urls "${TOOL_URLS}"' in download_step["run"]
+    assert '--archive-member "${TOOL_ARCHIVE_MEMBER}"' in download_step["run"]
     assert "dev/verified_download.py" in download_step["run"]
 
 
@@ -102,6 +105,27 @@
     assert f"::warning::Failed to fetch {missing_a}" in out
     assert f"::warning::Failed to fetch {missing_b}" in out
 
+    tgz_path = tmp_path / "tool.tar.gz"
+    with tarfile.open(tgz_path, "w:gz") as tar:
+        tar.add(src_file, arcname="tool")
+    archive_dest = tmp_path / "out" / "archive-tool"
+    rc = verified_download.main(
+        [
+            "--urls",
+            tgz_path.as_uri(),
+            "--sha256",
+            _sha256(payload),
+            "--archive-member",
+            "tool",
+            "--executable",
+            "--output",
+            str(archive_dest),
+        ]
+    )
+    assert rc == 0
+    assert archive_dest.read_bytes() == payload
+    assert bool(archive_dest.stat().st_mode & stat.S_IXUSR)
+
 
 def test_falls_back_to_tgz_and_zip_archive_tuples_and_reuses_cached_file(
     tmp_path: Path, capsys: pytest.CaptureFixture[str]
diff --git a/version.bzl b/version.bzl
index 4d85b5c..d48f48b 100644
--- a/version.bzl
+++ b/version.bzl
@@ -15,25 +15,26 @@
 
 # Currently used Bazel version. This version is what the rules here are tested
 # against.
-# This version should be updated together with the version of Bazel
-# in .bazelversion.
+# LINT.IfChange(bazel_version)
 BAZEL_VERSION = "8.x"
+# LINT.ThenChange(//.bazelversion)
 
-# NOTE: Keep in sync with .bazelci/presubmit.yml
 # This is the minimum supported bazel version, that we have some tests for.
+# LINT.IfChange(minimum_bazel_version)
 MINIMUM_BAZEL_VERSION = "7.4.1"
+# LINT.ThenChange(//.bazelci/presubmit.yml:minimum_supported_version)
 
 # Versions of Bazel which users should be able to use.
 # Ensures we don't break backwards-compatibility,
 # accidentally forcing users to update their LTS-supported bazel.
 # These are the versions used when testing nested workspaces with
 # rules_bazel_integration_test.
-#
-# Keep in sync with MODULE.bazel's bazel_binaries config
+# LINT.IfChange(supported_bazel_versions)
 SUPPORTED_BAZEL_VERSIONS = [
     BAZEL_VERSION,
     MINIMUM_BAZEL_VERSION,
 ]
+# LINT.ThenChange(//MODULE.bazel:bazel_binaries)
 
 def bazel_version_to_binary_label(version):
     return "@build_bazel_bazel_%s//:bazel_binary" % version.replace(".", "_")