Repository: conda-forge/conda-smithy

Issue:
enable a feedstock token per CI service
Per suggestions from @isuruf (and I think @wolfv too a while back? possibly others too!), we should have separate tokens per CI service. This will reduce the number of token rotations we need.


Discussion:

Resolving patch:
diff --git a/conda_smithy/cli.py b/conda_smithy/cli.py
--- a/conda_smithy/cli.py
+++ b/conda_smithy/cli.py
@@ -9,9 +9,8 @@
 
 from textwrap import dedent
 
-import conda
+import conda  # noqa
 import conda_build.api
-from distutils.version import LooseVersion
 from conda_build.metadata import MetaData
 from conda_smithy.utils import get_feedstock_name_from_meta, merge_dict
 from ruamel.yaml import YAML
@@ -63,7 +62,7 @@ def generate_feedstock_content(target_directory, source_recipe_dir):
         try:
             with open(forge_yml_recipe, "r") as fp:
                 _cfg = yaml.load(fp.read())
-        except:
+        except Exception:
             _cfg = {}
 
         with open(forge_yml, "r") as fp:
@@ -676,7 +675,7 @@ class GenerateFeedstockToken(Subcommand):
     def __init__(self, parser):
         super(GenerateFeedstockToken, self).__init__(
             parser,
-            "Generate a feedstock token at ~/.conda-smithy/{user or org}_{project}.token",
+            "Generate a feedstock token.",
         )
         scp = self.subcommand_parser
         scp.add_argument(
@@ -684,6 +683,11 @@ def __init__(self, parser):
             default=feedstock_io.get_repo_root(os.getcwd()) or os.getcwd(),
             help="The directory of the feedstock git repository.",
         )
+        scp.add_argument(
+            "--unique-token-per-provider",
+            action="store_true",
+            help="If set, use a unique token per CI provider.",
+        )
         group = scp.add_mutually_exclusive_group()
         group.add_argument(
             "--user", help="github username under which to register this repo"
@@ -697,16 +701,39 @@ def __init__(self, parser):
     def __call__(self, args):
         from conda_smithy.feedstock_tokens import (
             generate_and_write_feedstock_token,
+            feedstock_token_local_path,
         )
 
         owner = args.user or args.organization
         repo = os.path.basename(os.path.abspath(args.feedstock_directory))
 
-        generate_and_write_feedstock_token(owner, repo)
-        print(
-            "Your feedstock token has been generated at ~/.conda-smithy/%s_%s.token\n"
-            "This token is stored in plaintext so be careful!" % (owner, repo)
-        )
+        if not args.unique_token_per_provider:
+            generate_and_write_feedstock_token(owner, repo)
+            print(
+                "Your feedstock token has been generated at %s\n"
+                "This token is stored in plaintext so be careful!"
+                % feedstock_token_local_path(owner, repo)
+            )
+        else:
+            for provider in [
+                "azure",
+                "travis",
+                "circle",
+                "drone",
+                "github_actions",
+            ]:
+                generate_and_write_feedstock_token(
+                    owner, repo, provider=provider
+                )
+                print(
+                    "Your feedstock token has been generated at %s\n"
+                    "This token is stored in plaintext so be careful!"
+                    % (
+                        feedstock_token_local_path(
+                            owner, repo, provider=provider
+                        )
+                    )
+                )
 
 
 class RegisterFeedstockToken(Subcommand):
@@ -738,6 +765,11 @@ def __init__(self, parser):
                 "{user or org}/feedstock-tokens on GitHub."
             ),
         )
