[lkgr] Add a 'last known good revision' recipe

This recipe forwards a ref to the last known good revision (LKGR). The
LKGR is the last revision that had successful builds on a configured
set of builders.

https://github.com/dart-lang/sdk/issues/42917

Change-Id: Ic7cccfe3c46e341bba293b1a667bdaaa92107bc6
Reviewed-on: https://dart-review.googlesource.com/c/recipes/+/157501
Commit-Queue: Alexander Thomas <athom@google.com>
Reviewed-by: William Hesse <whesse@google.com>
diff --git a/README.recipes.md b/README.recipes.md
index 8d235eb..90ee9d5 100644
--- a/README.recipes.md
+++ b/README.recipes.md
@@ -21,6 +21,7 @@
   * [dart:examples/example](#recipes-dart_examples_example)
   * [dart:examples/example-get_secret](#recipes-dart_examples_example-get_secret)
   * [presubmit/presubmit](#recipes-presubmit_presubmit)
+  * [roller/lkgr](#recipes-roller_lkgr)
 ## Recipe Modules
 
 ### *recipe_modules* / [bisect\_build](/recipe_modules/bisect_build)
@@ -204,6 +205,11 @@
 [DEPS](/recipes/presubmit/presubmit.py#10): [depot\_tools/gclient][depot_tools/recipe_modules/gclient], [depot\_tools/presubmit][depot_tools/recipe_modules/presubmit], [depot\_tools/tryserver][depot_tools/recipe_modules/tryserver], [recipe\_engine/buildbucket][recipe_engine/recipe_modules/buildbucket], [recipe\_engine/context][recipe_engine/recipe_modules/context], [recipe\_engine/json][recipe_engine/recipe_modules/json], [recipe\_engine/path][recipe_engine/recipe_modules/path]
 
 &mdash; **def [RunSteps](/recipes/presubmit/presubmit.py#21)(api):**
+### *recipes* / [roller/lkgr](/recipes/roller/lkgr.py)
+
+[DEPS](/recipes/roller/lkgr.py#17): [depot\_tools/bot\_update][depot_tools/recipe_modules/bot_update], [depot\_tools/gclient][depot_tools/recipe_modules/gclient], [depot\_tools/git][depot_tools/recipe_modules/git], [recipe\_engine/context][recipe_engine/recipe_modules/context], [recipe\_engine/json][recipe_engine/recipe_modules/json], [recipe\_engine/path][recipe_engine/recipe_modules/path], [recipe\_engine/properties][recipe_engine/recipe_modules/properties], [recipe\_engine/raw\_io][recipe_engine/recipe_modules/raw_io], [recipe\_engine/runtime][recipe_engine/recipe_modules/runtime], [recipe\_engine/step][recipe_engine/recipe_modules/step]
+
+&mdash; **def [RunSteps](/recipes/roller/lkgr.py#33)(api, properties):**
 
 [build/recipe_modules/goma]: https://chromium.googlesource.com/chromium/tools/build.git/+/ef467ff44548c79ab45d4cc910427d2a176abae6/scripts/slave/README.recipes.md#recipe_modules-goma
 [depot_tools/recipe_modules/bot_update]: https://chromium.googlesource.com/chromium/tools/depot_tools.git/+/8351dc1fb7c4eb1858096c51ffe3f45754877cb0/recipes/README.recipes.md#recipe_modules-bot_update
diff --git a/recipes/roller/lkgr.expected/error.json b/recipes/roller/lkgr.expected/error.json
new file mode 100644
index 0000000..fcf1b24
--- /dev/null
+++ b/recipes/roller/lkgr.expected/error.json
@@ -0,0 +1,95 @@
+[
+  {
+    "cmd": [
+      "bb",
+      "batch"
+    ],
+    "infra_step": true,
+    "name": "search for good builds",
+    "stdin": "{\"requests\": [{\"searchBuilds\": {\"fields\": \"builds.*.output.properties,builds.*.builder\", \"predicate\": {\"builder\": {\"bucket\": \"ci\", \"builder\": \"a\", \"project\": \"dart\"}, \"status\": \"SUCCESS\", \"tags\": [{\"key\": \"user_agent\", \"value\": \"luci-scheduler\"}]}}}, {\"searchBuilds\": {\"fields\": \"builds.*.output.properties,builds.*.builder\", \"predicate\": {\"builder\": {\"bucket\": \"ci\", \"builder\": \"b\", \"project\": \"dart\"}, \"status\": \"SUCCESS\", \"tags\": [{\"key\": \"user_agent\", \"value\": \"luci-scheduler\"}]}}}, {\"searchBuilds\": {\"fields\": \"builds.*.output.properties,builds.*.builder\", \"predicate\": {\"builder\": {\"bucket\": \"ci\", \"builder\": \"c\", \"project\": \"dart\"}, \"status\": \"SUCCESS\", \"tags\": [{\"key\": \"user_agent\", \"value\": \"luci-scheduler\"}]}}}]}",
+    "timeout": 300,
+    "~followup_annotations": [
+      "@@@STEP_TEXT@Request #0<br>Status code: 1<br>Message: search failed<br>@@@",
+      "@@@STEP_LOG_LINE@json.output[response]@{@@@",
+      "@@@STEP_LOG_LINE@json.output[response]@  \"responses\": [@@@",
+      "@@@STEP_LOG_LINE@json.output[response]@    {@@@",
+      "@@@STEP_LOG_LINE@json.output[response]@      \"error\": {@@@",
+      "@@@STEP_LOG_LINE@json.output[response]@        \"code\": 1, @@@",
+      "@@@STEP_LOG_LINE@json.output[response]@        \"message\": \"search failed\"@@@",
+      "@@@STEP_LOG_LINE@json.output[response]@      }@@@",
+      "@@@STEP_LOG_LINE@json.output[response]@    }@@@",
+      "@@@STEP_LOG_LINE@json.output[response]@  ]@@@",
+      "@@@STEP_LOG_LINE@json.output[response]@}@@@",
+      "@@@STEP_LOG_END@json.output[response]@@@",
+      "@@@STEP_LOG_LINE@request@{@@@",
+      "@@@STEP_LOG_LINE@request@  \"requests\": [@@@",
+      "@@@STEP_LOG_LINE@request@    {@@@",
+      "@@@STEP_LOG_LINE@request@      \"searchBuilds\": {@@@",
+      "@@@STEP_LOG_LINE@request@        \"fields\": \"builds.*.output.properties,builds.*.builder\", @@@",
+      "@@@STEP_LOG_LINE@request@        \"predicate\": {@@@",
+      "@@@STEP_LOG_LINE@request@          \"builder\": {@@@",
+      "@@@STEP_LOG_LINE@request@            \"bucket\": \"ci\", @@@",
+      "@@@STEP_LOG_LINE@request@            \"builder\": \"a\", @@@",
+      "@@@STEP_LOG_LINE@request@            \"project\": \"dart\"@@@",
+      "@@@STEP_LOG_LINE@request@          }, @@@",
+      "@@@STEP_LOG_LINE@request@          \"status\": \"SUCCESS\", @@@",
+      "@@@STEP_LOG_LINE@request@          \"tags\": [@@@",
+      "@@@STEP_LOG_LINE@request@            {@@@",
+      "@@@STEP_LOG_LINE@request@              \"key\": \"user_agent\", @@@",
+      "@@@STEP_LOG_LINE@request@              \"value\": \"luci-scheduler\"@@@",
+      "@@@STEP_LOG_LINE@request@            }@@@",
+      "@@@STEP_LOG_LINE@request@          ]@@@",
+      "@@@STEP_LOG_LINE@request@        }@@@",
+      "@@@STEP_LOG_LINE@request@      }@@@",
+      "@@@STEP_LOG_LINE@request@    }, @@@",
+      "@@@STEP_LOG_LINE@request@    {@@@",
+      "@@@STEP_LOG_LINE@request@      \"searchBuilds\": {@@@",
+      "@@@STEP_LOG_LINE@request@        \"fields\": \"builds.*.output.properties,builds.*.builder\", @@@",
+      "@@@STEP_LOG_LINE@request@        \"predicate\": {@@@",
+      "@@@STEP_LOG_LINE@request@          \"builder\": {@@@",
+      "@@@STEP_LOG_LINE@request@            \"bucket\": \"ci\", @@@",
+      "@@@STEP_LOG_LINE@request@            \"builder\": \"b\", @@@",
+      "@@@STEP_LOG_LINE@request@            \"project\": \"dart\"@@@",
+      "@@@STEP_LOG_LINE@request@          }, @@@",
+      "@@@STEP_LOG_LINE@request@          \"status\": \"SUCCESS\", @@@",
+      "@@@STEP_LOG_LINE@request@          \"tags\": [@@@",
+      "@@@STEP_LOG_LINE@request@            {@@@",
+      "@@@STEP_LOG_LINE@request@              \"key\": \"user_agent\", @@@",
+      "@@@STEP_LOG_LINE@request@              \"value\": \"luci-scheduler\"@@@",
+      "@@@STEP_LOG_LINE@request@            }@@@",
+      "@@@STEP_LOG_LINE@request@          ]@@@",
+      "@@@STEP_LOG_LINE@request@        }@@@",
+      "@@@STEP_LOG_LINE@request@      }@@@",
+      "@@@STEP_LOG_LINE@request@    }, @@@",
+      "@@@STEP_LOG_LINE@request@    {@@@",
+      "@@@STEP_LOG_LINE@request@      \"searchBuilds\": {@@@",
+      "@@@STEP_LOG_LINE@request@        \"fields\": \"builds.*.output.properties,builds.*.builder\", @@@",
+      "@@@STEP_LOG_LINE@request@        \"predicate\": {@@@",
+      "@@@STEP_LOG_LINE@request@          \"builder\": {@@@",
+      "@@@STEP_LOG_LINE@request@            \"bucket\": \"ci\", @@@",
+      "@@@STEP_LOG_LINE@request@            \"builder\": \"c\", @@@",
+      "@@@STEP_LOG_LINE@request@            \"project\": \"dart\"@@@",
+      "@@@STEP_LOG_LINE@request@          }, @@@",
+      "@@@STEP_LOG_LINE@request@          \"status\": \"SUCCESS\", @@@",
+      "@@@STEP_LOG_LINE@request@          \"tags\": [@@@",
+      "@@@STEP_LOG_LINE@request@            {@@@",
+      "@@@STEP_LOG_LINE@request@              \"key\": \"user_agent\", @@@",
+      "@@@STEP_LOG_LINE@request@              \"value\": \"luci-scheduler\"@@@",
+      "@@@STEP_LOG_LINE@request@            }@@@",
+      "@@@STEP_LOG_LINE@request@          ]@@@",
+      "@@@STEP_LOG_LINE@request@        }@@@",
+      "@@@STEP_LOG_LINE@request@      }@@@",
+      "@@@STEP_LOG_LINE@request@    }@@@",
+      "@@@STEP_LOG_LINE@request@  ]@@@",
+      "@@@STEP_LOG_LINE@request@}@@@",
+      "@@@STEP_LOG_END@request@@@",
+      "@@@STEP_EXCEPTION@@@"
+    ]
+  },
+  {
+    "failure": {
+      "humanReason": "Infra Failure: Step('search for good builds') (retcode: 1)"
+    },
+    "name": "$result"
+  }
+]
\ No newline at end of file
diff --git a/recipes/roller/lkgr.expected/no-builds.json b/recipes/roller/lkgr.expected/no-builds.json
new file mode 100644
index 0000000..8d1cae2
--- /dev/null
+++ b/recipes/roller/lkgr.expected/no-builds.json
@@ -0,0 +1,95 @@
+[
+  {
+    "cmd": [
+      "bb",
+      "batch"
+    ],
+    "infra_step": true,
+    "name": "search for good builds",
+    "stdin": "{\"requests\": [{\"searchBuilds\": {\"fields\": \"builds.*.output.properties,builds.*.builder\", \"predicate\": {\"builder\": {\"bucket\": \"ci\", \"builder\": \"a\", \"project\": \"dart\"}, \"status\": \"SUCCESS\", \"tags\": [{\"key\": \"user_agent\", \"value\": \"luci-scheduler\"}]}}}, {\"searchBuilds\": {\"fields\": \"builds.*.output.properties,builds.*.builder\", \"predicate\": {\"builder\": {\"bucket\": \"ci\", \"builder\": \"b\", \"project\": \"dart\"}, \"status\": \"SUCCESS\", \"tags\": [{\"key\": \"user_agent\", \"value\": \"luci-scheduler\"}]}}}, {\"searchBuilds\": {\"fields\": \"builds.*.output.properties,builds.*.builder\", \"predicate\": {\"builder\": {\"bucket\": \"ci\", \"builder\": \"c\", \"project\": \"dart\"}, \"status\": \"SUCCESS\", \"tags\": [{\"key\": \"user_agent\", \"value\": \"luci-scheduler\"}]}}}]}",
+    "timeout": 300,
+    "~followup_annotations": [
+      "@@@STEP_LOG_LINE@json.output[response]@{@@@",
+      "@@@STEP_LOG_LINE@json.output[response]@  \"responses\": [@@@",
+      "@@@STEP_LOG_LINE@json.output[response]@    {@@@",
+      "@@@STEP_LOG_LINE@json.output[response]@      \"searchBuilds\": {}@@@",
+      "@@@STEP_LOG_LINE@json.output[response]@    }@@@",
+      "@@@STEP_LOG_LINE@json.output[response]@  ]@@@",
+      "@@@STEP_LOG_LINE@json.output[response]@}@@@",
+      "@@@STEP_LOG_END@json.output[response]@@@",
+      "@@@STEP_LOG_LINE@request@{@@@",
+      "@@@STEP_LOG_LINE@request@  \"requests\": [@@@",
+      "@@@STEP_LOG_LINE@request@    {@@@",
+      "@@@STEP_LOG_LINE@request@      \"searchBuilds\": {@@@",
+      "@@@STEP_LOG_LINE@request@        \"fields\": \"builds.*.output.properties,builds.*.builder\", @@@",
+      "@@@STEP_LOG_LINE@request@        \"predicate\": {@@@",
+      "@@@STEP_LOG_LINE@request@          \"builder\": {@@@",
+      "@@@STEP_LOG_LINE@request@            \"bucket\": \"ci\", @@@",
+      "@@@STEP_LOG_LINE@request@            \"builder\": \"a\", @@@",
+      "@@@STEP_LOG_LINE@request@            \"project\": \"dart\"@@@",
+      "@@@STEP_LOG_LINE@request@          }, @@@",
+      "@@@STEP_LOG_LINE@request@          \"status\": \"SUCCESS\", @@@",
+      "@@@STEP_LOG_LINE@request@          \"tags\": [@@@",
+      "@@@STEP_LOG_LINE@request@            {@@@",
+      "@@@STEP_LOG_LINE@request@              \"key\": \"user_agent\", @@@",
+      "@@@STEP_LOG_LINE@request@              \"value\": \"luci-scheduler\"@@@",
+      "@@@STEP_LOG_LINE@request@            }@@@",
+      "@@@STEP_LOG_LINE@request@          ]@@@",
+      "@@@STEP_LOG_LINE@request@        }@@@",
+      "@@@STEP_LOG_LINE@request@      }@@@",
+      "@@@STEP_LOG_LINE@request@    }, @@@",
+      "@@@STEP_LOG_LINE@request@    {@@@",
+      "@@@STEP_LOG_LINE@request@      \"searchBuilds\": {@@@",
+      "@@@STEP_LOG_LINE@request@        \"fields\": \"builds.*.output.properties,builds.*.builder\", @@@",
+      "@@@STEP_LOG_LINE@request@        \"predicate\": {@@@",
+      "@@@STEP_LOG_LINE@request@          \"builder\": {@@@",
+      "@@@STEP_LOG_LINE@request@            \"bucket\": \"ci\", @@@",
+      "@@@STEP_LOG_LINE@request@            \"builder\": \"b\", @@@",
+      "@@@STEP_LOG_LINE@request@            \"project\": \"dart\"@@@",
+      "@@@STEP_LOG_LINE@request@          }, @@@",
+      "@@@STEP_LOG_LINE@request@          \"status\": \"SUCCESS\", @@@",
+      "@@@STEP_LOG_LINE@request@          \"tags\": [@@@",
+      "@@@STEP_LOG_LINE@request@            {@@@",
+      "@@@STEP_LOG_LINE@request@              \"key\": \"user_agent\", @@@",
+      "@@@STEP_LOG_LINE@request@              \"value\": \"luci-scheduler\"@@@",
+      "@@@STEP_LOG_LINE@request@            }@@@",
+      "@@@STEP_LOG_LINE@request@          ]@@@",
+      "@@@STEP_LOG_LINE@request@        }@@@",
+      "@@@STEP_LOG_LINE@request@      }@@@",
+      "@@@STEP_LOG_LINE@request@    }, @@@",
+      "@@@STEP_LOG_LINE@request@    {@@@",
+      "@@@STEP_LOG_LINE@request@      \"searchBuilds\": {@@@",
+      "@@@STEP_LOG_LINE@request@        \"fields\": \"builds.*.output.properties,builds.*.builder\", @@@",
+      "@@@STEP_LOG_LINE@request@        \"predicate\": {@@@",
+      "@@@STEP_LOG_LINE@request@          \"builder\": {@@@",
+      "@@@STEP_LOG_LINE@request@            \"bucket\": \"ci\", @@@",
+      "@@@STEP_LOG_LINE@request@            \"builder\": \"c\", @@@",
+      "@@@STEP_LOG_LINE@request@            \"project\": \"dart\"@@@",
+      "@@@STEP_LOG_LINE@request@          }, @@@",
+      "@@@STEP_LOG_LINE@request@          \"status\": \"SUCCESS\", @@@",
+      "@@@STEP_LOG_LINE@request@          \"tags\": [@@@",
+      "@@@STEP_LOG_LINE@request@            {@@@",
+      "@@@STEP_LOG_LINE@request@              \"key\": \"user_agent\", @@@",
+      "@@@STEP_LOG_LINE@request@              \"value\": \"luci-scheduler\"@@@",
+      "@@@STEP_LOG_LINE@request@            }@@@",
+      "@@@STEP_LOG_LINE@request@          ]@@@",
+      "@@@STEP_LOG_LINE@request@        }@@@",
+      "@@@STEP_LOG_LINE@request@      }@@@",
+      "@@@STEP_LOG_LINE@request@    }@@@",
+      "@@@STEP_LOG_LINE@request@  ]@@@",
+      "@@@STEP_LOG_LINE@request@}@@@",
+      "@@@STEP_LOG_END@request@@@"
+    ]
+  },
+  {
+    "cmd": [],
+    "name": "no good hashes found",
+    "~followup_annotations": [
+      "@@@STEP_LOG_LINE@builds@{}@@@",
+      "@@@STEP_LOG_END@builds@@@"
+    ]
+  },
+  {
+    "name": "$result"
+  }
+]
\ No newline at end of file
diff --git a/recipes/roller/lkgr.expected/no-intersection.json b/recipes/roller/lkgr.expected/no-intersection.json
new file mode 100644
index 0000000..a468205
--- /dev/null
+++ b/recipes/roller/lkgr.expected/no-intersection.json
@@ -0,0 +1,221 @@
+[
+  {
+    "cmd": [
+      "bb",
+      "batch"
+    ],
+    "infra_step": true,
+    "name": "search for good builds",
+    "stdin": "{\"requests\": [{\"searchBuilds\": {\"fields\": \"builds.*.output.properties,builds.*.builder\", \"predicate\": {\"builder\": {\"bucket\": \"ci\", \"builder\": \"a\", \"project\": \"dart\"}, \"status\": \"SUCCESS\", \"tags\": [{\"key\": \"user_agent\", \"value\": \"luci-scheduler\"}]}}}, {\"searchBuilds\": {\"fields\": \"builds.*.output.properties,builds.*.builder\", \"predicate\": {\"builder\": {\"bucket\": \"ci\", \"builder\": \"b\", \"project\": \"dart\"}, \"status\": \"SUCCESS\", \"tags\": [{\"key\": \"user_agent\", \"value\": \"luci-scheduler\"}]}}}, {\"searchBuilds\": {\"fields\": \"builds.*.output.properties,builds.*.builder\", \"predicate\": {\"builder\": {\"bucket\": \"ci\", \"builder\": \"c\", \"project\": \"dart\"}, \"status\": \"SUCCESS\", \"tags\": [{\"key\": \"user_agent\", \"value\": \"luci-scheduler\"}]}}}]}",
+    "timeout": 300,
+    "~followup_annotations": [
+      "@@@STEP_LOG_LINE@json.output[response]@{@@@",
+      "@@@STEP_LOG_LINE@json.output[response]@  \"responses\": [@@@",
+      "@@@STEP_LOG_LINE@json.output[response]@    {@@@",
+      "@@@STEP_LOG_LINE@json.output[response]@      \"searchBuilds\": {@@@",
+      "@@@STEP_LOG_LINE@json.output[response]@        \"builds\": [@@@",
+      "@@@STEP_LOG_LINE@json.output[response]@          {@@@",
+      "@@@STEP_LOG_LINE@json.output[response]@            \"builder\": {@@@",
+      "@@@STEP_LOG_LINE@json.output[response]@              \"bucket\": \"ci\", @@@",
+      "@@@STEP_LOG_LINE@json.output[response]@              \"builder\": \"a\", @@@",
+      "@@@STEP_LOG_LINE@json.output[response]@              \"project\": \"dart\"@@@",
+      "@@@STEP_LOG_LINE@json.output[response]@            }, @@@",
+      "@@@STEP_LOG_LINE@json.output[response]@            \"output\": {@@@",
+      "@@@STEP_LOG_LINE@json.output[response]@              \"properties\": {@@@",
+      "@@@STEP_LOG_LINE@json.output[response]@                \"got_revision\": \"0\"@@@",
+      "@@@STEP_LOG_LINE@json.output[response]@              }@@@",
+      "@@@STEP_LOG_LINE@json.output[response]@            }@@@",
+      "@@@STEP_LOG_LINE@json.output[response]@          }, @@@",
+      "@@@STEP_LOG_LINE@json.output[response]@          {@@@",
+      "@@@STEP_LOG_LINE@json.output[response]@            \"builder\": {@@@",
+      "@@@STEP_LOG_LINE@json.output[response]@              \"bucket\": \"ci\", @@@",
+      "@@@STEP_LOG_LINE@json.output[response]@              \"builder\": \"a\", @@@",
+      "@@@STEP_LOG_LINE@json.output[response]@              \"project\": \"dart\"@@@",
+      "@@@STEP_LOG_LINE@json.output[response]@            }, @@@",
+      "@@@STEP_LOG_LINE@json.output[response]@            \"output\": {@@@",
+      "@@@STEP_LOG_LINE@json.output[response]@              \"properties\": {@@@",
+      "@@@STEP_LOG_LINE@json.output[response]@                \"got_revision\": \"1\"@@@",
+      "@@@STEP_LOG_LINE@json.output[response]@              }@@@",
+      "@@@STEP_LOG_LINE@json.output[response]@            }@@@",
+      "@@@STEP_LOG_LINE@json.output[response]@          }, @@@",
+      "@@@STEP_LOG_LINE@json.output[response]@          {@@@",
+      "@@@STEP_LOG_LINE@json.output[response]@            \"builder\": {@@@",
+      "@@@STEP_LOG_LINE@json.output[response]@              \"bucket\": \"ci\", @@@",
+      "@@@STEP_LOG_LINE@json.output[response]@              \"builder\": \"a\", @@@",
+      "@@@STEP_LOG_LINE@json.output[response]@              \"project\": \"dart\"@@@",
+      "@@@STEP_LOG_LINE@json.output[response]@            }, @@@",
+      "@@@STEP_LOG_LINE@json.output[response]@            \"output\": {@@@",
+      "@@@STEP_LOG_LINE@json.output[response]@              \"properties\": {@@@",
+      "@@@STEP_LOG_LINE@json.output[response]@                \"got_revision\": \"4\"@@@",
+      "@@@STEP_LOG_LINE@json.output[response]@              }@@@",
+      "@@@STEP_LOG_LINE@json.output[response]@            }@@@",
+      "@@@STEP_LOG_LINE@json.output[response]@          }@@@",
+      "@@@STEP_LOG_LINE@json.output[response]@        ]@@@",
+      "@@@STEP_LOG_LINE@json.output[response]@      }@@@",
+      "@@@STEP_LOG_LINE@json.output[response]@    }, @@@",
+      "@@@STEP_LOG_LINE@json.output[response]@    {@@@",
+      "@@@STEP_LOG_LINE@json.output[response]@      \"searchBuilds\": {@@@",
+      "@@@STEP_LOG_LINE@json.output[response]@        \"builds\": [@@@",
+      "@@@STEP_LOG_LINE@json.output[response]@          {@@@",
+      "@@@STEP_LOG_LINE@json.output[response]@            \"builder\": {@@@",
+      "@@@STEP_LOG_LINE@json.output[response]@              \"bucket\": \"ci\", @@@",
+      "@@@STEP_LOG_LINE@json.output[response]@              \"builder\": \"b\", @@@",
+      "@@@STEP_LOG_LINE@json.output[response]@              \"project\": \"dart\"@@@",
+      "@@@STEP_LOG_LINE@json.output[response]@            }, @@@",
+      "@@@STEP_LOG_LINE@json.output[response]@            \"output\": {@@@",
+      "@@@STEP_LOG_LINE@json.output[response]@              \"properties\": {@@@",
+      "@@@STEP_LOG_LINE@json.output[response]@                \"got_revision\": \"1\"@@@",
+      "@@@STEP_LOG_LINE@json.output[response]@              }@@@",
+      "@@@STEP_LOG_LINE@json.output[response]@            }@@@",
+      "@@@STEP_LOG_LINE@json.output[response]@          }, @@@",
+      "@@@STEP_LOG_LINE@json.output[response]@          {@@@",
+      "@@@STEP_LOG_LINE@json.output[response]@            \"builder\": {@@@",
+      "@@@STEP_LOG_LINE@json.output[response]@              \"bucket\": \"ci\", @@@",
+      "@@@STEP_LOG_LINE@json.output[response]@              \"builder\": \"b\", @@@",
+      "@@@STEP_LOG_LINE@json.output[response]@              \"project\": \"dart\"@@@",
+      "@@@STEP_LOG_LINE@json.output[response]@            }, @@@",
+      "@@@STEP_LOG_LINE@json.output[response]@            \"output\": {@@@",
+      "@@@STEP_LOG_LINE@json.output[response]@              \"properties\": {@@@",
+      "@@@STEP_LOG_LINE@json.output[response]@                \"got_revision\": \"2\"@@@",
+      "@@@STEP_LOG_LINE@json.output[response]@              }@@@",
+      "@@@STEP_LOG_LINE@json.output[response]@            }@@@",
+      "@@@STEP_LOG_LINE@json.output[response]@          }, @@@",
+      "@@@STEP_LOG_LINE@json.output[response]@          {@@@",
+      "@@@STEP_LOG_LINE@json.output[response]@            \"builder\": {@@@",
+      "@@@STEP_LOG_LINE@json.output[response]@              \"bucket\": \"ci\", @@@",
+      "@@@STEP_LOG_LINE@json.output[response]@              \"builder\": \"b\", @@@",
+      "@@@STEP_LOG_LINE@json.output[response]@              \"project\": \"dart\"@@@",
+      "@@@STEP_LOG_LINE@json.output[response]@            }, @@@",
+      "@@@STEP_LOG_LINE@json.output[response]@            \"output\": {@@@",
+      "@@@STEP_LOG_LINE@json.output[response]@              \"properties\": {@@@",
+      "@@@STEP_LOG_LINE@json.output[response]@                \"got_revision\": \"3\"@@@",
+      "@@@STEP_LOG_LINE@json.output[response]@              }@@@",
+      "@@@STEP_LOG_LINE@json.output[response]@            }@@@",
+      "@@@STEP_LOG_LINE@json.output[response]@          }@@@",
+      "@@@STEP_LOG_LINE@json.output[response]@        ]@@@",
+      "@@@STEP_LOG_LINE@json.output[response]@      }@@@",
+      "@@@STEP_LOG_LINE@json.output[response]@    }, @@@",
+      "@@@STEP_LOG_LINE@json.output[response]@    {@@@",
+      "@@@STEP_LOG_LINE@json.output[response]@      \"searchBuilds\": {@@@",
+      "@@@STEP_LOG_LINE@json.output[response]@        \"builds\": [@@@",
+      "@@@STEP_LOG_LINE@json.output[response]@          {@@@",
+      "@@@STEP_LOG_LINE@json.output[response]@            \"builder\": {@@@",
+      "@@@STEP_LOG_LINE@json.output[response]@              \"bucket\": \"ci\", @@@",
+      "@@@STEP_LOG_LINE@json.output[response]@              \"builder\": \"c\", @@@",
+      "@@@STEP_LOG_LINE@json.output[response]@              \"project\": \"dart\"@@@",
+      "@@@STEP_LOG_LINE@json.output[response]@            }, @@@",
+      "@@@STEP_LOG_LINE@json.output[response]@            \"output\": {@@@",
+      "@@@STEP_LOG_LINE@json.output[response]@              \"properties\": {@@@",
+      "@@@STEP_LOG_LINE@json.output[response]@                \"got_revision\": \"2\"@@@",
+      "@@@STEP_LOG_LINE@json.output[response]@              }@@@",
+      "@@@STEP_LOG_LINE@json.output[response]@            }@@@",
+      "@@@STEP_LOG_LINE@json.output[response]@          }, @@@",
+      "@@@STEP_LOG_LINE@json.output[response]@          {@@@",
+      "@@@STEP_LOG_LINE@json.output[response]@            \"builder\": {@@@",
+      "@@@STEP_LOG_LINE@json.output[response]@              \"bucket\": \"ci\", @@@",
+      "@@@STEP_LOG_LINE@json.output[response]@              \"builder\": \"c\", @@@",
+      "@@@STEP_LOG_LINE@json.output[response]@              \"project\": \"dart\"@@@",
+      "@@@STEP_LOG_LINE@json.output[response]@            }, @@@",
+      "@@@STEP_LOG_LINE@json.output[response]@            \"output\": {@@@",
+      "@@@STEP_LOG_LINE@json.output[response]@              \"properties\": {@@@",
+      "@@@STEP_LOG_LINE@json.output[response]@                \"got_revision\": \"3\"@@@",
+      "@@@STEP_LOG_LINE@json.output[response]@              }@@@",
+      "@@@STEP_LOG_LINE@json.output[response]@            }@@@",
+      "@@@STEP_LOG_LINE@json.output[response]@          }@@@",
+      "@@@STEP_LOG_LINE@json.output[response]@        ]@@@",
+      "@@@STEP_LOG_LINE@json.output[response]@      }@@@",
+      "@@@STEP_LOG_LINE@json.output[response]@    }@@@",
+      "@@@STEP_LOG_LINE@json.output[response]@  ]@@@",
+      "@@@STEP_LOG_LINE@json.output[response]@}@@@",
+      "@@@STEP_LOG_END@json.output[response]@@@",
+      "@@@STEP_LOG_LINE@request@{@@@",
+      "@@@STEP_LOG_LINE@request@  \"requests\": [@@@",
+      "@@@STEP_LOG_LINE@request@    {@@@",
+      "@@@STEP_LOG_LINE@request@      \"searchBuilds\": {@@@",
+      "@@@STEP_LOG_LINE@request@        \"fields\": \"builds.*.output.properties,builds.*.builder\", @@@",
+      "@@@STEP_LOG_LINE@request@        \"predicate\": {@@@",
+      "@@@STEP_LOG_LINE@request@          \"builder\": {@@@",
+      "@@@STEP_LOG_LINE@request@            \"bucket\": \"ci\", @@@",
+      "@@@STEP_LOG_LINE@request@            \"builder\": \"a\", @@@",
+      "@@@STEP_LOG_LINE@request@            \"project\": \"dart\"@@@",
+      "@@@STEP_LOG_LINE@request@          }, @@@",
+      "@@@STEP_LOG_LINE@request@          \"status\": \"SUCCESS\", @@@",
+      "@@@STEP_LOG_LINE@request@          \"tags\": [@@@",
+      "@@@STEP_LOG_LINE@request@            {@@@",
+      "@@@STEP_LOG_LINE@request@              \"key\": \"user_agent\", @@@",
+      "@@@STEP_LOG_LINE@request@              \"value\": \"luci-scheduler\"@@@",
+      "@@@STEP_LOG_LINE@request@            }@@@",
+      "@@@STEP_LOG_LINE@request@          ]@@@",
+      "@@@STEP_LOG_LINE@request@        }@@@",
+      "@@@STEP_LOG_LINE@request@      }@@@",
+      "@@@STEP_LOG_LINE@request@    }, @@@",
+      "@@@STEP_LOG_LINE@request@    {@@@",
+      "@@@STEP_LOG_LINE@request@      \"searchBuilds\": {@@@",
+      "@@@STEP_LOG_LINE@request@        \"fields\": \"builds.*.output.properties,builds.*.builder\", @@@",
+      "@@@STEP_LOG_LINE@request@        \"predicate\": {@@@",
+      "@@@STEP_LOG_LINE@request@          \"builder\": {@@@",
+      "@@@STEP_LOG_LINE@request@            \"bucket\": \"ci\", @@@",
+      "@@@STEP_LOG_LINE@request@            \"builder\": \"b\", @@@",
+      "@@@STEP_LOG_LINE@request@            \"project\": \"dart\"@@@",
+      "@@@STEP_LOG_LINE@request@          }, @@@",
+      "@@@STEP_LOG_LINE@request@          \"status\": \"SUCCESS\", @@@",
+      "@@@STEP_LOG_LINE@request@          \"tags\": [@@@",
+      "@@@STEP_LOG_LINE@request@            {@@@",
+      "@@@STEP_LOG_LINE@request@              \"key\": \"user_agent\", @@@",
+      "@@@STEP_LOG_LINE@request@              \"value\": \"luci-scheduler\"@@@",
+      "@@@STEP_LOG_LINE@request@            }@@@",
+      "@@@STEP_LOG_LINE@request@          ]@@@",
+      "@@@STEP_LOG_LINE@request@        }@@@",
+      "@@@STEP_LOG_LINE@request@      }@@@",
+      "@@@STEP_LOG_LINE@request@    }, @@@",
+      "@@@STEP_LOG_LINE@request@    {@@@",
+      "@@@STEP_LOG_LINE@request@      \"searchBuilds\": {@@@",
+      "@@@STEP_LOG_LINE@request@        \"fields\": \"builds.*.output.properties,builds.*.builder\", @@@",
+      "@@@STEP_LOG_LINE@request@        \"predicate\": {@@@",
+      "@@@STEP_LOG_LINE@request@          \"builder\": {@@@",
+      "@@@STEP_LOG_LINE@request@            \"bucket\": \"ci\", @@@",
+      "@@@STEP_LOG_LINE@request@            \"builder\": \"c\", @@@",
+      "@@@STEP_LOG_LINE@request@            \"project\": \"dart\"@@@",
+      "@@@STEP_LOG_LINE@request@          }, @@@",
+      "@@@STEP_LOG_LINE@request@          \"status\": \"SUCCESS\", @@@",
+      "@@@STEP_LOG_LINE@request@          \"tags\": [@@@",
+      "@@@STEP_LOG_LINE@request@            {@@@",
+      "@@@STEP_LOG_LINE@request@              \"key\": \"user_agent\", @@@",
+      "@@@STEP_LOG_LINE@request@              \"value\": \"luci-scheduler\"@@@",
+      "@@@STEP_LOG_LINE@request@            }@@@",
+      "@@@STEP_LOG_LINE@request@          ]@@@",
+      "@@@STEP_LOG_LINE@request@        }@@@",
+      "@@@STEP_LOG_LINE@request@      }@@@",
+      "@@@STEP_LOG_LINE@request@    }@@@",
+      "@@@STEP_LOG_LINE@request@  ]@@@",
+      "@@@STEP_LOG_LINE@request@}@@@",
+      "@@@STEP_LOG_END@request@@@"
+    ]
+  },
+  {
+    "cmd": [],
+    "name": "no good hashes found",
+    "~followup_annotations": [
+      "@@@STEP_LOG_LINE@builds@{@@@",
+      "@@@STEP_LOG_LINE@builds@  \"a\": [@@@",
+      "@@@STEP_LOG_LINE@builds@    \"0\", @@@",
+      "@@@STEP_LOG_LINE@builds@    \"1\", @@@",
+      "@@@STEP_LOG_LINE@builds@    \"4\"@@@",
+      "@@@STEP_LOG_LINE@builds@  ], @@@",
+      "@@@STEP_LOG_LINE@builds@  \"b\": [@@@",
+      "@@@STEP_LOG_LINE@builds@    \"1\", @@@",
+      "@@@STEP_LOG_LINE@builds@    \"2\", @@@",
+      "@@@STEP_LOG_LINE@builds@    \"3\"@@@",
+      "@@@STEP_LOG_LINE@builds@  ], @@@",
+      "@@@STEP_LOG_LINE@builds@  \"c\": [@@@",
+      "@@@STEP_LOG_LINE@builds@    \"2\", @@@",
+      "@@@STEP_LOG_LINE@builds@    \"3\"@@@",
+      "@@@STEP_LOG_LINE@builds@  ]@@@",
+      "@@@STEP_LOG_LINE@builds@}@@@",
+      "@@@STEP_LOG_END@builds@@@"
+    ]
+  },
+  {
+    "name": "$result"
+  }
+]
\ No newline at end of file
diff --git a/recipes/roller/lkgr.expected/push.json b/recipes/roller/lkgr.expected/push.json
new file mode 100644
index 0000000..436e7d6
--- /dev/null
+++ b/recipes/roller/lkgr.expected/push.json
@@ -0,0 +1,253 @@
+[
+  {
+    "cmd": [
+      "bb",
+      "batch"
+    ],
+    "infra_step": true,
+    "name": "search for good builds",
+    "stdin": "{\"requests\": [{\"searchBuilds\": {\"fields\": \"builds.*.output.properties,builds.*.builder\", \"predicate\": {\"builder\": {\"bucket\": \"ci\", \"builder\": \"a\", \"project\": \"dart\"}, \"status\": \"SUCCESS\", \"tags\": [{\"key\": \"user_agent\", \"value\": \"luci-scheduler\"}]}}}, {\"searchBuilds\": {\"fields\": \"builds.*.output.properties,builds.*.builder\", \"predicate\": {\"builder\": {\"bucket\": \"ci\", \"builder\": \"b\", \"project\": \"dart\"}, \"status\": \"SUCCESS\", \"tags\": [{\"key\": \"user_agent\", \"value\": \"luci-scheduler\"}]}}}, {\"searchBuilds\": {\"fields\": \"builds.*.output.properties,builds.*.builder\", \"predicate\": {\"builder\": {\"bucket\": \"ci\", \"builder\": \"c\", \"project\": \"dart\"}, \"status\": \"SUCCESS\", \"tags\": [{\"key\": \"user_agent\", \"value\": \"luci-scheduler\"}]}}}]}",
+    "timeout": 300,
+    "~followup_annotations": [
+      "@@@STEP_LOG_LINE@json.output[response]@{@@@",
+      "@@@STEP_LOG_LINE@json.output[response]@  \"responses\": [@@@",
+      "@@@STEP_LOG_LINE@json.output[response]@    {@@@",
+      "@@@STEP_LOG_LINE@json.output[response]@      \"searchBuilds\": {@@@",
+      "@@@STEP_LOG_LINE@json.output[response]@        \"builds\": [@@@",
+      "@@@STEP_LOG_LINE@json.output[response]@          {@@@",
+      "@@@STEP_LOG_LINE@json.output[response]@            \"builder\": {@@@",
+      "@@@STEP_LOG_LINE@json.output[response]@              \"bucket\": \"ci\", @@@",
+      "@@@STEP_LOG_LINE@json.output[response]@              \"builder\": \"a\", @@@",
+      "@@@STEP_LOG_LINE@json.output[response]@              \"project\": \"dart\"@@@",
+      "@@@STEP_LOG_LINE@json.output[response]@            }, @@@",
+      "@@@STEP_LOG_LINE@json.output[response]@            \"output\": {@@@",
+      "@@@STEP_LOG_LINE@json.output[response]@              \"properties\": {@@@",
+      "@@@STEP_LOG_LINE@json.output[response]@                \"got_revision\": \"2\"@@@",
+      "@@@STEP_LOG_LINE@json.output[response]@              }@@@",
+      "@@@STEP_LOG_LINE@json.output[response]@            }@@@",
+      "@@@STEP_LOG_LINE@json.output[response]@          }@@@",
+      "@@@STEP_LOG_LINE@json.output[response]@        ]@@@",
+      "@@@STEP_LOG_LINE@json.output[response]@      }@@@",
+      "@@@STEP_LOG_LINE@json.output[response]@    }, @@@",
+      "@@@STEP_LOG_LINE@json.output[response]@    {@@@",
+      "@@@STEP_LOG_LINE@json.output[response]@      \"searchBuilds\": {@@@",
+      "@@@STEP_LOG_LINE@json.output[response]@        \"builds\": [@@@",
+      "@@@STEP_LOG_LINE@json.output[response]@          {@@@",
+      "@@@STEP_LOG_LINE@json.output[response]@            \"builder\": {@@@",
+      "@@@STEP_LOG_LINE@json.output[response]@              \"bucket\": \"ci\", @@@",
+      "@@@STEP_LOG_LINE@json.output[response]@              \"builder\": \"b\", @@@",
+      "@@@STEP_LOG_LINE@json.output[response]@              \"project\": \"dart\"@@@",
+      "@@@STEP_LOG_LINE@json.output[response]@            }, @@@",
+      "@@@STEP_LOG_LINE@json.output[response]@            \"output\": {@@@",
+      "@@@STEP_LOG_LINE@json.output[response]@              \"properties\": {@@@",
+      "@@@STEP_LOG_LINE@json.output[response]@                \"got_revision\": \"1\"@@@",
+      "@@@STEP_LOG_LINE@json.output[response]@              }@@@",
+      "@@@STEP_LOG_LINE@json.output[response]@            }@@@",
+      "@@@STEP_LOG_LINE@json.output[response]@          }, @@@",
+      "@@@STEP_LOG_LINE@json.output[response]@          {@@@",
+      "@@@STEP_LOG_LINE@json.output[response]@            \"builder\": {@@@",
+      "@@@STEP_LOG_LINE@json.output[response]@              \"bucket\": \"ci\", @@@",
+      "@@@STEP_LOG_LINE@json.output[response]@              \"builder\": \"b\", @@@",
+      "@@@STEP_LOG_LINE@json.output[response]@              \"project\": \"dart\"@@@",
+      "@@@STEP_LOG_LINE@json.output[response]@            }, @@@",
+      "@@@STEP_LOG_LINE@json.output[response]@            \"output\": {@@@",
+      "@@@STEP_LOG_LINE@json.output[response]@              \"properties\": {@@@",
+      "@@@STEP_LOG_LINE@json.output[response]@                \"got_revision\": \"2\"@@@",
+      "@@@STEP_LOG_LINE@json.output[response]@              }@@@",
+      "@@@STEP_LOG_LINE@json.output[response]@            }@@@",
+      "@@@STEP_LOG_LINE@json.output[response]@          }, @@@",
+      "@@@STEP_LOG_LINE@json.output[response]@          {@@@",
+      "@@@STEP_LOG_LINE@json.output[response]@            \"builder\": {@@@",
+      "@@@STEP_LOG_LINE@json.output[response]@              \"bucket\": \"ci\", @@@",
+      "@@@STEP_LOG_LINE@json.output[response]@              \"builder\": \"b\", @@@",
+      "@@@STEP_LOG_LINE@json.output[response]@              \"project\": \"dart\"@@@",
+      "@@@STEP_LOG_LINE@json.output[response]@            }, @@@",
+      "@@@STEP_LOG_LINE@json.output[response]@            \"output\": {@@@",
+      "@@@STEP_LOG_LINE@json.output[response]@              \"properties\": {@@@",
+      "@@@STEP_LOG_LINE@json.output[response]@                \"got_revision\": \"3\"@@@",
+      "@@@STEP_LOG_LINE@json.output[response]@              }@@@",
+      "@@@STEP_LOG_LINE@json.output[response]@            }@@@",
+      "@@@STEP_LOG_LINE@json.output[response]@          }@@@",
+      "@@@STEP_LOG_LINE@json.output[response]@        ]@@@",
+      "@@@STEP_LOG_LINE@json.output[response]@      }@@@",
+      "@@@STEP_LOG_LINE@json.output[response]@    }, @@@",
+      "@@@STEP_LOG_LINE@json.output[response]@    {@@@",
+      "@@@STEP_LOG_LINE@json.output[response]@      \"searchBuilds\": {@@@",
+      "@@@STEP_LOG_LINE@json.output[response]@        \"builds\": [@@@",
+      "@@@STEP_LOG_LINE@json.output[response]@          {@@@",
+      "@@@STEP_LOG_LINE@json.output[response]@            \"builder\": {@@@",
+      "@@@STEP_LOG_LINE@json.output[response]@              \"bucket\": \"ci\", @@@",
+      "@@@STEP_LOG_LINE@json.output[response]@              \"builder\": \"c\", @@@",
+      "@@@STEP_LOG_LINE@json.output[response]@              \"project\": \"dart\"@@@",
+      "@@@STEP_LOG_LINE@json.output[response]@            }, @@@",
+      "@@@STEP_LOG_LINE@json.output[response]@            \"output\": {@@@",
+      "@@@STEP_LOG_LINE@json.output[response]@              \"properties\": {@@@",
+      "@@@STEP_LOG_LINE@json.output[response]@                \"got_revision\": \"2\"@@@",
+      "@@@STEP_LOG_LINE@json.output[response]@              }@@@",
+      "@@@STEP_LOG_LINE@json.output[response]@            }@@@",
+      "@@@STEP_LOG_LINE@json.output[response]@          }, @@@",
+      "@@@STEP_LOG_LINE@json.output[response]@          {@@@",
+      "@@@STEP_LOG_LINE@json.output[response]@            \"builder\": {@@@",
+      "@@@STEP_LOG_LINE@json.output[response]@              \"bucket\": \"ci\", @@@",
+      "@@@STEP_LOG_LINE@json.output[response]@              \"builder\": \"c\", @@@",
+      "@@@STEP_LOG_LINE@json.output[response]@              \"project\": \"dart\"@@@",
+      "@@@STEP_LOG_LINE@json.output[response]@            }, @@@",
+      "@@@STEP_LOG_LINE@json.output[response]@            \"output\": {@@@",
+      "@@@STEP_LOG_LINE@json.output[response]@              \"properties\": {@@@",
+      "@@@STEP_LOG_LINE@json.output[response]@                \"got_revision\": \"3\"@@@",
+      "@@@STEP_LOG_LINE@json.output[response]@              }@@@",
+      "@@@STEP_LOG_LINE@json.output[response]@            }@@@",
+      "@@@STEP_LOG_LINE@json.output[response]@          }@@@",
+      "@@@STEP_LOG_LINE@json.output[response]@        ]@@@",
+      "@@@STEP_LOG_LINE@json.output[response]@      }@@@",
+      "@@@STEP_LOG_LINE@json.output[response]@    }@@@",
+      "@@@STEP_LOG_LINE@json.output[response]@  ]@@@",
+      "@@@STEP_LOG_LINE@json.output[response]@}@@@",
+      "@@@STEP_LOG_END@json.output[response]@@@",
+      "@@@STEP_LOG_LINE@request@{@@@",
+      "@@@STEP_LOG_LINE@request@  \"requests\": [@@@",
+      "@@@STEP_LOG_LINE@request@    {@@@",
+      "@@@STEP_LOG_LINE@request@      \"searchBuilds\": {@@@",
+      "@@@STEP_LOG_LINE@request@        \"fields\": \"builds.*.output.properties,builds.*.builder\", @@@",
+      "@@@STEP_LOG_LINE@request@        \"predicate\": {@@@",
+      "@@@STEP_LOG_LINE@request@          \"builder\": {@@@",
+      "@@@STEP_LOG_LINE@request@            \"bucket\": \"ci\", @@@",
+      "@@@STEP_LOG_LINE@request@            \"builder\": \"a\", @@@",
+      "@@@STEP_LOG_LINE@request@            \"project\": \"dart\"@@@",
+      "@@@STEP_LOG_LINE@request@          }, @@@",
+      "@@@STEP_LOG_LINE@request@          \"status\": \"SUCCESS\", @@@",
+      "@@@STEP_LOG_LINE@request@          \"tags\": [@@@",
+      "@@@STEP_LOG_LINE@request@            {@@@",
+      "@@@STEP_LOG_LINE@request@              \"key\": \"user_agent\", @@@",
+      "@@@STEP_LOG_LINE@request@              \"value\": \"luci-scheduler\"@@@",
+      "@@@STEP_LOG_LINE@request@            }@@@",
+      "@@@STEP_LOG_LINE@request@          ]@@@",
+      "@@@STEP_LOG_LINE@request@        }@@@",
+      "@@@STEP_LOG_LINE@request@      }@@@",
+      "@@@STEP_LOG_LINE@request@    }, @@@",
+      "@@@STEP_LOG_LINE@request@    {@@@",
+      "@@@STEP_LOG_LINE@request@      \"searchBuilds\": {@@@",
+      "@@@STEP_LOG_LINE@request@        \"fields\": \"builds.*.output.properties,builds.*.builder\", @@@",
+      "@@@STEP_LOG_LINE@request@        \"predicate\": {@@@",
+      "@@@STEP_LOG_LINE@request@          \"builder\": {@@@",
+      "@@@STEP_LOG_LINE@request@            \"bucket\": \"ci\", @@@",
+      "@@@STEP_LOG_LINE@request@            \"builder\": \"b\", @@@",
+      "@@@STEP_LOG_LINE@request@            \"project\": \"dart\"@@@",
+      "@@@STEP_LOG_LINE@request@          }, @@@",
+      "@@@STEP_LOG_LINE@request@          \"status\": \"SUCCESS\", @@@",
+      "@@@STEP_LOG_LINE@request@          \"tags\": [@@@",
+      "@@@STEP_LOG_LINE@request@            {@@@",
+      "@@@STEP_LOG_LINE@request@              \"key\": \"user_agent\", @@@",
+      "@@@STEP_LOG_LINE@request@              \"value\": \"luci-scheduler\"@@@",
+      "@@@STEP_LOG_LINE@request@            }@@@",
+      "@@@STEP_LOG_LINE@request@          ]@@@",
+      "@@@STEP_LOG_LINE@request@        }@@@",
+      "@@@STEP_LOG_LINE@request@      }@@@",
+      "@@@STEP_LOG_LINE@request@    }, @@@",
+      "@@@STEP_LOG_LINE@request@    {@@@",
+      "@@@STEP_LOG_LINE@request@      \"searchBuilds\": {@@@",
+      "@@@STEP_LOG_LINE@request@        \"fields\": \"builds.*.output.properties,builds.*.builder\", @@@",
+      "@@@STEP_LOG_LINE@request@        \"predicate\": {@@@",
+      "@@@STEP_LOG_LINE@request@          \"builder\": {@@@",
+      "@@@STEP_LOG_LINE@request@            \"bucket\": \"ci\", @@@",
+      "@@@STEP_LOG_LINE@request@            \"builder\": \"c\", @@@",
+      "@@@STEP_LOG_LINE@request@            \"project\": \"dart\"@@@",
+      "@@@STEP_LOG_LINE@request@          }, @@@",
+      "@@@STEP_LOG_LINE@request@          \"status\": \"SUCCESS\", @@@",
+      "@@@STEP_LOG_LINE@request@          \"tags\": [@@@",
+      "@@@STEP_LOG_LINE@request@            {@@@",
+      "@@@STEP_LOG_LINE@request@              \"key\": \"user_agent\", @@@",
+      "@@@STEP_LOG_LINE@request@              \"value\": \"luci-scheduler\"@@@",
+      "@@@STEP_LOG_LINE@request@            }@@@",
+      "@@@STEP_LOG_LINE@request@          ]@@@",
+      "@@@STEP_LOG_LINE@request@        }@@@",
+      "@@@STEP_LOG_LINE@request@      }@@@",
+      "@@@STEP_LOG_LINE@request@    }@@@",
+      "@@@STEP_LOG_LINE@request@  ]@@@",
+      "@@@STEP_LOG_LINE@request@}@@@",
+      "@@@STEP_LOG_END@request@@@"
+    ]
+  },
+  {
+    "cmd": [
+      "python",
+      "-u",
+      "RECIPE_MODULE[depot_tools::bot_update]/resources/bot_update.py",
+      "--spec-path",
+      "cache_dir = '[CACHE]/git'\nsolutions = [{'deps_file': 'DEPS', 'managed': False, 'name': 'sdk', 'url': 'https://dart.googlesource.com/sdk.git'}]",
+      "--revision_mapping_file",
+      "{}",
+      "--git-cache-dir",
+      "[CACHE]/git",
+      "--cleanup-dir",
+      "[CLEANUP]/bot_update",
+      "--output_json",
+      "/path/to/tmp/json",
+      "--revision",
+      "sdk@HEAD",
+      "--no_fetch_tags"
+    ],
+    "cwd": "[CACHE]/builder",
+    "env": {
+      "GIT_HTTP_LOW_SPEED_LIMIT": "102400",
+      "GIT_HTTP_LOW_SPEED_TIME": "300"
+    },
+    "env_suffixes": {
+      "DEPOT_TOOLS_UPDATE": [
+        "0"
+      ],
+      "PATH": [
+        "RECIPE_REPO[depot_tools]"
+      ]
+    },
+    "infra_step": true,
+    "name": "bot_update",
+    "timeout": 1080,
+    "~followup_annotations": [
+      "@@@STEP_TEXT@Some step text@@@",
+      "@@@STEP_LOG_LINE@json.output@{@@@",
+      "@@@STEP_LOG_LINE@json.output@  \"did_run\": true, @@@",
+      "@@@STEP_LOG_LINE@json.output@  \"fixed_revisions\": {@@@",
+      "@@@STEP_LOG_LINE@json.output@    \"sdk\": \"HEAD\"@@@",
+      "@@@STEP_LOG_LINE@json.output@  }, @@@",
+      "@@@STEP_LOG_LINE@json.output@  \"manifest\": {@@@",
+      "@@@STEP_LOG_LINE@json.output@    \"sdk\": {@@@",
+      "@@@STEP_LOG_LINE@json.output@      \"repository\": \"https://fake.org/sdk.git\", @@@",
+      "@@@STEP_LOG_LINE@json.output@      \"revision\": \"5a374dcd2e5eb762b527af3a5bab6072a4d24493\"@@@",
+      "@@@STEP_LOG_LINE@json.output@    }@@@",
+      "@@@STEP_LOG_LINE@json.output@  }, @@@",
+      "@@@STEP_LOG_LINE@json.output@  \"patch_failure\": false, @@@",
+      "@@@STEP_LOG_LINE@json.output@  \"patch_root\": \"sdk\", @@@",
+      "@@@STEP_LOG_LINE@json.output@  \"properties\": {}, @@@",
+      "@@@STEP_LOG_LINE@json.output@  \"root\": \"sdk\", @@@",
+      "@@@STEP_LOG_LINE@json.output@  \"source_manifest\": {@@@",
+      "@@@STEP_LOG_LINE@json.output@    \"directories\": {@@@",
+      "@@@STEP_LOG_LINE@json.output@      \"sdk\": {@@@",
+      "@@@STEP_LOG_LINE@json.output@        \"git_checkout\": {@@@",
+      "@@@STEP_LOG_LINE@json.output@          \"repo_url\": \"https://fake.org/sdk.git\", @@@",
+      "@@@STEP_LOG_LINE@json.output@          \"revision\": \"5a374dcd2e5eb762b527af3a5bab6072a4d24493\"@@@",
+      "@@@STEP_LOG_LINE@json.output@        }@@@",
+      "@@@STEP_LOG_LINE@json.output@      }@@@",
+      "@@@STEP_LOG_LINE@json.output@    }, @@@",
+      "@@@STEP_LOG_LINE@json.output@    \"version\": 0@@@",
+      "@@@STEP_LOG_LINE@json.output@  }, @@@",
+      "@@@STEP_LOG_LINE@json.output@  \"step_text\": \"Some step text\"@@@",
+      "@@@STEP_LOG_LINE@json.output@}@@@",
+      "@@@STEP_LOG_END@json.output@@@"
+    ]
+  },
+  {
+    "cmd": [
+      "git",
+      "push",
+      "https://dart.googlesource.com/sdk.git",
+      "2:refs/heads/lkgr"
+    ],
+    "cwd": "[CACHE]/builder/sdk",
+    "infra_step": true,
+    "name": "push 2 to refs/heads/lkgr"
+  },
+  {
+    "name": "$result"
+  }
+]
\ No newline at end of file
diff --git a/recipes/roller/lkgr.proto b/recipes/roller/lkgr.proto
new file mode 100644
index 0000000..1856269
--- /dev/null
+++ b/recipes/roller/lkgr.proto
@@ -0,0 +1,16 @@
+// Copyright (c) 2020, the Dart project authors. All rights reserved. Use of
+// this source code is governed by a BSD-style license that can be found in the
+// LICENSE file.
+syntax = "proto3";
+
+package recipes.dart.roller.lkgr;
+
+import "go.chromium.org/luci/buildbucket/proto/builder.proto";
+
+message LastKnownGoodRevision {
+  // The builders that are required to be green to advance the LKGR.
+  repeated .buildbucket.v2.BuilderID builders = 1;
+
+  // The ref to advance if the builders are green.
+  string ref = 2;
+}
\ No newline at end of file
diff --git a/recipes/roller/lkgr.py b/recipes/roller/lkgr.py
new file mode 100644
index 0000000..c510f0d
--- /dev/null
+++ b/recipes/roller/lkgr.py
@@ -0,0 +1,245 @@
+# Copyright 2020 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style license that can be
+# found in the LICENSE file.
+
+from recipe_engine import post_process
+from google.protobuf import field_mask_pb2
+from google.protobuf import json_format
+from google.protobuf.struct_pb2 import Struct
+
+from PB.go.chromium.org.luci.buildbucket.proto import build as build_pb2
+from PB.go.chromium.org.luci.buildbucket.proto import builder as builder_pb2
+from PB.go.chromium.org.luci.buildbucket.proto import builds_service as builds_service_pb2
+from PB.go.chromium.org.luci.buildbucket.proto import common as common_pb2
+
+from PB.recipes.dart.roller.lkgr import LastKnownGoodRevision
+
+DEPS = [
+    'depot_tools/bot_update',
+    'depot_tools/gclient',
+    'depot_tools/git',
+    'recipe_engine/context',
+    'recipe_engine/json',
+    'recipe_engine/path',
+    'recipe_engine/properties',
+    'recipe_engine/raw_io',
+    'recipe_engine/runtime',
+    'recipe_engine/step',
+]
+
+PROPERTIES = LastKnownGoodRevision
+
+
+def RunSteps(api, properties):
+  builders = properties.builders
+  assert builders, 'the recipe requires builders to be specified'
+  ref = properties.ref
+  assert ref, 'the recipe requires a ref to be specified'
+  builds = _find_good_builds(api, builders)
+  commit_hash = _find_good_hash(api, builders, builds)
+
+  # Guard against the empty string to avoid deleting the remote branch
+  if not commit_hash:
+    step = api.step('no good hashes found', None)
+    step.presentation.logs['builds'] = _pretty_print(api, builds)
+    return
+
+  api.gclient.set_config('dart')
+  with api.context(cwd=api.path['cache'].join('builder')):
+    api.bot_update.ensure_checkout(timeout=1080, no_fetch_tags=True)
+
+  git_args = ['push']
+  if api.runtime.is_experimental:
+    git_args.append('--dry-run')
+  git_args += [
+      'https://dart.googlesource.com/sdk.git',
+      '%s:%s' % (commit_hash, ref),
+  ]
+  api.git(*git_args, name='push %s to %s' % (commit_hash, ref))
+
+
+def _find_good_builds(api, builders):
+  batch_request = builds_service_pb2.BatchRequest(
+      requests=[dict(search_builds=_search_req(api, b)) for b in builders])
+  request_dict = json_format.MessageToDict(batch_request)
+  try:
+    api.step(
+        'search for good builds',
+        ['bb', 'batch'],
+        infra_step=True,
+        stdin=api.json.input(request_dict),
+        stdout=api.json.output(name='response'),
+        timeout=5 * 60,  # 5 minutes
+    )
+  finally:
+    step_result = api.step.active_result
+    step_result.presentation.logs['request'] = _pretty_print(api, request_dict)
+
+    batch_result = builds_service_pb2.BatchResponse()
+    json_format.ParseDict(
+        step_result.stdout or {},
+        batch_result,
+        # Do not fail the build because recipe's proto copy is stale.
+        ignore_unknown_fields=True)
+
+    builds = {}
+    step_text = []
+    for i, result in enumerate(batch_result.responses):
+      # Print response errors in step text.
+      if result.HasField('error'):
+        step_text.extend([
+            'Request #%d' % i,
+            'Status code: %s' % result.error.code,
+            'Message: %s' % result.error.message,
+            '',  # Blank line.
+        ])
+      elif result.search_builds.builds:
+        commit = lambda build: build.output.properties['got_revision']
+        commit_hashes = map(commit, result.search_builds.builds)
+        builder = result.search_builds.builds[0].builder.builder
+        builds[builder] = commit_hashes
+    step_result.presentation.step_text = '<br>'.join(step_text)
+
+  return builds
+
+
+def _search_req(api, builder):
+  return builds_service_pb2.SearchBuildsRequest(
+      predicate=builds_service_pb2.BuildPredicate(
+          builder=builder,
+          status=common_pb2.SUCCESS,
+          tags=[
+              # Ignore non-standard builds (e.g. bisections)
+              common_pb2.StringPair(key='user_agent', value='luci-scheduler')
+          ],
+      ),
+      fields=field_mask_pb2.FieldMask(
+          paths=['builds.*.output.properties', 'builds.*.builder']))
+
+
+def _find_good_hash(api, builders, builds):
+  first = builders[0]
+  rest = builders[1:]
+
+  # The builds are sorted newest-to-oldest.
+  candidates = builds.get(first.builder, [])
+  if not candidates:
+    return None
+  intersection = set(candidates)
+  for builder in rest:
+    intersection.intersection_update(builds.get(builder.builder, []))
+    if not intersection:
+      return None
+  for commit_hash in candidates:
+    if commit_hash in intersection:
+      return commit_hash
+
+  assert False, 'A commit hash must be in the intersection.'  # pragma: no cover
+
+
+def _pretty_print(api, the_dict):
+  return api.json.dumps(the_dict, indent=2, sort_keys=True).splitlines()
+
+
+def GenTests(api):
+  yield api.test(
+      'no-builders-no-steps',
+      api.post_process(post_process.DoesNotRunRE, '(search|push).*'),
+      api.expect_exception('AssertionError'),
+      api.post_process(post_process.StatusException),
+      api.post_process(post_process.DropExpectation),
+  )
+
+  builders = [
+      builder_pb2.BuilderID(project='dart', bucket='ci', builder=builder)
+      for builder in ['a', 'b', 'c']
+  ]
+  input_properties = api.properties(
+      LastKnownGoodRevision(builders=builders, ref='refs/heads/lkgr'))
+
+  def _search_response(builds):
+    return dict(
+        search_builds=builds_service_pb2.SearchBuildsResponse(builds=builds))
+
+  no_builds_response = json_format.MessageToDict(
+      builds_service_pb2.BatchResponse(responses=[_search_response([])]))
+  yield api.test(
+      'no-builds',
+      input_properties,
+      api.step_data(
+          'search for good builds', stdout=api.json.output(no_builds_response)),
+      api.post_process(post_process.MustRun, 'no good hashes found'),
+      api.post_process(post_process.DoesNotRunRE, 'push.*'),
+      api.post_process(post_process.StatusSuccess),
+  )
+
+  yield api.test(
+      'error',
+      input_properties,
+      api.step_data(
+          'search for good builds',
+          retcode=1,
+          stdout=api.json.output(
+              json_format.MessageToDict(
+                  builds_service_pb2.BatchResponse(responses=[
+                      dict(error=dict(code=1, message='search failed'))
+                  ])))),
+      api.post_process(post_process.DoesNotRunRE, '(no|push).*'),
+      api.post_process(post_process.StatusException),
+  )
+
+  def build(builder, got_revision):
+    properties = Struct()
+    properties.update({'got_revision': got_revision})
+    return build_pb2.Build(
+        builder=builder,
+        output=build_pb2.Build.Output(properties=properties),
+    )
+
+  responses = []
+  responses.append(_search_response([build(builders[0], '2')]))
+  responses.append(
+      _search_response(
+          [build(builders[1], commit) for commit in ['1', '2', '3']]))
+  responses.append(
+      _search_response([build(builders[2], commit) for commit in ['2', '3']]))
+  test_response = builds_service_pb2.BatchResponse(responses=responses)
+  search_data = api.step_data(
+      'search for good builds',
+      stdout=api.json.output(json_format.MessageToDict(test_response)))
+  yield api.test(
+      'push',
+      input_properties,
+      search_data,
+      api.post_process(post_process.MustRun, 'push 2 to refs/heads/lkgr'),
+      api.post_process(post_process.DoesNotRunRE, 'no good hashes found'),
+      api.post_process(post_process.StatusSuccess),
+  )
+  yield api.test(
+      'dry-run',
+      input_properties,
+      search_data,
+      api.runtime(is_luci=True, is_experimental=True),
+      api.post_process(post_process.MustRun, 'push 2 to refs/heads/lkgr'),
+      api.post_process(post_process.DoesNotRunRE, 'no good hashes found'),
+      api.post_process(post_process.StepCommandContains,
+                       'push 2 to refs/heads/lkgr', ['--dry-run']),
+      api.post_process(post_process.StatusSuccess),
+      api.post_process(post_process.DropExpectation),
+  )
+
+  no_intersection = _search_response(
+      [build(builders[0], commit) for commit in ['0', '1', '4']])
+  no_intersection_response = json_format.MessageToDict(
+      builds_service_pb2.BatchResponse(
+          responses=[no_intersection, responses[1], responses[2]]))
+  yield api.test(
+      'no-intersection',
+      input_properties,
+      api.step_data(
+          'search for good builds',
+          stdout=api.json.output(no_intersection_response)),
+      api.post_process(post_process.MustRun, 'no good hashes found'),
+      api.post_process(post_process.DoesNotRunRE, 'push.*'),
+      api.post_process(post_process.StatusSuccess),
+  )