Bootstrap and automatically update Buildkite pipelines (#8619)

* Bootstrap and automatically update Buildkite pipelines

This means that:
  1. postsubmit runs always use with the version of the pipeline
     configuration from that commit
  2. presubmit runs use  with the version of the pipeline configuration
     from that commit if the PR comes from the main repo. This
     limitation is for security reasons and is why this PR branch is in
     the main repo. It limits potentially bypassing our normal checks to
     people who already have write access to the repository. Suggestions
     for ways to do this that support third party forks but maintains
     this security are welcome.
  3. Pipelines that we need to register with Buildkite can all be
     checked in to source control and updated as part of the commit to
     the main branch.

This required modifications to the smooth-checkout plugin, which I am
upstreaming in
https://github.com/hasura/smooth-checkout-buildkite-plugin/pull/25.

This allows testing changes to the presubmit pipeline itself on
presubmit. Of course, it relies on the previous version of the pipeline
being sufficiently compatible that it can successfully bootstrap, but
that shouldn't be too hard.

We now have enough stuff going on here that I created `pipelines/` and
`scripts/` subdirectories. I did not move any of the legacy files (e.g.
samples.yml) to avoid breaking anything.

It's also looking like I should factor out some of the shared Buildkite
Python API usage, but I'd prefer to defer that till a later PR.

Combines work from obsolete PRs
https://github.com/google/iree/pull/8609 and
https://github.com/google/iree/pull/8606. I tried to have this broken
out into two PRs, but testing the presubmit pipeline in its current form
(as registered with Buildkite) doesn't work very well, which is why this
PR exists. It got all messed up because of the directory restructuring.
I can split that out into a separate PR if that's preferred.

* Lint

* Avoid slashes in keys, which are apparently not allowed

* Maybe avoiding reported issues with duplicate keys

* Actually fix step key conflicts
diff --git a/build_tools/buildkite/pipelines/include/README.md b/build_tools/buildkite/pipelines/include/README.md
new file mode 100644
index 0000000..44d89a0
--- /dev/null
+++ b/build_tools/buildkite/pipelines/include/README.md
@@ -0,0 +1,5 @@
+# Partial Pipelines
+
+These are Buildkite pipelines intended to be inserted into other pipelines with
+the `buildkite-agent pipeline upload` command. Standalone pipelines, which we
+register with the Buildkite API, are stored in the main pipeline directory.
diff --git a/build_tools/buildkite/cla_failure.yml b/build_tools/buildkite/pipelines/include/cla-failure.yml
similarity index 78%
rename from build_tools/buildkite/cla_failure.yml
rename to build_tools/buildkite/pipelines/include/cla-failure.yml
index 696056a..8a3b008 100644
--- a/build_tools/buildkite/cla_failure.yml
+++ b/build_tools/buildkite/pipelines/include/cla-failure.yml
@@ -4,6 +4,9 @@
 # See https://llvm.org/LICENSE.txt for license information.
 # SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception
 
+# This block step is inserted when a contributor hasn't signed the CLA. A team
+# member will need to approve before builds can run on their PR.
+
 steps:
   - block: >
       :guardsman: :pouting_cat: :raised_hand: CLA Check failed.
diff --git a/build_tools/buildkite/pipelines/postsubmit.yml b/build_tools/buildkite/pipelines/postsubmit.yml
new file mode 100644
index 0000000..1fe0a72
--- /dev/null
+++ b/build_tools/buildkite/pipelines/postsubmit.yml
@@ -0,0 +1,33 @@
+# Copyright 2022 The IREE Authors
+#
+# Licensed under the Apache License v2.0 with LLVM Exceptions.
+# See https://llvm.org/LICENSE.txt for license information.
+# SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception
+
+agents:
+  queue: "orchestration"
+  security: "submitted"
+
+steps:
+  - label: ":hiking_boot: Bootstrapping postsubmit pipeline"
+    env:
+      PIPELINE_BOOTSTRAPPED: ${PIPELINE_BOOTSTRAPPED:-false}
+    commands: |
+      ./build_tools/buildkite/scripts/bootstrap_pipeline.sh \
+          build_tools/buildkite/pipelines/postsubmit.yml
+
+  - wait
+
+  - label: "Updating pipelines"
+    concurrency: 1
+    concurrency_group: "update-pipelines"
+    commands: |
+      export BUILDKITE_ACCESS_TOKEN="$(gcloud secrets versions access latest \
+          --secret=iree-buildkite-privileged)"
+      build_tools/buildkite/scripts/update_pipeline_configuration.py
+
+  - label: "Executing gcmn-test-pipeline"
+    commands: |
+      export BUILDKITE_ACCESS_TOKEN="$(gcloud secrets versions access latest \
+          --secret=iree-buildkite-privileged)"
+      ./build_tools/buildkite/scripts/wait_for_pipeline_success.py gcmn-test-pipeline
diff --git a/build_tools/buildkite/pipelines/presubmit.yml b/build_tools/buildkite/pipelines/presubmit.yml
new file mode 100644
index 0000000..815a57c
--- /dev/null
+++ b/build_tools/buildkite/pipelines/presubmit.yml
@@ -0,0 +1,53 @@
+# Copyright 2022 The IREE Authors
+#
+# Licensed under the Apache License v2.0 with LLVM Exceptions.
+# See https://llvm.org/LICENSE.txt for license information.
+# SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception
+
+# Note: this runs on "security: submitted" agents, since this pipeline itself is
+# submitted code and it fetches the scripts only from the main repository.
+agents:
+  queue: "orchestration"
+  security: "submitted"
+
+steps:
+  - label: ":hiking_boot: Bootstrapping presubmit pipeline"
+    # If this PR is coming from the main repo we can use its configs. Otherwise
+    # we proceed with the configs from the main repo.
+    if: "!build.pull_request.repository.fork"
+    env:
+      CUSTOM_REF: ${BUILDKITE_COMMIT}
+      PIPELINE_BOOTSTRAPPED: ${PIPELINE_BOOTSTRAPPED:-false}
+    commands: |
+      ./build_tools/buildkite/scripts/bootstrap_pipeline.sh \
+          build_tools/buildkite/pipelines/presubmit.yml
+
+  - wait
+
+  # TODO: Is there a better emoji here? Google logo (would have to add to
+  # Buildkite)?
+  - label: ":face_with_monocle: :admission_tickets: :raised_hand: Checking CLA"
+    plugins:
+      - https://github.com/GMNGeoffrey/smooth-checkout-buildkite-plugin#4e353abe72:
+          repos:
+            - config:
+                - url: ${BUILDKITE_REPO}
+                  ref: ${CUSTOM_REF:-main}
+    commands: |
+      ./build_tools/buildkite/scripts/check_cla.py ${BUILDKITE_COMMIT} \
+          || buildkite-agent pipeline upload \
+              build_tools/buildkite/pipelines/include/cla-failure.yml
+
+  - wait
+
+  - label: "Executing gcmn-test-pipeline"
+    plugins:
+      - https://github.com/GMNGeoffrey/smooth-checkout-buildkite-plugin#4e353abe72:
+          repos:
+            - config:
+                - url: ${BUILDKITE_REPO}
+                  ref: ${CUSTOM_REF:-main}
+    commands: |
+      export BUILDKITE_ACCESS_TOKEN="$(gcloud secrets versions access latest \
+          --secret=iree-buildkite-presubmit-pipelines)"
+      ./build_tools/buildkite/scripts/wait_for_pipeline_success.py gcmn-test-pipeline
diff --git a/build_tools/buildkite/postsubmit.yml b/build_tools/buildkite/postsubmit.yml
deleted file mode 100644
index 4113140..0000000
--- a/build_tools/buildkite/postsubmit.yml
+++ /dev/null
@@ -1,14 +0,0 @@
-# Copyright 2022 The IREE Authors
-#
-# Licensed under the Apache License v2.0 with LLVM Exceptions.
-# See https://llvm.org/LICENSE.txt for license information.
-# SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception
-
-steps:
-  - label: "Execute gcmn-test-pipeline"
-    agents:
-      queue: "orchestration"
-      security: "submitted"
-    commands: |
-      export BUILDKITE_ACCESS_TOKEN="$(gcloud secrets versions access latest --secret=iree-buildkite-presubmit-pipelines)"
-      ./build_tools/buildkite/wait_for_pipeline_success.py gcmn-test-pipeline
diff --git a/build_tools/buildkite/postsubmit_bootstrap.yml b/build_tools/buildkite/postsubmit_bootstrap.yml
deleted file mode 100644
index 60be3f6..0000000
--- a/build_tools/buildkite/postsubmit_bootstrap.yml
+++ /dev/null
@@ -1,17 +0,0 @@
-# Copyright 2022 The IREE Authors
-#
-# Licensed under the Apache License v2.0 with LLVM Exceptions.
-# See https://llvm.org/LICENSE.txt for license information.
-# SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception
-
-# Note that this file is *not* proccessed with "upload-pipeline" because then we
-# have infinite regress. For now, this is checked in here and then copy-pasted
-# into the Buildkite UI. Automatic updates to follow.
-
-steps:
-  - label: ":pipeline: Uploading postsubmit pipeline"
-    agents:
-      queue: "orchestration"
-      security: "submitted"
-    commands: |
-      buildkite-agent pipeline upload build_tools/buildkite/postsubmit.yml
diff --git a/build_tools/buildkite/presubmit.yml b/build_tools/buildkite/presubmit.yml
deleted file mode 100644
index f29e358..0000000
--- a/build_tools/buildkite/presubmit.yml
+++ /dev/null
@@ -1,44 +0,0 @@
-# Copyright 2022 The IREE Authors
-#
-# Licensed under the Apache License v2.0 with LLVM Exceptions.
-# See https://llvm.org/LICENSE.txt for license information.
-# SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception
-
-# Note: this runs on "security: submitted" agents, since this pipeline itself is
-# submitted code and it fetches the scripts from the main branch.
-
-steps:
-  # TODO: Is there a better emoji here? Google logo (would have to add to
-  # Buildkite)?
-  - label: ":face_with_monocle: :admission_tickets: :raised_hand: Check CLA"
-    agents:
-      queue: "orchestration"
-      security: "submitted"
-    # We're not checking out from the PR and besides saving time, we really
-    # don't want to because we don't want unsubmitted code on these executors at
-    # all.
-    plugins:
-      - thedyrt/skip-checkout#0870506e0b5d4becc164c6e64dbc331938f0bdcf:
-          cd: /tmp/
-    commands: |
-      curl \
-        https://raw.githubusercontent.com/google/iree/main/build_tools/buildkite/check_cla.py \
-        | python3 - ${BUILDKITE_COMMIT} \
-      || curl \
-        https://raw.githubusercontent.com/google/iree/main/build_tools/buildkite/cla_failure.yml \
-        | buildkite-agent pipeline upload
-
-  - wait
-
-  - label: "Execute gcmn-test-pipeline"
-    agents:
-      queue: "orchestration"
-      security: "submitted"
-    plugins:
-      - thedyrt/skip-checkout#0870506e0b5d4becc164c6e64dbc331938f0bdcf:
-          cd: /tmp/
-    commands: |
-      export BUILDKITE_ACCESS_TOKEN="$(gcloud secrets versions access latest --secret=iree-buildkite-presubmit-pipelines)"
-      curl \
-        https://raw.githubusercontent.com/google/iree/main/build_tools/buildkite/wait_for_pipeline_success.py \
-        | python3 - gcmn-test-pipeline
diff --git a/build_tools/buildkite/presubmit_bootstrap.yml b/build_tools/buildkite/presubmit_bootstrap.yml
deleted file mode 100644
index 6f8a730..0000000
--- a/build_tools/buildkite/presubmit_bootstrap.yml
+++ /dev/null
@@ -1,28 +0,0 @@
-# Copyright 2022 The IREE Authors
-#
-# Licensed under the Apache License v2.0 with LLVM Exceptions.
-# See https://llvm.org/LICENSE.txt for license information.
-# SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception
-
-# Note that this file is *not* proccessed with "upload-pipeline" on presubmit
-# because then it would be editable by PRs from forks. For now, this is checked
-# in here and then copy-pasted into the Buildkite UI. Automatic updates to
-# follow.
-
-steps:
-  - label: ":pipeline: Uploading presubmit pipeline"
-    agents:
-      queue: "orchestration"
-      security: "submitted"
-    # We're not checking out from the PR and besides saving time, we really
-    # don't want to because we don't want unsubmitted code on these executors at
-    # all.
-    plugins:
-      - thedyrt/skip-checkout#0870506e0b5d4becc164c6e64dbc331938f0bdcf:
-          cd: /tmp/
-    commands: |
-      # Piped rather than wget to avoid unnecessary intermediate files (that can
-      # cause confusion if not handled properly)
-      curl \
-        https://raw.githubusercontent.com/google/iree/main/build_tools/buildkite/presubmit.yml \
-        | buildkite-agent pipeline upload
diff --git a/build_tools/buildkite/scripts/bootstrap_pipeline.sh b/build_tools/buildkite/scripts/bootstrap_pipeline.sh
new file mode 100755
index 0000000..bcafd41
--- /dev/null
+++ b/build_tools/buildkite/scripts/bootstrap_pipeline.sh
@@ -0,0 +1,28 @@
+#!/bin/bash
+
+# Copyright 2022 The IREE Authors
+#
+# Licensed under the Apache License v2.0 with LLVM Exceptions.
+# See https://llvm.org/LICENSE.txt for license information.
+# SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception
+
+# Uploads the given pipeline if it hasn't already been bootstrapped. Common
+# usage is for a pipeline to call this on itself.
+
+set -euo pipefail
+
+PIPELINE_FILE=${1?}
+
+# Skip when running locally because Buildkite cli doesn't support replacing
+# pipelines (https://github.com/buildkite/cli/issues/122). This isn't too big a
+# limitation because presumably we're already running with the local file that
+# would be bootstrapped.
+if [[ ${BUILDKITE_ORGANIZATION_SLUG} == "local" ]]; then
+  echo "Local run. Skipping bootstrapping."
+  exit 0
+fi
+
+if [[ "${PIPELINE_BOOTSTRAPPED}" == "false" ]]; then
+  export PIPELINE_BOOTSTRAPPED="true"
+  buildkite-agent pipeline upload --replace $PIPELINE_FILE
+fi
diff --git a/build_tools/buildkite/check_cla.py b/build_tools/buildkite/scripts/check_cla.py
similarity index 100%
rename from build_tools/buildkite/check_cla.py
rename to build_tools/buildkite/scripts/check_cla.py
diff --git a/build_tools/buildkite/simulate_buildkite.sh b/build_tools/buildkite/scripts/simulate_buildkite.sh
similarity index 100%
rename from build_tools/buildkite/simulate_buildkite.sh
rename to build_tools/buildkite/scripts/simulate_buildkite.sh
diff --git a/build_tools/buildkite/scripts/update_pipeline_configuration.py b/build_tools/buildkite/scripts/update_pipeline_configuration.py
new file mode 100755
index 0000000..8543bc3
--- /dev/null
+++ b/build_tools/buildkite/scripts/update_pipeline_configuration.py
@@ -0,0 +1,201 @@
+#!/usr/bin/env python3
+# Copyright 2022 The IREE Authors
+#
+# Licensed under the Apache License v2.0 with LLVM Exceptions.
+# See https://llvm.org/LICENSE.txt for license information.
+# SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception
+"""Updates all Buildkite pipelines with the Buildkite API.
+
+This overwrites the configuration of all the Buildkite pipeline. Pipelines
+should be updated this way, not in the UI. Before updating, this checks for a
+specific header in the configuration stored in the API that indicates which
+Buildkite build previously updated it. If the updater was not this same pipeline
+the script fails. If it was a later build in this pipeline, it skips updating
+unless the `--force` flag is passed. This is to avoid situations where an
+earlier build is for some reason running a step after a later build due to race
+conditions or a retry. Buildkite concurrency groups should be used to prevent
+builds from trying to update pipelines simultaneously.
+"""
+
+import argparse
+import glob
+import os
+import subprocess
+import sys
+import urllib
+
+import requests
+from pybuildkite import buildkite
+
+PIPELINE_ROOT_PATH = "build_tools/buildkite/pipelines"
+BUILD_URL_PREAMBLE = "# Automatically updated by Buildkite pipeline"
+
+UPDATE_INFO_HEADER = f"""{BUILD_URL_PREAMBLE} {{build_url}}
+# from pipeline file {{pipeline_file_url}}
+# with script {{script_url}}
+
+"""
+
+
+def get_git_root():
+  return subprocess.run(["git", "rev-parse", "--show-toplevel"],
+                        check=True,
+                        stdout=subprocess.PIPE,
+                        text=True).stdout.strip()
+
+
+def should_update(bk, *, organization, pipeline_file, running_pipeline,
+                  running_build_number):
+  pipeline_to_update, _ = os.path.splitext(os.path.basename(pipeline_file))
+  previous_pipeline_configuration = bk.pipelines().get_pipeline(
+      organization, pipeline_to_update)["configuration"]
+
+  first_line, _ = previous_pipeline_configuration.split("\n", 1)
+  if not first_line.startswith(BUILD_URL_PREAMBLE):
+    print(f"Did not find build url preamble string '{BUILD_URL_PREAMBLE}' in"
+          f" pipeline configuration from Buildkite API. Aborting.")
+    sys.exit(3)
+
+  previous_build_url = first_line[len(BUILD_URL_PREAMBLE):].strip()
+
+  parsed_url = urllib.parse.urlparse(previous_build_url)
+  path_components = parsed_url.path.split("/")
+  # We're just going to be super picky here. If these invariants end up being a
+  # problem, it's easy to relax them.
+  if any((
+      parsed_url.scheme != "https",
+      parsed_url.netloc != "buildkite.com",
+      parsed_url.params != "",
+      parsed_url.query != "",
+      parsed_url.fragment != "",
+      len(path_components) != 5,
+      # Path starts with a slash, so the first component is empty
+      path_components[0] != "",
+      path_components[3] != "builds",
+  )):
+    print(f"URL of build that previously updated the pipeline is not in"
+          f" expected format. Got URL '{previous_build_url}'. Aborting")
+    sys.exit(4)
+  previous_organization = path_components[1]
+  previous_pipeline = path_components[2]
+  previous_build_number = int(path_components[4])
+
+  if previous_organization != organization:
+    print(f"Build was previously updated by a pipeline from a different"
+          f"organization '{previous_organization}' not current organization"
+          f" '{organization}'")
+    sys.exit(5)
+  if previous_pipeline != running_pipeline:
+    print(f"Build was previously updated by a pipeline from a different"
+          f"organization '{previous_pipeline}' not current organization"
+          f" '{running_pipeline}'")
+    sys.exit(5)
+
+  if previous_build_number > running_build_number:
+    print(f"...pipeline was already updated by later build"
+          f" ({previous_build_number}) of this pipeline. Skipping update.")
+    return False
+
+  return True
+
+
+def update_pipeline(bk, *, organization, pipeline_file, running_pipeline,
+                    running_build_number, running_commit):
+  pipeline_to_update, _ = os.path.splitext(os.path.basename(pipeline_file))
+
+  short_running_commit = running_commit[:10]
+  with open(pipeline_file) as f:
+    new_pipeline_configuration = f.read()
+
+  new_build_url = f"https://buildkite.com/{organization}/{running_pipeline}/builds/{running_build_number}"
+  script_relpath = os.path.relpath(__file__)
+  new_script_url = f"https://github.com/google/iree/blob/{short_running_commit}/{script_relpath}"
+  new_pipeline_file_url = f"https://github.com/google/iree/blob/{short_running_commit}/{pipeline_file}"
+
+  header = UPDATE_INFO_HEADER.format(build_url=new_build_url,
+                                     script_url=new_script_url,
+                                     pipeline_file_url=new_pipeline_file_url)
+  new_pipeline_configuration = header + new_pipeline_configuration
+
+  bk.pipelines().update_pipeline(organization=organization,
+                                 pipeline=pipeline_to_update,
+                                 configuration=new_pipeline_configuration)
+  print("...updated successfully")
+
+
+def parse_args():
+  parser = argparse.ArgumentParser(
+      description="Updates the configurations for all Buildkite pipeline.")
+  parser.add_argument(
+      "--force",
+      action="store_true",
+      default=False,
+      help=("Force updates for all pipelines without checking the existing"
+            " configuration. Use with caution."))
+  parser.add_argument(
+      "pipelines",
+      type=str,
+      nargs="*",
+      help="Pipelines to update. Default is all of them.",
+  )
+  return parser.parse_args()
+
+
+def main(args):
+  # A token for the Buildkite API. Needs read/write privileges on builds to
+  # watch and create builds. Within our pipelines we fetch this from secret
+  # manager: https://cloud.google.com/secret-manager. Users can create a
+  # personal token for running this script locally:
+  # https://buildkite.com/docs/apis/managing-api-tokens
+  access_token = os.environ["BUILDKITE_ACCESS_TOKEN"]
+
+  # Buildkite sets these environment variables. See
+  # https://buildkite.com/docs/pipelines/environment-variables. If running
+  # locally you can set locally or use the simulate_buildkite.sh script.
+  organization = os.environ["BUILDKITE_ORGANIZATION_SLUG"]
+  running_pipeline = os.environ["BUILDKITE_PIPELINE_SLUG"]
+  running_build_number = int(os.environ["BUILDKITE_BUILD_NUMBER"])
+  running_commit = os.environ["BUILDKITE_COMMIT"]
+
+  bk = buildkite.Buildkite()
+  bk.set_access_token(access_token)
+
+  git_root = get_git_root()
+  os.chdir(git_root)
+  glob_pattern = os.path.join(PIPELINE_ROOT_PATH, "*.yml")
+
+  pipeline_files = ((
+      os.path.join(PIPELINE_ROOT_PATH, f"{p}.yml") for p in args.pipelines)
+                    if args.pipelines else glob.iglob(glob_pattern))
+  first_error = None
+  if args.force:
+    print("Was passed force, so not checking existing pipeline configurations.")
+  for pipeline_file in pipeline_files:
+    # TODO: Support creating a new pipeline.
+    print(f"Updating from: '{pipeline_file}'...")
+    try:
+      if args.force or should_update(bk,
+                                     organization=organization,
+                                     pipeline_file=pipeline_file,
+                                     running_pipeline=running_pipeline,
+                                     running_build_number=running_build_number):
+        update_pipeline(
+            bk,
+            organization=organization,
+            pipeline_file=pipeline_file,
+            running_pipeline=running_pipeline,
+            running_build_number=running_build_number,
+            running_commit=running_commit,
+        )
+    except Exception as e:
+      if first_error is None:
+        first_error = e
+      print(e)
+
+  if first_error is not None:
+    print("Encountered errors. Stack of first error:")
+    raise first_error
+
+
+if __name__ == "__main__":
+  main(parse_args())
diff --git a/build_tools/buildkite/wait_for_pipeline_success.py b/build_tools/buildkite/scripts/wait_for_pipeline_success.py
similarity index 89%
rename from build_tools/buildkite/wait_for_pipeline_success.py
rename to build_tools/buildkite/scripts/wait_for_pipeline_success.py
index f494cf9..dc7563e 100755
--- a/build_tools/buildkite/wait_for_pipeline_success.py
+++ b/build_tools/buildkite/scripts/wait_for_pipeline_success.py
@@ -4,9 +4,12 @@
 # Licensed under the Apache License v2.0 with LLVM Exceptions.
 # See https://llvm.org/LICENSE.txt for license information.
 # SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception
+"""Potentially triggers and then waits for a Buildkite pipeline.
 
-# Checks if the given pipeline is already running for the given commit, starts
-# it if not, and then waits for it to complete.
+Checks if the given pipeline is already running for the given commit, starts
+it if not, and then waits for it to complete. Exits successfully if the
+triggered (or pre-existing) build succeeds, otherwise fails.
+"""
 
 import argparse
 import os
@@ -14,6 +17,9 @@
 import sys
 import time
 
+# Fake build to return when running locally.
+FAKE_PASSED_BUILD = dict(number=42, state="passed")
+
 
 def get_build_number(build):
   return build.get("number")
@@ -61,7 +67,7 @@
 
     # Buildkite sets these environment variables. See
     # https://buildkite.com/docs/pipelines/environment-variables. If running
-    # locally you can set locally, you can use the simulate_buildkite.sh script.
+    # locally you can set locally or use the simulate_buildkite.sh script.
     organization = os.environ["BUILDKITE_ORGANIZATION_SLUG"]
     commit = os.environ["BUILDKITE_COMMIT"]
     branch = os.environ["BUILDKITE_BRANCH"]
@@ -92,12 +98,22 @@
     )
 
   def get_builds(self):
+    # Avoid API calls when running locally. The local organization doesn't
+    # exist.
+    if self._organization == "local":
+      return [FAKE_PASSED_BUILD]
     return self._buildkite.builds().list_all_for_pipeline(
         organization=self._organization,
         pipeline=self._pipeline,
         commit=self._commit)
 
   def get_build_by_number(self, build_number):
+    # Avoid API calls when running locally. The local organization doesn't
+    # exist.
+    if (self._organization == "local" and
+        build_number == get_build_number(FAKE_PASSED_BUILD)):
+      print("Returning fake build because running locally")
+      return FAKE_PASSED_BUILD
     return self._buildkite.builds().get_build_by_number(self._organization,
                                                         self._pipeline,
                                                         build_number)
@@ -204,7 +220,7 @@
 
 def parse_args():
   parser = argparse.ArgumentParser(
-      description="Waits on the status of the last BuildKite build for a given"
+      description="Waits on the status of the last Buildkite build for a given"
       " commit or creates such a build if none exists")
   parser.add_argument(
       "pipeline", help="The pipeline for which to create and wait for builds")