[roll] Add a roll to dev recipe

The recipe merges the latest commit on a specified ref to dev. It
updates the VERSION file in refs/heads/dev to match the specified
ref or bumps the PRERELEASE version by 1 if they are already matching.

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

Change-Id: I2eddae8e35dcb9d90d3ea35df93316ac84e9db66
Reviewed-on: https://dart-review.googlesource.com/c/recipes/+/157960
Commit-Queue: Alexander Thomas <athom@google.com>
Reviewed-by: Karl Klose <karlklose@google.com>
diff --git a/README.recipes.md b/README.recipes.md
index f19b10d..98f2a34 100644
--- a/README.recipes.md
+++ b/README.recipes.md
@@ -22,6 +22,7 @@
   * [dart:examples/example-get_secret](#recipes-dart_examples_example-get_secret)
   * [presubmit/presubmit](#recipes-presubmit_presubmit)
   * [roller/lkgr](#recipes-roller_lkgr)
+  * [roller/roll_to_dev](#recipes-roller_roll_to_dev)
 ## Recipe Modules
 
 ### *recipe_modules* / [bisect\_build](/recipe_modules/bisect_build)
@@ -210,6 +211,11 @@
 [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):**
+### *recipes* / [roller/roll\_to\_dev](/recipes/roller/roll_to_dev.py)
+
+[DEPS](/recipes/roller/roll_to_dev.py#9): [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], [depot\_tools/gitiles][depot_tools/recipe_modules/gitiles], [recipe\_engine/context][recipe_engine/recipe_modules/context], [recipe\_engine/file][recipe_engine/recipe_modules/file], [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/roll_to_dev.py#37)(api, properties):**
 
 [build/recipe_modules/goma]: https://chromium.googlesource.com/chromium/tools/build.git/+/d384bbe2de7b92c4959711d38499a57f6b5fdfed/scripts/slave/README.recipes.md#recipe_modules-goma
 [depot_tools/recipe_modules/bot_update]: https://chromium.googlesource.com/chromium/tools/depot_tools.git/+/0fa91d0f3563276834b632b21d03fc327eff6c9c/recipes/README.recipes.md#recipe_modules-bot_update
diff --git a/recipes/roller/roll.expected/push.json b/recipes/roller/roll.expected/push.json
new file mode 100644
index 0000000..03c8354
--- /dev/null
+++ b/recipes/roller/roll.expected/push.json
@@ -0,0 +1,254 @@
+[
+  {
+    "cmd": [
+      "python",
+      "-u",
+      "RECIPE_MODULE[depot_tools::gitiles]/resources/gerrit_client.py",
+      "--json-file",
+      "/path/to/tmp/json",
+      "--url",
+      "https://dart.googlesource.com/sdk/+/refs/heads/lkgr",
+      "--format",
+      "json"
+    ],
+    "name": "get commit to merge",
+    "~followup_annotations": [
+      "@@@STEP_LOG_LINE@json.output@{@@@",
+      "@@@STEP_LOG_LINE@json.output@  \"author\": {@@@",
+      "@@@STEP_LOG_LINE@json.output@    \"email\": \"testauthor@fake.chromium.org\", @@@",
+      "@@@STEP_LOG_LINE@json.output@    \"name\": \"Test Author\", @@@",
+      "@@@STEP_LOG_LINE@json.output@    \"time\": \"Mon Jan 01 00:00:00 2015\"@@@",
+      "@@@STEP_LOG_LINE@json.output@  }, @@@",
+      "@@@STEP_LOG_LINE@json.output@  \"commit\": \"1f8ac10f23c5b5bc1167bda84b833e5c057a77d2\", @@@",
+      "@@@STEP_LOG_LINE@json.output@  \"committer\": {@@@",
+      "@@@STEP_LOG_LINE@json.output@    \"email\": \"testauthor@fake.chromium.org\", @@@",
+      "@@@STEP_LOG_LINE@json.output@    \"name\": \"Test Author\", @@@",
+      "@@@STEP_LOG_LINE@json.output@    \"time\": \"Mon Jan 01 00:00:00 2015\"@@@",
+      "@@@STEP_LOG_LINE@json.output@  }, @@@",
+      "@@@STEP_LOG_LINE@json.output@  \"message\": \"Subject\\n\\nMessage\\n\", @@@",
+      "@@@STEP_LOG_LINE@json.output@  \"parents\": [@@@",
+      "@@@STEP_LOG_LINE@json.output@    \"8bb834e0b20c5477e78620c2108be38b4cab1ea1\"@@@",
+      "@@@STEP_LOG_LINE@json.output@  ], @@@",
+      "@@@STEP_LOG_LINE@json.output@  \"tree\": \"48761f4afebd42d59efcf591792ab1e79289fafd\", @@@",
+      "@@@STEP_LOG_LINE@json.output@  \"tree_diff\": [@@@",
+      "@@@STEP_LOG_LINE@json.output@    {@@@",
+      "@@@STEP_LOG_LINE@json.output@      \"new_id\": \"0f975f72631d85445ca50562eb5cc2c71837b55e\", @@@",
+      "@@@STEP_LOG_LINE@json.output@      \"new_mode\": 33188, @@@",
+      "@@@STEP_LOG_LINE@json.output@      \"new_path\": \"foo/bar\", @@@",
+      "@@@STEP_LOG_LINE@json.output@      \"old_id\": \"0000000000000000000000000000000000000000\", @@@",
+      "@@@STEP_LOG_LINE@json.output@      \"old_mode\": 0, @@@",
+      "@@@STEP_LOG_LINE@json.output@      \"type\": \"add\"@@@",
+      "@@@STEP_LOG_LINE@json.output@    }, @@@",
+      "@@@STEP_LOG_LINE@json.output@    {@@@",
+      "@@@STEP_LOG_LINE@json.output@      \"new_id\": \"52c4105c1b40b1e59cbb2f51ff017ecf74a1a8c0\", @@@",
+      "@@@STEP_LOG_LINE@json.output@      \"new_mode\": 33188, @@@",
+      "@@@STEP_LOG_LINE@json.output@      \"new_path\": \"baz/qux\", @@@",
+      "@@@STEP_LOG_LINE@json.output@      \"old_id\": \"0000000000000000000000000000000000000000\", @@@",
+      "@@@STEP_LOG_LINE@json.output@      \"old_mode\": 0, @@@",
+      "@@@STEP_LOG_LINE@json.output@      \"type\": \"add\"@@@",
+      "@@@STEP_LOG_LINE@json.output@    }@@@",
+      "@@@STEP_LOG_LINE@json.output@  ]@@@",
+      "@@@STEP_LOG_LINE@json.output@}@@@",
+      "@@@STEP_LOG_END@json.output@@@"
+    ]
+  },
+  {
+    "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",
+      "--refs",
+      "refs/heads/dev",
+      "--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",
+      "merge",
+      "--no-commit--no-ff",
+      "1f8ac10f23c5b5bc1167bda84b833e5c057a77d2"
+    ],
+    "cwd": "[CACHE]/builder",
+    "infra_step": true,
+    "name": "git merge"
+  },
+  {
+    "cmd": [
+      "python",
+      "-u",
+      "RECIPE_MODULE[depot_tools::gitiles]/resources/gerrit_client.py",
+      "--json-file",
+      "/path/to/tmp/json",
+      "--url",
+      "https://dart.googlesource.com/sdk/+/refs/heads/lkgr/tools/VERSION",
+      "--format",
+      "text"
+    ],
+    "cwd": "[CACHE]/builder",
+    "name": "download from_ref version file"
+  },
+  {
+    "cmd": [
+      "vpython",
+      "-u",
+      "RECIPE_MODULE[recipe_engine::file]/resources/fileutil.py",
+      "--json-output",
+      "/path/to/tmp/json",
+      "copy",
+      "tools/VERSION",
+      "/path/to/tmp/"
+    ],
+    "cwd": "[CACHE]/builder",
+    "infra_step": true,
+    "name": "read to_ref version file",
+    "~followup_annotations": [
+      "@@@STEP_LOG_LINE@VERSION@# Comment@@@",
+      "@@@STEP_LOG_LINE@VERSION@CHANNEL dev@@@",
+      "@@@STEP_LOG_LINE@VERSION@MAJOR 4@@@",
+      "@@@STEP_LOG_LINE@VERSION@MINOR 1@@@",
+      "@@@STEP_LOG_LINE@VERSION@PATCH 2@@@",
+      "@@@STEP_LOG_LINE@VERSION@PRERELEASE 34@@@",
+      "@@@STEP_LOG_LINE@VERSION@PRERELEASE_PATCH 25@@@",
+      "@@@STEP_LOG_LINE@VERSION@UNKNOWN_FIELD feg@@@",
+      "@@@STEP_LOG_END@VERSION@@@"
+    ]
+  },
+  {
+    "cmd": [
+      "vpython",
+      "-u",
+      "RECIPE_MODULE[recipe_engine::file]/resources/fileutil.py",
+      "--json-output",
+      "/path/to/tmp/json",
+      "copy",
+      "UNKNOWN_FIELD cde",
+      "tools/VERSION"
+    ],
+    "cwd": "[CACHE]/builder",
+    "infra_step": true,
+    "name": "write new version file",
+    "~followup_annotations": [
+      "@@@STEP_LOG_LINE@VERSION@UNKNOWN_FIELD cde@@@",
+      "@@@STEP_LOG_END@VERSION@@@"
+    ]
+  },
+  {
+    "cmd": [
+      "git",
+      "add",
+      "tools/VERSION"
+    ],
+    "cwd": "[CACHE]/builder",
+    "infra_step": true,
+    "name": "git add"
+  },
+  {
+    "cmd": [
+      "git",
+      "commit",
+      "--all",
+      "--message=Version 4.1.2-35.0.dev\n\nMerge commit '1f8ac10f23c5b5bc1167bda84b833e5c057a77d2' into dev\n"
+    ],
+    "cwd": "[CACHE]/builder",
+    "infra_step": true,
+    "name": "git commit"
+  },
+  {
+    "cmd": [
+      "git",
+      "tag",
+      "--annotate",
+      "--message=4.1.2-35.0.dev",
+      "4.1.2-35.0.dev"
+    ],
+    "cwd": "[CACHE]/builder",
+    "infra_step": true,
+    "name": "git tag"
+  },
+  {
+    "cmd": [
+      "git",
+      "push",
+      "https://dart.googlesource.com/sdk.git",
+      "refs/heads/dev"
+    ],
+    "cwd": "[CACHE]/builder",
+    "infra_step": true,
+    "name": "push to refs/heads/dev"
+  },
+  {
+    "cmd": [
+      "git",
+      "push",
+      "https://dart.googlesource.com/sdk.git",
+      "4.1.2-35.0.dev"
+    ],
+    "cwd": "[CACHE]/builder",
+    "infra_step": true,
+    "name": "tag 4.1.2-35.0.dev"
+  },
+  {
+    "name": "$result"
+  }
+]
\ No newline at end of file
diff --git a/recipes/roller/roll_to_dev.expected/no-push-if-already-merged.json b/recipes/roller/roll_to_dev.expected/no-push-if-already-merged.json
new file mode 100644
index 0000000..790c2ef
--- /dev/null
+++ b/recipes/roller/roll_to_dev.expected/no-push-if-already-merged.json
@@ -0,0 +1,154 @@
+[
+  {
+    "cmd": [
+      "python",
+      "-u",
+      "RECIPE_MODULE[depot_tools::gitiles]/resources/gerrit_client.py",
+      "--json-file",
+      "/path/to/tmp/json",
+      "--url",
+      "https://dart.googlesource.com/sdk/+/refs/heads/lkgr",
+      "--format",
+      "json"
+    ],
+    "name": "get commit to merge",
+    "~followup_annotations": [
+      "@@@STEP_LOG_LINE@json.output@{@@@",
+      "@@@STEP_LOG_LINE@json.output@  \"author\": {@@@",
+      "@@@STEP_LOG_LINE@json.output@    \"email\": \"testauthor@fake.chromium.org\", @@@",
+      "@@@STEP_LOG_LINE@json.output@    \"name\": \"Test Author\", @@@",
+      "@@@STEP_LOG_LINE@json.output@    \"time\": \"Mon Jan 01 00:00:00 2015\"@@@",
+      "@@@STEP_LOG_LINE@json.output@  }, @@@",
+      "@@@STEP_LOG_LINE@json.output@  \"commit\": \"1f8ac10f23c5b5bc1167bda84b833e5c057a77d2\", @@@",
+      "@@@STEP_LOG_LINE@json.output@  \"committer\": {@@@",
+      "@@@STEP_LOG_LINE@json.output@    \"email\": \"testauthor@fake.chromium.org\", @@@",
+      "@@@STEP_LOG_LINE@json.output@    \"name\": \"Test Author\", @@@",
+      "@@@STEP_LOG_LINE@json.output@    \"time\": \"Mon Jan 01 00:00:00 2015\"@@@",
+      "@@@STEP_LOG_LINE@json.output@  }, @@@",
+      "@@@STEP_LOG_LINE@json.output@  \"message\": \"Subject\\n\\nMessage\\n\", @@@",
+      "@@@STEP_LOG_LINE@json.output@  \"parents\": [@@@",
+      "@@@STEP_LOG_LINE@json.output@    \"8bb834e0b20c5477e78620c2108be38b4cab1ea1\"@@@",
+      "@@@STEP_LOG_LINE@json.output@  ], @@@",
+      "@@@STEP_LOG_LINE@json.output@  \"tree\": \"48761f4afebd42d59efcf591792ab1e79289fafd\", @@@",
+      "@@@STEP_LOG_LINE@json.output@  \"tree_diff\": [@@@",
+      "@@@STEP_LOG_LINE@json.output@    {@@@",
+      "@@@STEP_LOG_LINE@json.output@      \"new_id\": \"0f975f72631d85445ca50562eb5cc2c71837b55e\", @@@",
+      "@@@STEP_LOG_LINE@json.output@      \"new_mode\": 33188, @@@",
+      "@@@STEP_LOG_LINE@json.output@      \"new_path\": \"foo/bar\", @@@",
+      "@@@STEP_LOG_LINE@json.output@      \"old_id\": \"0000000000000000000000000000000000000000\", @@@",
+      "@@@STEP_LOG_LINE@json.output@      \"old_mode\": 0, @@@",
+      "@@@STEP_LOG_LINE@json.output@      \"type\": \"add\"@@@",
+      "@@@STEP_LOG_LINE@json.output@    }, @@@",
+      "@@@STEP_LOG_LINE@json.output@    {@@@",
+      "@@@STEP_LOG_LINE@json.output@      \"new_id\": \"52c4105c1b40b1e59cbb2f51ff017ecf74a1a8c0\", @@@",
+      "@@@STEP_LOG_LINE@json.output@      \"new_mode\": 33188, @@@",
+      "@@@STEP_LOG_LINE@json.output@      \"new_path\": \"baz/qux\", @@@",
+      "@@@STEP_LOG_LINE@json.output@      \"old_id\": \"0000000000000000000000000000000000000000\", @@@",
+      "@@@STEP_LOG_LINE@json.output@      \"old_mode\": 0, @@@",
+      "@@@STEP_LOG_LINE@json.output@      \"type\": \"add\"@@@",
+      "@@@STEP_LOG_LINE@json.output@    }@@@",
+      "@@@STEP_LOG_LINE@json.output@  ]@@@",
+      "@@@STEP_LOG_LINE@json.output@}@@@",
+      "@@@STEP_LOG_END@json.output@@@"
+    ]
+  },
+  {
+    "cmd": [
+      "python",
+      "-u",
+      "RECIPE_MODULE[depot_tools::git]/resources/git_setup.py",
+      "--path",
+      "[START_DIR]/sdk",
+      "--url",
+      "https://dart.googlesource.com/sdk"
+    ],
+    "name": "git setup"
+  },
+  {
+    "cmd": [
+      "git",
+      "fetch",
+      "origin",
+      "dev",
+      "--progress"
+    ],
+    "cwd": "[START_DIR]/sdk",
+    "env": {
+      "PATH": "RECIPE_REPO[depot_tools]:<PATH>"
+    },
+    "infra_step": true,
+    "name": "git fetch"
+  },
+  {
+    "cmd": [
+      "git",
+      "checkout",
+      "-f",
+      "FETCH_HEAD"
+    ],
+    "cwd": "[START_DIR]/sdk",
+    "infra_step": true,
+    "name": "git checkout"
+  },
+  {
+    "cmd": [
+      "git",
+      "rev-parse",
+      "HEAD"
+    ],
+    "cwd": "[START_DIR]/sdk",
+    "infra_step": true,
+    "name": "read revision",
+    "~followup_annotations": [
+      "@@@STEP_TEXT@<br/>checked out 'deadbeef'<br/>@@@"
+    ]
+  },
+  {
+    "cmd": [
+      "git",
+      "clean",
+      "-f",
+      "-d",
+      "-x"
+    ],
+    "cwd": "[START_DIR]/sdk",
+    "infra_step": true,
+    "name": "git clean"
+  },
+  {
+    "cmd": [
+      "git",
+      "checkout",
+      "dev"
+    ],
+    "cwd": "[START_DIR]/sdk",
+    "infra_step": true,
+    "name": "checkout dev"
+  },
+  {
+    "cmd": [
+      "git",
+      "fetch",
+      "origin",
+      "1f8ac10f23c5b5bc1167bda84b833e5c057a77d2"
+    ],
+    "cwd": "[START_DIR]/sdk",
+    "infra_step": true,
+    "name": "fetch 1f8ac10f23c5b5bc1167bda84b833e5c057a77d2"
+  },
+  {
+    "cmd": [
+      "git",
+      "merge-base",
+      "--is-ancestor",
+      "1f8ac10f23c5b5bc1167bda84b833e5c057a77d2",
+      "HEAD"
+    ],
+    "cwd": "[START_DIR]/sdk",
+    "infra_step": true,
+    "name": "check if commit has been merged before"
+  },
+  {
+    "name": "$result"
+  }
+]
\ No newline at end of file
diff --git a/recipes/roller/roll_to_dev.expected/push.json b/recipes/roller/roll_to_dev.expected/push.json
new file mode 100644
index 0000000..3979cfa
--- /dev/null
+++ b/recipes/roller/roll_to_dev.expected/push.json
@@ -0,0 +1,292 @@
+[
+  {
+    "cmd": [
+      "python",
+      "-u",
+      "RECIPE_MODULE[depot_tools::gitiles]/resources/gerrit_client.py",
+      "--json-file",
+      "/path/to/tmp/json",
+      "--url",
+      "https://dart.googlesource.com/sdk/+/refs/heads/lkgr",
+      "--format",
+      "json"
+    ],
+    "name": "get commit to merge",
+    "~followup_annotations": [
+      "@@@STEP_LOG_LINE@json.output@{@@@",
+      "@@@STEP_LOG_LINE@json.output@  \"author\": {@@@",
+      "@@@STEP_LOG_LINE@json.output@    \"email\": \"testauthor@fake.chromium.org\", @@@",
+      "@@@STEP_LOG_LINE@json.output@    \"name\": \"Test Author\", @@@",
+      "@@@STEP_LOG_LINE@json.output@    \"time\": \"Mon Jan 01 00:00:00 2015\"@@@",
+      "@@@STEP_LOG_LINE@json.output@  }, @@@",
+      "@@@STEP_LOG_LINE@json.output@  \"commit\": \"1f8ac10f23c5b5bc1167bda84b833e5c057a77d2\", @@@",
+      "@@@STEP_LOG_LINE@json.output@  \"committer\": {@@@",
+      "@@@STEP_LOG_LINE@json.output@    \"email\": \"testauthor@fake.chromium.org\", @@@",
+      "@@@STEP_LOG_LINE@json.output@    \"name\": \"Test Author\", @@@",
+      "@@@STEP_LOG_LINE@json.output@    \"time\": \"Mon Jan 01 00:00:00 2015\"@@@",
+      "@@@STEP_LOG_LINE@json.output@  }, @@@",
+      "@@@STEP_LOG_LINE@json.output@  \"message\": \"Subject\\n\\nMessage\\n\", @@@",
+      "@@@STEP_LOG_LINE@json.output@  \"parents\": [@@@",
+      "@@@STEP_LOG_LINE@json.output@    \"8bb834e0b20c5477e78620c2108be38b4cab1ea1\"@@@",
+      "@@@STEP_LOG_LINE@json.output@  ], @@@",
+      "@@@STEP_LOG_LINE@json.output@  \"tree\": \"48761f4afebd42d59efcf591792ab1e79289fafd\", @@@",
+      "@@@STEP_LOG_LINE@json.output@  \"tree_diff\": [@@@",
+      "@@@STEP_LOG_LINE@json.output@    {@@@",
+      "@@@STEP_LOG_LINE@json.output@      \"new_id\": \"0f975f72631d85445ca50562eb5cc2c71837b55e\", @@@",
+      "@@@STEP_LOG_LINE@json.output@      \"new_mode\": 33188, @@@",
+      "@@@STEP_LOG_LINE@json.output@      \"new_path\": \"foo/bar\", @@@",
+      "@@@STEP_LOG_LINE@json.output@      \"old_id\": \"0000000000000000000000000000000000000000\", @@@",
+      "@@@STEP_LOG_LINE@json.output@      \"old_mode\": 0, @@@",
+      "@@@STEP_LOG_LINE@json.output@      \"type\": \"add\"@@@",
+      "@@@STEP_LOG_LINE@json.output@    }, @@@",
+      "@@@STEP_LOG_LINE@json.output@    {@@@",
+      "@@@STEP_LOG_LINE@json.output@      \"new_id\": \"52c4105c1b40b1e59cbb2f51ff017ecf74a1a8c0\", @@@",
+      "@@@STEP_LOG_LINE@json.output@      \"new_mode\": 33188, @@@",
+      "@@@STEP_LOG_LINE@json.output@      \"new_path\": \"baz/qux\", @@@",
+      "@@@STEP_LOG_LINE@json.output@      \"old_id\": \"0000000000000000000000000000000000000000\", @@@",
+      "@@@STEP_LOG_LINE@json.output@      \"old_mode\": 0, @@@",
+      "@@@STEP_LOG_LINE@json.output@      \"type\": \"add\"@@@",
+      "@@@STEP_LOG_LINE@json.output@    }@@@",
+      "@@@STEP_LOG_LINE@json.output@  ]@@@",
+      "@@@STEP_LOG_LINE@json.output@}@@@",
+      "@@@STEP_LOG_END@json.output@@@"
+    ]
+  },
+  {
+    "cmd": [
+      "python",
+      "-u",
+      "RECIPE_MODULE[depot_tools::git]/resources/git_setup.py",
+      "--path",
+      "[START_DIR]/sdk",
+      "--url",
+      "https://dart.googlesource.com/sdk"
+    ],
+    "name": "git setup"
+  },
+  {
+    "cmd": [
+      "git",
+      "fetch",
+      "origin",
+      "dev",
+      "--progress"
+    ],
+    "cwd": "[START_DIR]/sdk",
+    "env": {
+      "PATH": "RECIPE_REPO[depot_tools]:<PATH>"
+    },
+    "infra_step": true,
+    "name": "git fetch"
+  },
+  {
+    "cmd": [
+      "git",
+      "checkout",
+      "-f",
+      "FETCH_HEAD"
+    ],
+    "cwd": "[START_DIR]/sdk",
+    "infra_step": true,
+    "name": "git checkout"
+  },
+  {
+    "cmd": [
+      "git",
+      "rev-parse",
+      "HEAD"
+    ],
+    "cwd": "[START_DIR]/sdk",
+    "infra_step": true,
+    "name": "read revision",
+    "~followup_annotations": [
+      "@@@STEP_TEXT@<br/>checked out 'deadbeef'<br/>@@@"
+    ]
+  },
+  {
+    "cmd": [
+      "git",
+      "clean",
+      "-f",
+      "-d",
+      "-x"
+    ],
+    "cwd": "[START_DIR]/sdk",
+    "infra_step": true,
+    "name": "git clean"
+  },
+  {
+    "cmd": [
+      "git",
+      "checkout",
+      "dev"
+    ],
+    "cwd": "[START_DIR]/sdk",
+    "infra_step": true,
+    "name": "checkout dev"
+  },
+  {
+    "cmd": [
+      "git",
+      "fetch",
+      "origin",
+      "1f8ac10f23c5b5bc1167bda84b833e5c057a77d2"
+    ],
+    "cwd": "[START_DIR]/sdk",
+    "infra_step": true,
+    "name": "fetch 1f8ac10f23c5b5bc1167bda84b833e5c057a77d2"
+  },
+  {
+    "cmd": [
+      "git",
+      "merge-base",
+      "--is-ancestor",
+      "1f8ac10f23c5b5bc1167bda84b833e5c057a77d2",
+      "HEAD"
+    ],
+    "cwd": "[START_DIR]/sdk",
+    "infra_step": true,
+    "name": "check if commit has been merged before"
+  },
+  {
+    "cmd": [
+      "git",
+      "merge",
+      "--no-commit",
+      "--no-ff",
+      "1f8ac10f23c5b5bc1167bda84b833e5c057a77d2"
+    ],
+    "cwd": "[START_DIR]/sdk",
+    "infra_step": true,
+    "name": "merge 1f8ac10f23c5b5bc1167bda84b833e5c057a77d2 to dev"
+  },
+  {
+    "cmd": [
+      "git",
+      "checkout",
+      "--ours",
+      "tools/VERSION"
+    ],
+    "cwd": "[START_DIR]/sdk",
+    "infra_step": true,
+    "name": "restore version file on dev"
+  },
+  {
+    "cmd": [
+      "python",
+      "-u",
+      "RECIPE_MODULE[depot_tools::gitiles]/resources/gerrit_client.py",
+      "--json-file",
+      "/path/to/tmp/json",
+      "--url",
+      "https://dart.googlesource.com/sdk/+/refs/heads/lkgr/tools/VERSION",
+      "--format",
+      "text"
+    ],
+    "cwd": "[START_DIR]/sdk",
+    "name": "download from_ref version file"
+  },
+  {
+    "cmd": [
+      "vpython",
+      "-u",
+      "RECIPE_MODULE[recipe_engine::file]/resources/fileutil.py",
+      "--json-output",
+      "/path/to/tmp/json",
+      "copy",
+      "[START_DIR]/sdk/tools/VERSION",
+      "/path/to/tmp/"
+    ],
+    "cwd": "[START_DIR]/sdk",
+    "infra_step": true,
+    "name": "read to_ref version file",
+    "~followup_annotations": [
+      "@@@STEP_LOG_LINE@VERSION@# Comment@@@",
+      "@@@STEP_LOG_LINE@VERSION@CHANNEL dev@@@",
+      "@@@STEP_LOG_LINE@VERSION@MAJOR 4@@@",
+      "@@@STEP_LOG_LINE@VERSION@MINOR 1@@@",
+      "@@@STEP_LOG_LINE@VERSION@PATCH 2@@@",
+      "@@@STEP_LOG_LINE@VERSION@PRERELEASE 34@@@",
+      "@@@STEP_LOG_LINE@VERSION@PRERELEASE_PATCH 25@@@",
+      "@@@STEP_LOG_LINE@VERSION@UNKNOWN_FIELD feg@@@",
+      "@@@STEP_LOG_END@VERSION@@@"
+    ]
+  },
+  {
+    "cmd": [
+      "vpython",
+      "-u",
+      "RECIPE_MODULE[recipe_engine::file]/resources/fileutil.py",
+      "--json-output",
+      "/path/to/tmp/json",
+      "copy",
+      "UNKNOWN_FIELD cde",
+      "[START_DIR]/sdk/tools/VERSION"
+    ],
+    "cwd": "[START_DIR]/sdk",
+    "infra_step": true,
+    "name": "write new version file",
+    "~followup_annotations": [
+      "@@@STEP_LOG_LINE@VERSION@UNKNOWN_FIELD cde@@@",
+      "@@@STEP_LOG_END@VERSION@@@"
+    ]
+  },
+  {
+    "cmd": [
+      "git",
+      "add",
+      "tools/VERSION"
+    ],
+    "cwd": "[START_DIR]/sdk",
+    "infra_step": true,
+    "name": "git add"
+  },
+  {
+    "cmd": [
+      "git",
+      "commit",
+      "--all",
+      "--message=Version 4.1.2-35.0.dev\n\nMerge commit '1f8ac10f23c5b5bc1167bda84b833e5c057a77d2' into 'dev'\n"
+    ],
+    "cwd": "[START_DIR]/sdk",
+    "infra_step": true,
+    "name": "git commit"
+  },
+  {
+    "cmd": [
+      "git",
+      "tag",
+      "--annotate",
+      "--message=4.1.2-35.0.dev",
+      "4.1.2-35.0.dev"
+    ],
+    "cwd": "[START_DIR]/sdk",
+    "infra_step": true,
+    "name": "git tag"
+  },
+  {
+    "cmd": [
+      "git",
+      "push",
+      "https://dart.googlesource.com/sdk.git",
+      "refs/heads/dev"
+    ],
+    "cwd": "[START_DIR]/sdk",
+    "infra_step": true,
+    "name": "push to refs/heads/dev"
+  },
+  {
+    "cmd": [
+      "git",
+      "push",
+      "https://dart.googlesource.com/sdk.git",
+      "4.1.2-35.0.dev"
+    ],
+    "cwd": "[START_DIR]/sdk",
+    "infra_step": true,
+    "name": "tag 4.1.2-35.0.dev"
+  },
+  {
+    "name": "$result"
+  }
+]
\ No newline at end of file
diff --git a/recipes/roller/roll_to_dev.proto b/recipes/roller/roll_to_dev.proto
new file mode 100644
index 0000000..6f0d0f8
--- /dev/null
+++ b/recipes/roller/roll_to_dev.proto
@@ -0,0 +1,11 @@
+// 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.roll_to_dev;
+
+message RollToDev {
+  // The source ref (e.g. "refs/heads/lkgr") or commit SHA to roll from.
+  string from_ref = 1;
+}
\ No newline at end of file
diff --git a/recipes/roller/roll_to_dev.py b/recipes/roller/roll_to_dev.py
new file mode 100644
index 0000000..b9e37af
--- /dev/null
+++ b/recipes/roller/roll_to_dev.py
@@ -0,0 +1,278 @@
+# 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 PB.recipes.dart.roller.roll_to_dev import RollToDev
+
+DEPS = [
+    'depot_tools/bot_update',
+    'depot_tools/gclient',
+    'depot_tools/git',
+    'depot_tools/gitiles',
+    'recipe_engine/context',
+    'recipe_engine/file',
+    'recipe_engine/json',
+    'recipe_engine/path',
+    'recipe_engine/properties',
+    'recipe_engine/raw_io',
+    'recipe_engine/runtime',
+    'recipe_engine/step',
+]
+DEV_BRANCH = 'dev'
+DEV_REF = 'refs/heads/dev'
+MESSAGE_TEMPLATE = '''Version %s
+
+Merge commit '%s' into '%s'
+'''
+PROPERTIES = RollToDev
+REPO_URL = 'https://dart.googlesource.com/sdk'
+SUPPORTED_FIELDS = {
+    'CHANNEL', 'MAJOR', 'MINOR', 'PATCH', 'PRERELEASE', 'PRERELEASE_PATCH'
+}
+VERSION_PATH = 'tools/VERSION'
+
+
+def RunSteps(api, properties):
+  from_ref = properties.from_ref
+  assert from_ref, 'the recipe requires a from_ref'
+  to_ref = DEV_REF
+  commit = api.gitiles.commit_log(
+      commit=from_ref,
+      step_name='get commit to merge',
+      url=REPO_URL,
+  )
+  commit_hash = commit['commit']
+  assert commit_hash, 'no commit hash to merge found'
+
+  api.git.checkout(
+      REPO_URL,
+      ref=to_ref,
+      submodules=False,
+      submodule_update_recursive=False,
+  )
+  with api.context(cwd=api.path['checkout']):
+    api.git('checkout', DEV_BRANCH, name='checkout dev')
+    api.git('fetch', 'origin', commit_hash, name='fetch %s' % commit_hash)
+    result = api.git(
+        'merge-base',
+        '--is-ancestor',
+        commit_hash,
+        'HEAD',
+        name='check if commit has been merged before',
+        ok_ret=[0, 1],  # merge-base returns 1 if the commit is not an ancestor.
+    )
+    if result.exc_result.retcode == 0:
+      # The commit has already been pushed before.
+      return
+
+    api.git(
+        'merge',
+        '--no-commit',
+        '--no-ff',
+        commit_hash,
+        name='merge %s to dev' % commit_hash,
+        ok_ret='any')
+    api.git(
+        'checkout', '--ours', VERSION_PATH, name='restore version file on dev')
+    version, channel = _update_version_file(api, from_ref)
+    message = MESSAGE_TEMPLATE % (version, commit_hash, channel)
+    api.git('commit', '--all', '--message=%s' % message)
+    api.git('tag', '--annotate', '--message=%s' % version, version)
+    push_args = ['push']
+    if api.runtime.is_experimental:
+      push_args.append('--dry-run')
+    push_args.append('https://dart.googlesource.com/sdk.git')
+    api.git(*push_args + [to_ref], name='push to %s' % to_ref)
+    api.git(*push_args + [version], name='tag %s' % version)
+
+
+def _update_version_file(api, from_ref):
+  from_version_file_text = api.gitiles.download_file(
+      REPO_URL,
+      VERSION_PATH,
+      branch=from_ref,
+      step_name='download from_ref version file',
+  )
+  from_version = _parse_version_file(from_version_file_text)
+  from_triplet = _version_triplet(from_version)
+
+  to_version_file_text = api.file.read_text(
+      'read to_ref version file', api.path['checkout'].join(VERSION_PATH))
+  to_version = _parse_version_file(to_version_file_text)
+  to_triplet = _version_triplet(to_version)
+
+  if not from_triplet == to_triplet:
+    # major, minor, or patch was bumped on from_ref
+    assert from_triplet > to_triplet, 'version downgrade is unsupported'
+    for field in ['MAJOR', 'MINOR', 'PATCH']:
+      to_version[field] = from_version[field]
+    # reset prerelease
+    to_version['PRERELEASE'] = '0'
+  else:
+    _bump(to_version, 'PRERELEASE')
+
+  # always '0' on dev
+  to_version['PRERELEASE_PATCH'] = '0'
+
+  # copy unknown fields
+  for field in from_version.keys():
+    if field not in SUPPORTED_FIELDS:
+      to_version[field] = from_version[field]
+
+  _write_version_file(api, from_version_file_text, to_version)
+  api.git('add', VERSION_PATH)
+  return _version_string(to_version), to_version['CHANNEL']
+
+
+def _parse_version_file(version_file_text):
+  version = {}
+  for line in version_file_text.splitlines():
+    if not line or line.startswith('#'):
+      continue
+    field, value = line.split(' ')
+    version[field] = value
+  return version
+
+
+def _version_triplet(version):
+  return (version['MAJOR'], version['MINOR'], version['PATCH'])
+
+
+def _bump(version, field):
+  version[field] = str(int(version[field]) + 1)
+
+
+def _write_version_file(api, version_file_text, new_version):
+  updated_lines = []
+  for line in version_file_text.splitlines():
+    if line.startswith('#'):
+      updated_lines.append(line)
+    else:
+      field, _ = line.split(' ')
+      updated_lines = ['%s %s' % (field, new_version[field])]
+  api.file.write_text('write new version file',
+                      api.path['checkout'].join(VERSION_PATH),
+                      '\n'.join(updated_lines))
+
+
+def _version_string(version):
+  return '%s.%s.%s-%s.%s.%s' % (version['MAJOR'], version['MINOR'],
+                                version['PATCH'], version['PRERELEASE'],
+                                version['PRERELEASE_PATCH'], version['CHANNEL'])
+
+
+def GenTests(api):
+
+  assertion_error = (
+      api.post_process(post_process.DoesNotRunRE, '(push|tag).*'),
+      api.expect_exception('AssertionError'),
+      api.post_process(post_process.StatusException),
+      api.post_process(post_process.DropExpectation),
+  )
+  yield api.test('no-from-ref-no-push', *assertion_error)
+
+  input_properties = api.properties(RollToDev(from_ref='refs/heads/lkgr'))
+  from_ref_commit = api.step_data(
+      'get commit to merge',
+      api.gitiles.make_commit_test_data(
+          'abcdef', 'Subject\n\nMessage\n', new_files=['foo/bar', 'baz/qux']))
+  from_ref_version_file = api.step_data(
+      'download from_ref version file',
+      api.gitiles.make_encoded_file('''# Comment
+CHANNEL abc
+MAJOR 4
+MINOR 1
+PATCH 2
+PRERELEASE 0
+PRERELEASE_PATCH 0
+UNKNOWN_FIELD cde
+'''))
+  to_ref_version_file = api.step_data(
+      'read to_ref version file',
+      api.file.read_text('''# Comment
+CHANNEL dev
+MAJOR 4
+MINOR 1
+PATCH 2
+PRERELEASE 34
+PRERELEASE_PATCH 25
+UNKNOWN_FIELD feg
+'''))
+
+  yield api.test(
+      'no-push-if-already-merged',
+      input_properties,
+      from_ref_commit,
+      api.post_process(post_process.DoesNotRunRE, '(push|tag).*'),
+      api.post_process(post_process.StatusSuccess),
+  )
+
+  unmerged = api.step_data('check if commit has been merged before', retcode=1)
+  yield api.test(
+      'push',
+      input_properties,
+      from_ref_commit,
+      from_ref_version_file,
+      to_ref_version_file,
+      unmerged,
+      api.post_process(post_process.MustRun, 'push to refs/heads/dev'),
+      api.post_process(post_process.MustRun, 'tag 4.1.2-35.0.dev'),
+      api.post_process(post_process.StatusSuccess),
+  )
+  yield api.test(
+      'dry-run',
+      api.runtime(is_luci=True, is_experimental=True),
+      input_properties,
+      from_ref_commit,
+      from_ref_version_file,
+      to_ref_version_file,
+      unmerged,
+      api.post_process(post_process.MustRun, 'push to refs/heads/dev'),
+      api.post_process(post_process.MustRun, 'tag 4.1.2-35.0.dev'),
+      api.post_process(post_process.StepCommandContains,
+                       'push to refs/heads/dev', ['--dry-run']),
+      api.post_process(post_process.StepCommandContains, 'tag 4.1.2-35.0.dev',
+                       ['--dry-run']),
+      api.post_process(post_process.StatusSuccess),
+      api.post_process(post_process.DropExpectation),
+  )
+
+  from_ref_version_file = api.step_data(
+      'download from_ref version file',
+      api.gitiles.make_encoded_file('''# Comment
+CHANNEL abc
+MAJOR 5
+MINOR 2
+PATCH 10
+PRERELEASE 0
+PRERELEASE_PATCH 0
+UNKNOWN_FIELD cde
+'''))
+  yield api.test(
+      'push-version-mismatch',
+      input_properties,
+      from_ref_commit,
+      from_ref_version_file,
+      to_ref_version_file,
+      unmerged,
+      api.post_process(post_process.MustRun, 'push to refs/heads/dev'),
+      api.post_process(post_process.MustRun, 'tag 5.2.10-0.0.dev'),
+      api.post_process(post_process.StatusSuccess),
+      api.post_process(post_process.DropExpectation),
+  )
+  from_ref_version_file = api.step_data(
+      'download from_ref version file',
+      api.gitiles.make_encoded_file('''# Comment
+CHANNEL abc
+MAJOR 4
+MINOR 0
+PATCH 2
+PRERELEASE 0
+PRERELEASE_PATCH 0
+UNKNOWN_FIELD cde
+'''))
+  yield api.test('no-push-version-downgrade', input_properties, from_ref_commit,
+                 from_ref_version_file, to_ref_version_file, unmerged,
+                 *assertion_error)