+        scp.add_argument(
+            "--unique-token-per-provider",
+            action="store_true",
+            help="If set, use a unique token per CI provider.",
+        )
         group = scp.add_mutually_exclusive_group()
         group.add_argument(
             "--user", help="github username under which to register this repo"
@@ -768,9 +800,8 @@ def __init__(self, parser):
 
     def __call__(self, args):
         from conda_smithy.feedstock_tokens import (
-            register_feedstock_token_with_proviers,
+            register_feedstock_token_with_providers,
             register_feedstock_token,
-            feedstock_token_exists,
         )
         from conda_smithy.ci_register import drone_default_endpoint
 
@@ -789,15 +820,10 @@ def __call__(self, args):
         else:
             token_repo = args.token_repo
 
-        if feedstock_token_exists(owner, repo, token_repo):
-            raise RuntimeError(
-                "Token for repo %s/%s already exists!" % (owner, repo)
-            )
-
         print("Registering the feedstock tokens. Can take up to ~30 seconds.")
 
         # do all providers first
-        register_feedstock_token_with_proviers(
+        register_feedstock_token_with_providers(
             owner,
             repo,
             drone=args.drone,
@@ -806,10 +832,32 @@ def __call__(self, args):
             azure=args.azure,
             github_actions=args.github_actions,
             drone_endpoints=drone_endpoints,
+            unique_token_per_provider=args.unique_token_per_provider,
         )
 
         # then if that works do the github repo
-        register_feedstock_token(owner, repo, token_repo)
+        if args.unique_token_per_provider:
+            for ci in [
+                "azure",
+                "travis",
+                "circle",
+                "drone",
+                "github_actions",
+            ]:
+                if getattr(args, ci):
+                    register_feedstock_token(
+                        owner,
+                        repo,
+                        token_repo,
+                        provider=ci,
+                    )
+        else:
+            register_feedstock_token(
+                owner,
+                repo,
+                token_repo,
+                provider=None,
+            )
 
         print("Successfully registered the feedstock token!")
 
diff --git a/conda_smithy/feedstock_tokens.py b/conda_smithy/feedstock_tokens.py
--- a/conda_smithy/feedstock_tokens.py
+++ b/conda_smithy/feedstock_tokens.py
@@ -12,6 +12,10 @@
 The `generate-feedstock-token` command must be called before the `register-feedstock-token`
 command. It generates a random token and writes it to
 
+    ~/.conda-smithy/{user or org}_{repo}_{ci}.token
+
+or
+
     ~/.conda-smithy/{user or org}_{repo}.token
 
 Then when you call `register-feedstock-token`, the generated token is placed
@@ -22,20 +26,56 @@
 import tempfile
 import os
 import json
-import sys
+import time
 import secrets
 import hmac
-from contextlib import redirect_stderr, redirect_stdout
+from contextlib import redirect_stderr, redirect_stdout, contextmanager
 
 import git
 import requests
 import scrypt
 
 
-def generate_and_write_feedstock_token(user, project):
-    """Generate a feedstock token and write it to
+class FeedstockTokenError(Exception):
+    """Custom exception for sanitized token errors."""
+
+
+@contextmanager
+def _secure_io():
+    """context manager that redirects stdout and
+    stderr to /dev/null to avoid spilling tokens"""
+
+    if "DEBUG_FEEDSTOCK_TOKENS" in os.environ:
+        yield
+    else:
+        # the redirect business
+        with open(os.devnull, "w") as fp:
+            with redirect_stdout(fp), redirect_stderr(fp):
+                yield
+
+
+def feedstock_token_local_path(user, project, provider=None):
+    """Return the path locally where the feedstock
+    token is stored.
+    """
+    if provider is None:
+        pth = os.path.join(
+            "~",
+            ".conda-smithy",
+            "%s_%s.token" % (user, project),
+        )
+    else:
+        pth = os.path.join(
+            "~",
+            ".conda-smithy",
+            "%s_%s_%s.token" % (user, project, provider),
+        )
+    return os.path.expanduser(pth)
+
 
-        ~/.conda-smithy/{user or org}_{repo}_feedstock.token
+def generate_and_write_feedstock_token(user, project, provider=None):
+    """Generate a feedstock token and write it to the file given by
+    ``feedstock_token_local_path(user, project, provider=provider)``.
 
     This function will fail if the token file already exists.
     """
@@ -43,22 +83,21 @@ def generate_and_write_feedstock_token(user, project):
     # spill tokens
     failed = False
     err_msg = None
-    with open(os.devnull, "w") as fp, redirect_stdout(fp), redirect_stderr(fp):
+    with _secure_io():
         try:
             token = secrets.token_hex(32)
-            pth = os.path.join(
-                "~",
-                ".conda-smithy",
-                "%s_%s.token" % (user, project),
-            )
-            pth = os.path.expanduser(pth)
+            pth = feedstock_token_local_path(user, project, provider=provider)
             if os.path.exists(pth):
                 failed = True
-                err_msg = "Token for %s/%s is already written locally!" % (
-                    user,
-                    project,
+                err_msg = (
+                    "Token for %s/%s on provider%s is already written locally!"
+                    % (
+                        user,
+                        project,
+                        "" if provider is None else " " + provider,
+                    )
                 )
-                raise RuntimeError(err_msg)
+                raise FeedstockTokenError(err_msg)
 
             os.makedirs(os.path.dirname(pth), exist_ok=True)
 
@@ -71,24 +110,23 @@ def generate_and_write_feedstock_token(user, project):
 
     if failed:
         if err_msg:
-            raise RuntimeError(err_msg)
+            raise FeedstockTokenError(err_msg)
         else:
-            raise RuntimeError(
+            raise FeedstockTokenError(
                 (
-                    "Generating the feedstock token for %s/%s failed!"
+                    "Generating the feedstock token for %s/%s on provider%s failed!"
                     " Try the command locally with DEBUG_FEEDSTOCK_TOKENS"
                     " defined in the environment to investigate!"
                 )
-                % (user, project)
+                % (user, project, "" if provider is None else " " + provider)
             )
 
     return failed
 
 
-def read_feedstock_token(user, project):
-    """Read the feedstock token from
-
-        ~/.conda-smithy/{user or org}_{repo}.token
+def read_feedstock_token(user, project, provider=None):
+    """Read the feedstock token from the path given by
+    ``feedstock_token_local_path(user, project, provider=provider)``.
 
     In order to not spill any tokens to stdout/stderr, this function
     should be used in a `try...except` block with stdout/stderr redirected
@@ -98,30 +136,22 @@ def read_feedstock_token(user, project):
     feedstock_token = None
 
     # read the token
-    user_token_pth = os.path.join(
-        "~",
-        ".conda-smithy",
-        "%s_%s.token" % (user, project),
+    user_token_pth = feedstock_token_local_path(
+        user, project, provider=provider
     )
-    user_token_pth = os.path.expanduser(user_token_pth)
 
     if not os.path.exists(user_token_pth):
-        err_msg = "No token found in '~/.conda-smithy/%s_%s.token'" % (
-            user,
-            project,
-        )
+        err_msg = "No token found in '%s'" % user_token_pth
     else:
         with open(user_token_pth, "r") as fp:
             feedstock_token = fp.read().strip()
         if not feedstock_token:
-            err_msg = (
-                "Empty token found in '~/.conda-smithy/" "%s_%s.token'"
-            ) % (user, project)
+            err_msg = "Empty token found in '%s'" % user_token_pth
             feedstock_token = None
     return feedstock_token, err_msg
 
 
-def feedstock_token_exists(user, project, token_repo):
+def feedstock_token_exists(user, project, token_repo, provider=None):
     """Test if the feedstock token exists for the given repo.
 
     All exceptions are swallowed and stdout/stderr from this function is
@@ -138,9 +168,7 @@ def feedstock_token_exists(user, project, token_repo):
     exists = False
     failed = False
     err_msg = None
-    with tempfile.TemporaryDirectory() as tmpdir, open(
-        os.devnull, "w"
-    ) as fp, redirect_stdout(fp), redirect_stderr(fp):
+    with _secure_io(), tempfile.TemporaryDirectory() as tmpdir:
         try:
             # clone the repo
             _token_repo = (
@@ -157,7 +185,20 @@ def feedstock_token_exists(user, project, token_repo):
             )
 
             if os.path.exists(token_file):
-                exists = True
+                with open(token_file, "r") as fp:
+                    token_data = json.load(fp)
+
+                if "tokens" not in token_data:
+                    token_data = {"tokens": [token_data]}
+
+                now = time.time()
+                for td in token_data["tokens"]:
+                    _provider = td.get("provider", None)
+                    _expires_at = td.get("expires_at", None)
+                    if ((_provider is None) or (_provider == provider)) and (
+                        (_expires_at is None) or (_expires_at > now)
+                    ):
+                        exists = True
         except Exception as e:
             if "DEBUG_FEEDSTOCK_TOKENS" in os.environ:
                 raise e
@@ -165,21 +206,23 @@ def feedstock_token_exists(user, project, token_repo):
 
     if failed:
         if err_msg:
-            raise RuntimeError(err_msg)
+            raise FeedstockTokenError(err_msg)
         else:
-            raise RuntimeError(
+            raise FeedstockTokenError(
                 (
-                    "Testing for the feedstock token for %s/%s failed!"
+                    "Testing for the feedstock token for %s/%s on provider%s failed!"
                     " Try the command locally with DEBUG_FEEDSTOCK_TOKENS"
                     " defined in the environment to investigate!"
                 )
-                % (user, project)
+                % (user, project, "" if provider is None else " " + provider)
             )
 
     return exists
 
 
-def is_valid_feedstock_token(user, project, feedstock_token, token_repo):
+def is_valid_feedstock_token(
+    user, project, feedstock_token, token_repo, provider=None
+):
     """Test if the input feedstock_token is valid.
 
     All exceptions are swallowed and stdout/stderr from this function is
@@ -199,9 +242,7 @@ def is_valid_feedstock_token(user, project, feedstock_token, token_repo):
 
     # capture stdout, stderr and suppress all exceptions so we don't
     # spill tokens
-    with tempfile.TemporaryDirectory() as tmpdir, open(
-        os.devnull, "w"
-    ) as fp, redirect_stdout(fp), redirect_stderr(fp):
+    with _secure_io(), tempfile.TemporaryDirectory() as tmpdir:
         try:
             # clone the repo
             _token_repo = (
@@ -221,37 +262,48 @@ def is_valid_feedstock_token(user, project, feedstock_token, token_repo):
                 with open(token_file, "r") as fp:
                     token_data = json.load(fp)
 
-                salted_token = scrypt.hash(
-                    feedstock_token,
-                    bytes.fromhex(token_data["salt"]),
-                    buflen=256,
-                )
+                if "tokens" not in token_data:
+                    token_data = {"tokens": [token_data]}
+
+                now = time.time()
+                for td in token_data["tokens"]:
+                    _provider = td.get("provider", None)
+                    _expires_at = td.get("expires_at", None)
+                    if ((_provider is None) or (_provider == provider)) and (
+                        (_expires_at is None) or (_expires_at > now)
+                    ):
+                        salted_token = scrypt.hash(
+                            feedstock_token,
+                            bytes.fromhex(td["salt"]),
+                            buflen=256,
+                        )
 
-                valid = hmac.compare_digest(
-                    salted_token,
-                    bytes.fromhex(token_data["hashed_token"]),
-                )
+                        valid = hmac.compare_digest(
+                            salted_token,
+                            bytes.fromhex(td["hashed_token"]),
+                        )
         except Exception as e:
             if "DEBUG_FEEDSTOCK_TOKENS" in os.environ:
                 raise e
             failed = True
+
     if failed:
         if err_msg:
-            raise RuntimeError(err_msg)
+            raise FeedstockTokenError(err_msg)
         else:
-            raise RuntimeError(
+            raise FeedstockTokenError(
                 (
-                    "Validating the feedstock token for %s/%s failed!"
+                    "Validating the feedstock token for %s/%s on provider%s failed!"
                     " Try the command locally with DEBUG_FEEDSTOCK_TOKENS"
                     " defined in the environment to investigate!"
                 )
-                % (user, project)
+                % (user, project, "" if provider is None else " " + provider)
             )
 
     return valid
 
 
-def register_feedstock_token(user, project, token_repo):
+def register_feedstock_token(user, project, token_repo, provider=None):
     """Register the feedstock token with the token repo.
 
     This function uses a random salt and scrypt to hash the feedstock
@@ -274,14 +326,14 @@ def register_feedstock_token(user, project, token_repo):
 
     # capture stdout, stderr and suppress all exceptions so we don't
     # spill tokens
-    with tempfile.TemporaryDirectory() as tmpdir, open(
-        os.devnull, "w"
-    ) as fp, redirect_stdout(fp), redirect_stderr(fp):
+    with _secure_io(), tempfile.TemporaryDirectory() as tmpdir:
         try:
-            feedstock_token, err_msg = read_feedstock_token(user, project)
+            feedstock_token, err_msg = read_feedstock_token(
+                user, project, provider=provider
+            )
             if err_msg:
                 failed = True
-                raise RuntimeError(err_msg)
+                raise FeedstockTokenError(err_msg)
 
             # clone the repo
             _token_repo = (
@@ -297,15 +349,14 @@ def register_feedstock_token(user, project, token_repo):
                 project + ".json",
             )
 
-            # don't overwrite existing tokens
-            # check again since there might be a race condition
+            # append the token if needed
             if os.path.exists(token_file):
-                failed = True
-                err_msg = "Token for repo %s/%s already exists!" % (
-                    user,
-                    project,
-                )
-                raise RuntimeError(err_msg)
+                with open(token_file, "r") as fp:
+                    token_data = json.load(fp)
+                if "tokens" not in token_data:
+                    token_data = {"tokens": [token_data]}
+            else:
+                token_data = {"tokens": []}
 
             # salt, encrypt and write
             salt = os.urandom(64)
@@ -314,14 +365,22 @@ def register_feedstock_token(user, project, token_repo):
                 "salt": salt.hex(),
                 "hashed_token": salted_token.hex(),
             }
+            if provider is not None:
+                data["provider"] = provider
+            token_data["tokens"].append(data)
             with open(token_file, "w") as fp:
-                json.dump(data, fp)
+                json.dump(token_data, fp)
 
             # push
             repo.index.add(token_file)
             repo.index.commit(
-                "[ci skip] [skip ci] [cf admin skip] ***NO_CI*** added token for %s/%s"
-                % (user, project)
+                "[ci skip] [skip ci] [cf admin skip] ***NO_CI*** "
+                "added token for %s/%s on provider%s"
+                % (
+                    user,
+                    project,
+                    "" if provider is None else " " + provider,
+                )
             )
             repo.remote().pull(rebase=True)
             repo.remote().push()
@@ -329,23 +388,24 @@ def register_feedstock_token(user, project, token_repo):
             if "DEBUG_FEEDSTOCK_TOKENS" in os.environ:
                 raise e
             failed = True
+
     if failed:
         if err_msg:
-            raise RuntimeError(err_msg)
+            raise FeedstockTokenError(err_msg)
         else:
-            raise RuntimeError(
+            raise FeedstockTokenError(
                 (
-                    "Registering the feedstock token for %s/%s failed!"
+                    "Registering the feedstock token for %s/%s on provider%s failed!"
                     " Try the command locally with DEBUG_FEEDSTOCK_TOKENS"
                     " defined in the environment to investigate!"
                 )
-                % (user, project)
+                % (user, project, "" if provider is None else " " + provider)
             )
 
     return failed
 
 
-def register_feedstock_token_with_proviers(
+def register_feedstock_token_with_providers(
     user,
     project,
     *,
@@ -356,6 +416,7 @@ def register_feedstock_token_with_proviers(
     github_actions=True,
     clobber=True,
     drone_endpoints=(),
+    unique_token_per_provider=False,
 ):
     """Register the feedstock token with provider CI services.
 
@@ -373,127 +434,104 @@ def register_feedstock_token_with_proviers(
     # to generate the proper errors for missing tokens
     from .ci_register import travis_endpoint  # noqa
     from .azure_ci_utils import default_config  # noqa
+    from .ci_register import drone_default_endpoint
+
+    def _register_token(user, project, clobber, provider, func, args=None):
+        args = args or tuple()
+
+        if unique_token_per_provider:
+            feedstock_token, err_msg = read_feedstock_token(
+                user, project, provider=provider
+            )
+        else:
+            feedstock_token, err_msg = read_feedstock_token(
+                user, project, provider=None
+            )
+
+        if err_msg:
+            raise FeedstockTokenError(err_msg)
+
+        try:
+            func(user, project, feedstock_token, clobber, *args)
+        except Exception as e:
+            if "DEBUG_FEEDSTOCK_TOKENS" in os.environ:
+                raise e
+            else:
+                err_msg = (
+                    "Failed to register feedstock token for %s/%s"
+                    " on %s for args %r!"
+                ) % (user, project, provider, args)
+                raise FeedstockTokenError(err_msg)
 
     # capture stdout, stderr and suppress all exceptions so we don't
     # spill tokens
     failed = False
-    err_msg = None
-    with open(os.devnull, "w") as fp:
-        if "DEBUG_FEEDSTOCK_TOKENS" in os.environ:
-            fpo = sys.stdout
-            fpe = sys.stdout
-        else:
-            fpo = fp
-            fpe = fp
-
-        with redirect_stdout(fpo), redirect_stderr(fpe):
-            try:
-                feedstock_token, err_msg = read_feedstock_token(user, project)
-                if err_msg:
-                    failed = True
-                    raise RuntimeError(err_msg)
-
-                if circle:
-                    try:
-                        add_feedstock_token_to_circle(
-                            user, project, feedstock_token, clobber
-                        )
-                    except Exception as e:
-                        if "DEBUG_FEEDSTOCK_TOKENS" in os.environ:
-                            raise e
-                        else:
-                            err_msg = (
-                                "Failed to register feedstock token for %s/%s"
-                                " on circle!"
-                            ) % (user, project)
-                            failed = True
-                            raise RuntimeError(err_msg)
-
-                if drone:
-                    for drone_endpoint in drone_endpoints:
-                        try:
-                            add_feedstock_token_to_drone(
-                                user,
-                                project,
-                                feedstock_token,
-                                clobber,
-                                drone_endpoint,
-                            )
-                        except Exception as e:
-                            if "DEBUG_FEEDSTOCK_TOKENS" in os.environ:
-                                raise e
-                            else:
-                                err_msg = (
-                                    "Failed to register feedstock token for %s/%s"
-                                    " on drone endpoint %s!"
-                                ) % (user, project, drone_endpoint)
-                                failed = True
-                                raise RuntimeError(err_msg)
-
-                if travis:
-                    try:
-                        add_feedstock_token_to_travis(
-                            user, project, feedstock_token, clobber
-                        )
-                    except Exception as e:
-                        if "DEBUG_FEEDSTOCK_TOKENS" in os.environ:
-                            raise e
-                        else:
-                            err_msg = (
-                                "Failed to register feedstock token for %s/%s"
-                                " on travis!"
-                            ) % (user, project)
-                            failed = True
-                            raise RuntimeError(err_msg)
-
-                if azure:
-                    try:
-                        add_feedstock_token_to_azure(
-                            user, project, feedstock_token, clobber
-                        )
-                    except Exception as e:
-                        if "DEBUG_FEEDSTOCK_TOKENS" in os.environ:
-                            raise e
-                        else:
-                            err_msg = (
-                                "Failed to register feedstock token for %s/%s"
-                                " on azure!"
-                            ) % (user, project)
-                            failed = True
-                            raise RuntimeError(err_msg)
-
-                if github_actions:
-                    try:
-                        add_feedstock_token_to_github_actions(
-                            user, project, feedstock_token, clobber
-                        )
-                    except Exception as e:
-                        if "DEBUG_FEEDSTOCK_TOKENS" in os.environ:
-                            raise e
-                        else:
-                            err_msg = (
-                                "Failed to register feedstock token for %s/%s"
-                                " on github actions!"
-                            ) % (user, project)
-                            failed = True
-                            raise RuntimeError(err_msg)
-
-            except Exception as e:
-                if "DEBUG_FEEDSTOCK_TOKENS" in os.environ:
-                    raise e
-                failed = True
-    if failed:
-        if err_msg:
-            raise RuntimeError(err_msg)
-        else:
-            raise RuntimeError(
-                (
-                    "Registering the feedstock token with proviers for %s/%s failed!"
-                    " Try the command locally with DEBUG_FEEDSTOCK_TOKENS"
-                    " defined in the environment to investigate!"
+    drone_endpoints = drone_endpoints or [drone_default_endpoint]
+
+    with _secure_io():
+        try:
+            if circle:
+                _register_token(
+                    user,
+                    project,
+                    clobber,
+                    "circle",
+                    add_feedstock_token_to_circle,
+                )
+
+            if drone:
+                for drone_endpoint in drone_endpoints:
+                    _register_token(
+                        user,
+                        project,
+                        clobber,
+                        "drone",
+                        add_feedstock_token_to_drone,
+                        args=(drone_endpoint,),
+                    )
+
+            if travis:
+                _register_token(
+                    user,
+                    project,
+                    clobber,
+                    "travis",
+                    add_feedstock_token_to_travis,
+                )
+
+            if azure:
+                _register_token(
+                    user,
+                    project,
+                    clobber,
+                    "azure",
+                    add_feedstock_token_to_azure,
                 )
-                % (user, project)
+
+            if github_actions:
+                _register_token(
+                    user,
+                    project,
+                    clobber,
+                    "github_actions",
+                    add_feedstock_token_to_github_actions,
+                )
+        except FeedstockTokenError as e:
+            raise e
+        except Exception as e:
+            if "DEBUG_FEEDSTOCK_TOKENS" in os.environ:
+                raise e
+            failed = True
+
+    if failed:
+        raise FeedstockTokenError(
+            (
+                "Registering the feedstock token with providers for %s/%s failed!"
+                " Try the command locally with DEBUG_FEEDSTOCK_TOKENS"
+                " defined in the environment to investigate!"
             )
+            % (user, project)
+        )
 
 
 def add_feedstock_token_to_circle(user, project, feedstock_token, clobber):


Test patch:
diff --git a/tests/test_feedstock_tokens.py b/tests/test_feedstock_tokens.py
--- a/tests/test_feedstock_tokens.py
+++ b/tests/test_feedstock_tokens.py
@@ -1,6 +1,7 @@
 import os
 import json
 from unittest import mock
+import time
 
 import pytest
 import scrypt
@@ -10,13 +11,40 @@
     read_feedstock_token,
     feedstock_token_exists,
     register_feedstock_token,
-    register_feedstock_token_with_proviers,
+    register_feedstock_token_with_providers,
     is_valid_feedstock_token,
+    FeedstockTokenError,
+    feedstock_token_local_path,
 )
 
 from conda_smithy.ci_register import drone_default_endpoint
 
 
+@pytest.mark.parametrize(
+    "provider,ci,retval_ci",
+    [
+        (None, None, True),  # generic token so OK
+        (None, "azure", True),  # generic token w/ CI so OK
+        ("azure", None, False),  # provider-specific token w/o CI so not OK
+        (
+            "azure",
+            "azure",
+            True,
+        ),  # provider-specific token w/ correct CI so OK
+        (
+            "azure",
+            "blah",
+            False,
+        ),  # provider-specific token w/ wrong CI so not OK
+    ],
+)
+@pytest.mark.parametrize(
+    "expires_at,retval_time",
+    [
+        (time.time() + 1e4, True),  # expires in the future so OK
+        (time.time() - 1e4, False),  # expired in the past so not OK
+    ],
+)
 @pytest.mark.parametrize("project", ["bar", "bar-feedstock"])
 @pytest.mark.parametrize(
     "repo", ["GITHUB_TOKEN", "${GITHUB_TOKEN}", "GH_TOKEN", "${GH_TOKEN}"]
@@ -25,7 +53,17 @@
 @mock.patch("conda_smithy.feedstock_tokens.git")
 @mock.patch("conda_smithy.github.gh_token")
 def test_feedstock_tokens_roundtrip(
-    gh_mock, git_mock, tmp_mock, tmpdir, repo, project
+    gh_mock,
+    git_mock,
+    tmp_mock,
+    tmpdir,
+    repo,
+    project,
+    provider,
+    ci,
+    retval_ci,
+    expires_at,
+    retval_time,
 ):
     gh_mock.return_value = "abc123"
     tmp_mock.TemporaryDirectory.return_value.__enter__.return_value = str(
@@ -33,28 +71,46 @@ def test_feedstock_tokens_roundtrip(
     )
 
     user = "foo"
-    pth = os.path.expanduser("~/.conda-smithy/foo_%s.token" % project)
+    pth = feedstock_token_local_path(
+        user,
+        project,
+        provider=ci,
+    )
     token_json_pth = os.path.join(tmpdir, "tokens", "%s.json" % project)
     os.makedirs(os.path.join(tmpdir, "tokens"), exist_ok=True)
 
     try:
-        generate_and_write_feedstock_token(user, project)
+        generate_and_write_feedstock_token(user, project, provider=ci)
         assert os.path.exists(pth)
 
-        register_feedstock_token(user, project, repo)
+        register_feedstock_token(user, project, repo, provider=ci)
         assert os.path.exists(token_json_pth)
 
+        with open(token_json_pth) as fp:
+            token_data = json.load(fp)
+        if provider is not None:
+            token_data["tokens"][0]["provider"] = provider
+        if expires_at is not None:
+            token_data["tokens"][0]["expires_at"] = expires_at
+        with open(token_json_pth, "w") as fp:
+            fp.write(json.dumps(token_data))
+
         with open(pth, "r") as fp:
             feedstock_token = fp.read().strip()
 
-        retval = is_valid_feedstock_token(user, project, feedstock_token, repo)
+        retval = is_valid_feedstock_token(
+            user, project, feedstock_token, repo, provider=ci
+        )
     finally:
         if os.path.exists(pth):
             os.remove(pth)
+        if os.path.exists(token_json_pth):
+            os.remove(token_json_pth)
 
-    assert retval
+    assert retval is (retval_ci and retval_time)
 
 
+@pytest.mark.parametrize("ci", [None, "azure"])
 @pytest.mark.parametrize("project", ["bar", "bar-feedstock"])
 @pytest.mark.parametrize(
     "repo", ["GITHUB_TOKEN", "${GITHUB_TOKEN}", "GH_TOKEN", "${GH_TOKEN}"]
@@ -63,7 +119,13 @@ def test_feedstock_tokens_roundtrip(
 @mock.patch("conda_smithy.feedstock_tokens.git")
 @mock.patch("conda_smithy.github.gh_token")
 def test_is_valid_feedstock_token_nofile(
-    gh_mock, git_mock, tmp_mock, tmpdir, repo, project
+    gh_mock,
+    git_mock,
+    tmp_mock,
+    tmpdir,
+    repo,
+    project,
+    ci,
 ):
     gh_mock.return_value = "abc123"
     tmp_mock.TemporaryDirectory.return_value.__enter__.return_value = str(
@@ -72,10 +134,26 @@ def test_is_valid_feedstock_token_nofile(
 
     user = "conda-forge"
     feedstock_token = "akdjhfl"
-    retval = is_valid_feedstock_token(user, project, feedstock_token, repo)
+    retval = is_valid_feedstock_token(
+        user, project, feedstock_token, repo, provider=ci
+    )
     assert not retval
 
 
+@pytest.mark.parametrize(
+    "provider,ci",
+    [
+        (None, None),
+        (None, "azure"),
+        ("azure", None),
+        ("azure", "azure"),
+        ("azure", "blah"),
+    ],
+)
+@pytest.mark.parametrize(
+    "expires_at",
+    [(time.time() + 1e4), (time.time() - 1e4)],
+)
 @pytest.mark.parametrize("project", ["bar", "bar-feedstock"])
 @pytest.mark.parametrize(
     "repo", ["GITHUB_TOKEN", "${GITHUB_TOKEN}", "GH_TOKEN", "${GH_TOKEN}"]
@@ -84,7 +162,15 @@ def test_is_valid_feedstock_token_nofile(
 @mock.patch("conda_smithy.feedstock_tokens.git")
 @mock.patch("conda_smithy.github.gh_token")
 def test_is_valid_feedstock_token_badtoken(
-    gh_mock, git_mock, tmp_mock, tmpdir, repo, project
+    gh_mock,
+    git_mock,
+    tmp_mock,
+    tmpdir,
+    repo,
+    project,
+    expires_at,
+    provider,
+    ci,
 ):
     gh_mock.return_value = "abc123"
     tmp_mock.TemporaryDirectory.return_value.__enter__.return_value = str(
@@ -97,45 +183,70 @@ def test_is_valid_feedstock_token_badtoken(
     token_pth = os.path.join(tmpdir, "tokens", "%s.json" % project)
     os.makedirs(os.path.dirname(token_pth), exist_ok=True)
     with open(token_pth, "w") as fp:
-        json.dump({"salt": b"adf".hex(), "hashed_token": b"fgh".hex()}, fp)
-
-    retval = is_valid_feedstock_token(user, project, feedstock_token, repo)
+        td = {"salt": b"adf".hex(), "hashed_token": b"fgh".hex()}
+        if provider is not None:
+            td["provider"] = provider
+        if expires_at is not None:
+            td["expires_at"] = expires_at
+        json.dump({"tokens": [td]}, fp)
+
+    retval = is_valid_feedstock_token(
+        user, project, feedstock_token, repo, provider=ci
+    )
     assert not retval
 
 
-def test_generate_and_write_feedstock_token():
+@pytest.mark.parametrize("ci", [None, "azure"])
+def test_generate_and_write_feedstock_token(ci):
     user = "bar"
     repo = "foo"
 
-    pth = os.path.expanduser("~/.conda-smithy/bar_foo.token")
+    if ci:
+        pth = os.path.expanduser("~/.conda-smithy/bar_foo_%s.token" % ci)
+        opth = os.path.expanduser("~/.conda-smithy/bar_foo.token")
+    else:
+        pth = os.path.expanduser("~/.conda-smithy/bar_foo.token")
+        opth = os.path.expanduser("~/.conda-smithy/bar_foo_azure.token")
 
     try:
-        generate_and_write_feedstock_token(user, repo)
+        generate_and_write_feedstock_token(user, repo, provider=ci)
 
+        assert not os.path.exists(opth)
         assert os.path.exists(pth)
 
+        if ci is not None:
+            generate_and_write_feedstock_token(user, repo, provider=None)
+        else:
+            generate_and_write_feedstock_token(user, repo, provider="azure")
+
         # we cannot do it twice
-        with pytest.raises(RuntimeError):
-            generate_and_write_feedstock_token(user, repo)
+        with pytest.raises(FeedstockTokenError):
+            generate_and_write_feedstock_token(user, repo, provider=ci)
     finally:
         if os.path.exists(pth):
             os.remove(pth)
+        if os.path.exists(opth):
+            os.remove(opth)
 
 
-def test_read_feedstock_token():
+@pytest.mark.parametrize("ci", [None, "azure"])
+def test_read_feedstock_token(ci):
     user = "bar"
     repo = "foo"
-    pth = os.path.expanduser("~/.conda-smithy/bar_foo.token")
+    if ci:
+        pth = os.path.expanduser("~/.conda-smithy/bar_foo_%s.token" % ci)
+    else:
+        pth = os.path.expanduser("~/.conda-smithy/bar_foo.token")
 
     # no token
-    token, err = read_feedstock_token(user, repo)
+    token, err = read_feedstock_token(user, repo, provider=ci)
     assert "No token found in" in err
     assert token is None
 
     # empty
     try:
         os.system("touch " + pth)
-        token, err = read_feedstock_token(user, repo)
+        token, err = read_feedstock_token(user, repo, provider=ci)
         assert "Empty token found in" in err
         assert token is None
     finally:
@@ -144,17 +255,48 @@ def test_read_feedstock_token():
 
     # token ok
     try:
-        generate_and_write_feedstock_token(user, repo)
-        token, err = read_feedstock_token(user, repo)
+        generate_and_write_feedstock_token(user, repo, provider=ci)
+        token, err = read_feedstock_token(user, repo, provider=ci)
         assert err is None
         assert token is not None
 
+        if ci is not None:
+            token, err = read_feedstock_token(user, repo, provider=None)
+        else:
+            token, err = read_feedstock_token(user, repo, provider="azure")
+        assert "No token found in" in err
+        assert token is None
     finally:
         if os.path.exists(pth):
             os.remove(pth)
 
 
-@pytest.mark.parametrize("retval", [True, False])
+@pytest.mark.parametrize(
+    "provider,ci,retval_ci",
+    [
+        (None, None, True),  # generic token so OK
+        (None, "azure", True),  # generic token w/ CI so OK
+        ("azure", None, False),  # provider-specific token w/o CI so not OK
+        (
+            "azure",
+            "azure",
+            True,
+        ),  # provider-specific token w/ correct CI so OK
+        (
+            "azure",
+            "blah",
+            False,
+        ),  # provider-specific token w/ wrong CI so not OK
+    ],
+)
+@pytest.mark.parametrize(
+    "expires_at,retval_time",
+    [
+        (time.time() + 1e4, True),  # expires in the future so OK
+        (time.time() - 1e4, False),  # expired in the past so not OK
+    ],
+)
+@pytest.mark.parametrize("file_exists", [True, False])
 @pytest.mark.parametrize("project", ["bar", "bar-feedstock"])
 @pytest.mark.parametrize(
     "repo", ["$GITHUB_TOKEN", "${GITHUB_TOKEN}", "$GH_TOKEN", "${GH_TOKEN}"]
@@ -163,7 +305,18 @@ def test_read_feedstock_token():
 @mock.patch("conda_smithy.feedstock_tokens.git")
 @mock.patch("conda_smithy.github.gh_token")
 def test_feedstock_token_exists(
-    gh_mock, git_mock, tmp_mock, tmpdir, repo, project, retval
+    gh_mock,
+    git_mock,
+    tmp_mock,
+    tmpdir,
+    repo,
+    project,
+    file_exists,
+    ci,
+    provider,
+    retval_ci,
+    expires_at,
+    retval_time,
 ):
     gh_mock.return_value = "abc123"
     tmp_mock.TemporaryDirectory.return_value.__enter__.return_value = str(
@@ -172,13 +325,20 @@ def test_feedstock_token_exists(
 
     user = "foo"
     os.makedirs(os.path.join(tmpdir, "tokens"), exist_ok=True)
-    if retval:
+    if file_exists:
         with open(
             os.path.join(tmpdir, "tokens", "%s.json" % project), "w"
         ) as fp:
-            fp.write("blarg")
+            data = {"tokens": [{}]}
+            if provider is not None:
+                data["tokens"][0]["provider"] = provider
+            if expires_at is not None:
+                data["tokens"][0]["expires_at"] = expires_at
+            fp.write(json.dumps(data))
+
+    _retval = file_exists and retval_time and retval_ci
 
-    assert feedstock_token_exists(user, project, repo) is retval
+    assert feedstock_token_exists(user, project, repo, provider=ci) is _retval
 
     git_mock.Repo.clone_from.assert_called_once_with(
         "abc123",
@@ -187,6 +347,7 @@ def test_feedstock_token_exists(
     )
 
 
+@pytest.mark.parametrize("ci", [None, "azure"])
 @pytest.mark.parametrize("project", ["bar", "bar-feedstock"])
 @pytest.mark.parametrize(
     "repo", ["$GITHUB_TOKEN", "${GITHUB_TOKEN}", "$GH_TOKEN", "${GH_TOKEN}"]
@@ -195,7 +356,7 @@ def test_feedstock_token_exists(
 @mock.patch("conda_smithy.feedstock_tokens.git")
 @mock.patch("conda_smithy.github.gh_token")
 def test_feedstock_token_raises(
-    gh_mock, git_mock, tmp_mock, tmpdir, repo, project
+    gh_mock, git_mock, tmp_mock, tmpdir, repo, project, ci
 ):
     gh_mock.return_value = "abc123"
     tmp_mock.TemporaryDirectory.return_value.__enter__.return_value = str(
@@ -206,10 +367,10 @@ def test_feedstock_token_raises(
     user = "foo"
     os.makedirs(os.path.join(tmpdir, "tokens"), exist_ok=True)
     with open(os.path.join(tmpdir, "tokens", "%s.json" % project), "w") as fp:
-        fp.write("blarg")
+        fp.write("{}")
 
-    with pytest.raises(RuntimeError) as e:
-        feedstock_token_exists(user, project, repo)
+    with pytest.raises(FeedstockTokenError) as e:
+        feedstock_token_exists(user, project, repo, provider=ci)
 
     assert "Testing for the feedstock token for" in str(e.value)
 
@@ -220,6 +381,7 @@ def test_feedstock_token_raises(
     )
 
 
+@pytest.mark.parametrize("ci", [None, "azure"])
 @pytest.mark.parametrize(
     "repo", ["$GITHUB_TOKEN", "${GITHUB_TOKEN}", "$GH_TOKEN", "${GH_TOKEN}"]
 )
@@ -229,7 +391,14 @@ def test_feedstock_token_raises(
 @mock.patch("conda_smithy.feedstock_tokens.git")
 @mock.patch("conda_smithy.github.gh_token")
 def test_register_feedstock_token_works(
-    gh_mock, git_mock, tmp_mock, osuran_mock, secrets_mock, tmpdir, repo
+    gh_mock,
+    git_mock,
+    tmp_mock,
+    osuran_mock,
+    secrets_mock,
+    tmpdir,
+    repo,
+    ci,
 ):
     gh_mock.return_value = "abc123"
     tmp_mock.TemporaryDirectory.return_value.__enter__.return_value = str(
@@ -241,13 +410,17 @@ def test_register_feedstock_token_works(
     user = "foo"
     project = "bar"
     os.makedirs(os.path.join(tmpdir, "tokens"), exist_ok=True)
-    pth = os.path.expanduser("~/.conda-smithy/foo_%s.token" % project)
+    pth = feedstock_token_local_path(
+        user,
+        project,
+        provider=ci,
+    )
     token_json_pth = os.path.join(tmpdir, "tokens", "%s.json" % project)
 
     try:
-        generate_and_write_feedstock_token(user, project)
+        generate_and_write_feedstock_token(user, project, provider=ci)
 
-        register_feedstock_token(user, project, repo)
+        register_feedstock_token(user, project, repo, provider=ci)
 
     finally:
         if os.path.exists(pth):
@@ -262,8 +435,8 @@ def test_register_feedstock_token_works(
     repo = git_mock.Repo.clone_from.return_value
     repo.index.add.assert_called_once_with(token_json_pth)
     repo.index.commit.assert_called_once_with(
-        "[ci skip] [skip ci] [cf admin skip] ***NO_CI*** added token for %s/%s"
-        % (user, project)
+        "[ci skip] [skip ci] [cf admin skip] ***NO_CI*** added token for %s/%s on provider%s"
+        % (user, project, "" if ci is None else " " + ci)
     )
     repo.remote.return_value.pull.assert_called_once_with(rebase=True)
     repo.remote.return_value.push.assert_called_once_with()
@@ -273,11 +446,14 @@ def test_register_feedstock_token_works(
         "salt": b"\x80SA".hex(),
         "hashed_token": salted_token.hex(),
     }
+    if ci is not None:
+        data["provider"] = ci
 
     with open(token_json_pth, "r") as fp:
-        assert json.load(fp) == data
+        assert json.load(fp) == {"tokens": [data]}
 
 
+@pytest.mark.parametrize("ci", [None, "azure"])
 @pytest.mark.parametrize(
     "repo", ["$GITHUB_TOKEN", "${GITHUB_TOKEN}", "$GH_TOKEN", "${GH_TOKEN}"]
 )
@@ -287,7 +463,14 @@ def test_register_feedstock_token_works(
 @mock.patch("conda_smithy.feedstock_tokens.git")
 @mock.patch("conda_smithy.github.gh_token")
 def test_register_feedstock_token_notoken(
-    gh_mock, git_mock, tmp_mock, osuran_mock, secrets_mock, tmpdir, repo
+    gh_mock,
+    git_mock,
+    tmp_mock,
+    osuran_mock,
+    secrets_mock,
+    tmpdir,
+    repo,
+    ci,
 ):
     gh_mock.return_value = "abc123"
     tmp_mock.TemporaryDirectory.return_value.__enter__.return_value = str(
@@ -299,12 +482,16 @@ def test_register_feedstock_token_notoken(
     user = "foo"
     project = "bar"
     os.makedirs(os.path.join(tmpdir, "tokens"), exist_ok=True)
-    pth = os.path.expanduser("~/.conda-smithy/foo_bar.token")
+    pth = feedstock_token_local_path(
+        user,
+        project,
+        provider=ci,
+    )
     token_json_pth = os.path.join(tmpdir, "tokens", "bar.json")
 
     try:
-        with pytest.raises(RuntimeError) as e:
-            register_feedstock_token(user, project, repo)
+        with pytest.raises(FeedstockTokenError) as e:
+            register_feedstock_token(user, project, repo, provider=ci)
     finally:
         if os.path.exists(pth):
             os.remove(pth)
@@ -322,6 +509,7 @@ def test_register_feedstock_token_notoken(
     assert "No token found in" in str(e.value)
 
 
+@pytest.mark.parametrize("ci", [None, "azure"])
 @pytest.mark.parametrize(
     "repo", ["$GITHUB_TOKEN", "${GITHUB_TOKEN}", "$GH_TOKEN", "${GH_TOKEN}"]
 )
@@ -330,8 +518,15 @@ def test_register_feedstock_token_notoken(
 @mock.patch("conda_smithy.feedstock_tokens.tempfile")
 @mock.patch("conda_smithy.feedstock_tokens.git")
 @mock.patch("conda_smithy.github.gh_token")
-def test_register_feedstock_token_exists_already(
-    gh_mock, git_mock, tmp_mock, osuran_mock, secrets_mock, tmpdir, repo
+def test_register_feedstock_token_append(
+    gh_mock,
+    git_mock,
+    tmp_mock,
+    osuran_mock,
+    secrets_mock,
+    tmpdir,
+    repo,
+    ci,
 ):
     gh_mock.return_value = "abc123"
     tmp_mock.TemporaryDirectory.return_value.__enter__.return_value = str(
@@ -343,17 +538,18 @@ def test_register_feedstock_token_exists_already(
     user = "foo"
     project = "bar"
     os.makedirs(os.path.join(tmpdir, "tokens"), exist_ok=True)
-    pth = os.path.expanduser("~/.conda-smithy/foo_bar.token")
+    pth = feedstock_token_local_path(
+        user,
+        project,
+        provider=ci,
+    )
     token_json_pth = os.path.join(tmpdir, "tokens", "bar.json")
     with open(token_json_pth, "w") as fp:
-        fp.write("blarg")
+        fp.write('{"tokens": [1]}')
 
     try:
-        generate_and_write_feedstock_token(user, project)
-
-        with pytest.raises(RuntimeError) as e:
-            register_feedstock_token(user, project, repo)
-
+        generate_and_write_feedstock_token(user, project, provider=ci)
+        register_feedstock_token(user, project, repo, provider=ci)
     finally:
         if os.path.exists(pth):
             os.remove(pth)
@@ -365,14 +561,27 @@ def test_register_feedstock_token_exists_already(
     )
 
     repo = git_mock.Repo.clone_from.return_value
-    repo.index.add.assert_not_called()
-    repo.index.commit.assert_not_called()
-    repo.remote.return_value.pull.assert_not_called()
-    repo.remote.return_value.push.assert_not_called()
+    repo.index.add.assert_called_once_with(token_json_pth)
+    repo.index.commit.assert_called_once_with(
+        "[ci skip] [skip ci] [cf admin skip] ***NO_CI*** added token for %s/%s on provider%s"
+        % (user, project, "" if ci is None else " " + ci)
+    )
+    repo.remote.return_value.pull.assert_called_once_with(rebase=True)
+    repo.remote.return_value.push.assert_called_once_with()
+
+    salted_token = scrypt.hash("fgh", b"\x80SA", buflen=256)
+    data = {
+        "salt": b"\x80SA".hex(),
+        "hashed_token": salted_token.hex(),
+    }
+    if ci is not None:
+        data["provider"] = ci
 
-    assert "Token for repo foo/bar already exists!" in str(e.value)
+    with open(token_json_pth, "r") as fp:
+        assert json.load(fp) == {"tokens": [1, data]}
 
 
+@pytest.mark.parametrize("unique_token_per_provider", [False, True])
 @pytest.mark.parametrize("drone", [True, False])
 @pytest.mark.parametrize("circle", [True, False])
 @pytest.mark.parametrize("azure", [True, False])
@@ -398,17 +607,26 @@ def test_register_feedstock_token_with_providers(
     travis,
     github_actions,
     clobber,
+    unique_token_per_provider,
 ):
     user = "foo"
     project = "bar"
-
-    pth = os.path.expanduser("~/.conda-smithy/foo_bar.token")
+    providers = [
+        None,
+        "azure",
+        "travis",
+        "circle",
+        "drone",
+        "github_actions",
+    ]
 
     try:
-        generate_and_write_feedstock_token(user, project)
-        feedstock_token, _ = read_feedstock_token(user, project)
+        for provider in providers:
+            generate_and_write_feedstock_token(
+                user, project, provider=provider
+            )
 
-        register_feedstock_token_with_proviers(
+        register_feedstock_token_with_providers(
             user,
             project,
             drone=drone,
@@ -418,9 +636,17 @@ def test_register_feedstock_token_with_providers(
             github_actions=github_actions,
             clobber=clobber,
             drone_endpoints=[drone_default_endpoint],
+            unique_token_per_provider=unique_token_per_provider,
         )
 
         if drone:
+            if unique_token_per_provider:
+                feedstock_token, _ = read_feedstock_token(
+                    user, project, provider="drone"
+                )
+            else:
+                feedstock_token, _ = read_feedstock_token(user, project)
+
             drone_mock.assert_called_once_with(
                 user,
                 project,
@@ -432,6 +658,13 @@ def test_register_feedstock_token_with_providers(
             drone_mock.assert_not_called()
 
         if circle:
+            if unique_token_per_provider:
+                feedstock_token, _ = read_feedstock_token(
+                    user, project, provider="circle"
+                )
+            else:
+                feedstock_token, _ = read_feedstock_token(user, project)
+
             circle_mock.assert_called_once_with(
                 user, project, feedstock_token, clobber
             )
@@ -439,6 +672,13 @@ def test_register_feedstock_token_with_providers(
             circle_mock.assert_not_called()
 
         if travis:
+            if unique_token_per_provider:
+                feedstock_token, _ = read_feedstock_token(
+                    user, project, provider="travis"
+                )
+            else:
+                feedstock_token, _ = read_feedstock_token(user, project)
+
             travis_mock.assert_called_once_with(
                 user, project, feedstock_token, clobber
             )
@@ -446,6 +686,13 @@ def test_register_feedstock_token_with_providers(
             travis_mock.assert_not_called()
 
         if azure:
+            if unique_token_per_provider:
+                feedstock_token, _ = read_feedstock_token(
+                    user, project, provider="azure"
+                )
+            else:
+                feedstock_token, _ = read_feedstock_token(user, project)
+
             azure_mock.assert_called_once_with(
                 user, project, feedstock_token, clobber
             )
@@ -453,16 +700,26 @@ def test_register_feedstock_token_with_providers(
             azure_mock.assert_not_called()
 
         if github_actions:
+            if unique_token_per_provider:
+                feedstock_token, _ = read_feedstock_token(
+                    user, project, provider="github_actions"
+                )
+            else:
+                feedstock_token, _ = read_feedstock_token(user, project)
+
             github_actions_mock.assert_called_once_with(
                 user, project, feedstock_token, clobber
             )
         else:
             github_actions_mock.assert_not_called()
     finally:
-        if os.path.exists(pth):
-            os.remove(pth)
+        for provider in providers:
+            pth = feedstock_token_local_path(user, project, provider=provider)
+            if os.path.exists(pth):
+                os.remove(pth)
 
 
+@pytest.mark.parametrize("unique_token_per_provider", [False, True])
 @pytest.mark.parametrize("drone", [True, False])
 @pytest.mark.parametrize("circle", [True, False])
 @pytest.mark.parametrize("azure", [True, False])
@@ -476,7 +733,7 @@ def test_register_feedstock_token_with_providers(
 @mock.patch(
     "conda_smithy.feedstock_tokens.add_feedstock_token_to_github_actions"
 )
-def test_register_feedstock_token_with_proviers_notoken(
+def test_register_feedstock_token_with_providers_notoken(
     github_actions_mock,
     azure_mock,
     travis_mock,
@@ -488,23 +745,26 @@ def test_register_feedstock_token_with_proviers_notoken(
     travis,
     github_actions,
     clobber,
+    unique_token_per_provider,
 ):
     user = "foo"
     project = "bar"
 
-    with pytest.raises(RuntimeError) as e:
-        register_feedstock_token_with_proviers(
-            user,
-            project,
-            drone=drone,
-            circle=circle,
-            travis=travis,
-            azure=azure,
-            github_actions=github_actions,
-            clobber=clobber,
-        )
+    if any([drone, circle, travis, azure, github_actions]):
+        with pytest.raises(FeedstockTokenError) as e:
+            register_feedstock_token_with_providers(
+                user,
+                project,
+                drone=drone,
+                circle=circle,
+                travis=travis,
+                azure=azure,
+                github_actions=github_actions,
+                clobber=clobber,
+                unique_token_per_provider=unique_token_per_provider,
+            )
 
-    assert "No token" in str(e.value)
+        assert "No token" in str(e.value)
 
     drone_mock.assert_not_called()
     circle_mock.assert_not_called()
@@ -513,8 +773,9 @@ def test_register_feedstock_token_with_proviers_notoken(
     github_actions_mock.assert_not_called()
 
 
+@pytest.mark.parametrize("unique_token_per_provider", [False, True])
 @pytest.mark.parametrize(
-    "provider", ["drone", "circle", "travis", "azure", "github actions"]
+    "provider", ["drone", "circle", "travis", "azure", "github_actions"]
 )
 @mock.patch("conda_smithy.feedstock_tokens.add_feedstock_token_to_drone")
 @mock.patch("conda_smithy.feedstock_tokens.add_feedstock_token_to_circle")
@@ -523,18 +784,25 @@ def test_register_feedstock_token_with_proviers_notoken(
 @mock.patch(
     "conda_smithy.feedstock_tokens.add_feedstock_token_to_github_actions"
 )
-def test_register_feedstock_token_with_proviers_error(
+def test_register_feedstock_token_with_providers_error(
     github_actions_mock,
     azure_mock,
     travis_mock,
     circle_mock,
     drone_mock,
     provider,
+    unique_token_per_provider,
 ):
     user = "foo"
     project = "bar-feedstock"
-
-    pth = os.path.expanduser("~/.conda-smithy/foo_bar-feedstock.token")
+    providers = [
+        None,
+        "azure",
+        "travis",
+        "circle",
+        "drone",
+        "github_actions",
+    ]
 
     if provider == "drone":
         drone_mock.side_effect = ValueError("blah")
@@ -544,19 +812,26 @@ def test_register_feedstock_token_with_proviers_error(
         travis_mock.side_effect = ValueError("blah")
     if provider == "azure":
         azure_mock.side_effect = ValueError("blah")
-    if provider == "github actions":
+    if provider == "github_actions":
         github_actions_mock.side_effect = ValueError("blah")
 
     try:
-        generate_and_write_feedstock_token(user, project)
-        feedstock_token, _ = read_feedstock_token(user, project)
+        for _provider in providers:
+            generate_and_write_feedstock_token(
+                user, project, provider=_provider
+            )
 
-        with pytest.raises(RuntimeError) as e:
-            register_feedstock_token_with_proviers(
-                user, project, drone_endpoints=[drone_default_endpoint]
+        with pytest.raises(FeedstockTokenError) as e:
+            register_feedstock_token_with_providers(
+                user,
+                project,
+                drone_endpoints=[drone_default_endpoint],
+                unique_token_per_provider=unique_token_per_provider,
             )
 
         assert "on %s" % provider in str(e.value)
     finally:
-        if os.path.exists(pth):
-            os.remove(pth)
+        for _provider in providers:
+            pth = feedstock_token_local_path(user, project, provider=_provider)
+            if os.path.exists(pth):
+                os.remove(pth)