RDIT Products mark RDIT PRODUCTS

Read it before you run it

This is the whole setup script, in full, on this page. It registers one instance-wide system hook on your GitLab™ so that pushes and merge requests reach Jira. A GitLab administrator runs it once, inside their own network, against their own instance.

It has no third-party dependencies on purpose. One file, standard library only, so that the person being asked to run it with an administrator token can read every line of it first. That is the entire security argument, and it only works if the source is where you can see it.

DOWNLOAD

gitlab-setup.py

711 lines · 27,958 bytes

SHA-256 · 460a04e004698acb5e90ae4d52e2982427029913690cba4ffda00f1bd9bcf1d5

DOWNLOAD THE SCRIPT BACK TO THE DOCS

Check what you downloaded against the fingerprint above: shasum -a 256 gitlab-setup.py on macOS, sha256sum gitlab-setup.py on Linux.

What it does, in short

Runs onPython 3.6 or newer
Third-party packagesNone. Standard library only: argparse, getpass, hashlib, json, ssl, sys, traceback, urllib
Writes to your GitLabExactly one call — POST /api/v4/hooks or PUT /api/v4/hooks/:id
Deletes anythingNever. The script has no delete call in it
Writes to diskNothing. No config file, no cache, no state
Admin tokenRead from a no-echo prompt or stdin. There is deliberately no --token flag and no environment variable
Dry run--dry-run shows exactly what it would change without writing

How to run it

python3 gitlab-setup.py --gitlab-url https://gitlab.example.com --dry-run
python3 gitlab-setup.py --gitlab-url https://gitlab.example.com

It asks for three things: the web trigger URL and the shared secret, both from the app's configuration screen in Jira, and a GitLab administrator token. Run it again whenever you rotate the shared secret — it finds the hook it created and updates it in place.

The source, in full

#!/usr/bin/env python3
"""Register the Jira integration's GitLab system hook. One run, no stored state.

This script is run by a GitLab administrator, inside their own network, against
their own self-managed GitLab. It registers ONE instance-wide system hook that
delivers push and merge request events to Jira. There is no per-repository
configuration, now or later.

    python3 gitlab-setup.py --gitlab-url https://gitlab.example.com --dry-run
    python3 gitlab-setup.py --gitlab-url https://gitlab.example.com

Run it again whenever you rotate the shared secret in Jira: it finds the hook it
created and updates it in place.

================================ SECURITY ==================================
The admin token is used for this one run and then it is gone. Concretely, and
verifiably in this file:

  * It is read from a no-echo prompt, or from stdin (--token-stdin for the
    token alone, --stdin for an unattended run). There is deliberately NO
    --token flag and no environment variable: both are readable by other
    users on this host (`ps`, /proc/<pid>/environ), and a flag also lands
    in your shell history. Feed --stdin from a file or a password manager,
    never from `echo`, which puts the secret in your history anyway.
  * It is held in one attribute, Session._token, and read by exactly one
    method, Session._call, which puts it in the PRIVATE-TOKEN header.
  * Nothing is written to disk. No config file, no cache, no state. The
    script has no write-to-file call in it at all.
  * It is never printed. Not on the success path, not in an error, not in a
    traceback -- everything printed on a failure path goes through scrub().
    There is no debug or verbose flag that dumps headers or request bodies;
    that flag is exactly how this project leaked a credential once before.
  * Redirects are refused (_NoRedirect), so the header cannot be replayed to
    a host other than the one you named.

The shared secret from the Jira config page is treated the same way, and is
only ever shown as a short fingerprint.

The web trigger URL IS printed in full, deliberately. Its path segment is a
credential, but it is also the thing you are being asked to approve, and you
cannot verify a target you cannot see. The boundary this script enforces is:
nothing on disk, nothing in the argument list, nothing in shell history,
nothing in a shared log. Your own terminal is the intended destination.
============================================================================

Exit codes: 0 success or dry run, 2 bad usage (argparse), 3 refused to act,
4 API or authentication error, 5 TLS verification failed.

Requires Python 3.6+ and nothing else. No third-party packages.
"""

import argparse
import getpass
import hashlib
import json
import ssl
import sys
import traceback
import urllib.error
import urllib.request
from urllib.parse import urlsplit, urlunsplit

EXIT_OK = 0
EXIT_REFUSED = 3
EXIT_API = 4
EXIT_TLS = 5

# The events this hook subscribes to. This set tracks what the Jira app's
# handler actually consumes today (src/index.js: object_kind "push" and
# "merge_request"); everything else it receives it discards. Subscribing to
# an event we discard is not free -- it costs one Forge invocation per
# delivery, on every tag push across every project on the instance.
#
# Every flag is sent explicitly, including the False ones. GitLab documents
# no defaults for these, and repository_update_events has historically
# defaulted to on, so an omitted flag is not the same as an off flag.
#
# tag_push_events comes back when releases ship. Change it here and in the
# app's handler together.
DESIRED = {
    "push_events": True,
    "merge_requests_events": True,
    "tag_push_events": False,
    "repository_update_events": False,
    "enable_ssl_verification": True,
}

EVENT_LABELS = [
    ("push_events", "push events"),
    ("merge_requests_events", "merge request events"),
    ("tag_push_events", "tag push events"),
    ("repository_update_events", "repository update"),
]

# Shown in Admin Area > System Hooks, so a colleague who finds this hook can
# tell what it is instead of deleting it to see what breaks. Older GitLab
# versions reject these two fields; see _write_hook for the fallback.
HOOK_NAME = "Jira development information"
HOOK_DESCRIPTION = (
    "Created by the Jira app's setup script. Sends push and merge request "
    "events to Jira. Delete this hook to disconnect."
)

ACTION_CREATE = "create"
ACTION_UPDATE = "update"
ACTION_REFUSE = "refuse"


class Refused(Exception):
    """A precondition failed. Nothing was written."""


class ApiError(Exception):
    """GitLab said no, or could not be reached."""


class TlsError(Exception):
    """The TLS certificate could not be verified."""


# --------------------------------------------------------------------------
# Pure helpers. No network, no credentials held, unit-tested in selftest.py.
# --------------------------------------------------------------------------

def fingerprint(value):
    """Short, non-reversible fingerprint of a secret, for display only.

    Same convention as the app's src/redact.js: 4 hex chars is 16 bits,
    enough to tell "this is the same secret Jira shows me" apart from "a
    different one", useless for an attack on a 256-bit secret.
    """
    if not value:
        return "(none)"
    return "sha256:" + hashlib.sha256(value.encode("utf-8")).hexdigest()[:4]


def scrub(text, secrets):
    """Replaces any secret that appears in text with its fingerprint.

    Applied to everything printed on a failure path. GitLab's own error
    bodies do not contain our credentials, and a Python traceback does not
    print local variables -- this is the belt to that pair of braces, so
    that "the token is never printed" holds even for a failure nobody
    anticipated.
    """
    out = text
    for secret in secrets:
        if secret:
            out = out.replace(secret, "[REDACTED %s]" % fingerprint(secret))
    return out


def normalize_hook_url(url):
    """Canonical form used to decide whether a hook is ours.

    Scheme and host are case-insensitive per RFC 3986; the path is not, and
    a Forge web trigger path segment is case-sensitive, so it is compared
    byte for byte. One trailing slash is ignored because the GitLab UI adds
    one on occasion. The fragment is dropped -- it is never sent.
    """
    parts = urlsplit(url.strip())
    path = parts.path
    if len(path) > 1 and path.endswith("/"):
        path = path[:-1]
    return urlunsplit((parts.scheme.lower(), parts.netloc.lower(), path,
                       parts.query, ""))


def host_of(url):
    return urlsplit(url).netloc.lower()


def decide(hooks, webtrigger_url):
    """Works out what to do, from the hooks the instance already has.

    Matching is on the web trigger URL and nothing else: GitLab never
    returns a hook's token (only token_present), so comparing secrets is
    impossible by construction.
    """
    target = normalize_hook_url(webtrigger_url)
    target_host = host_of(target)

    matches = []
    same_host_others = []
    for hook in hooks:
        raw = hook.get("url") or ""
        normalized = normalize_hook_url(raw)
        if normalized == target:
            matches.append(hook)
        elif host_of(normalized) == target_host:
            # A Forge web trigger URL's host identifies the app and
            # environment, so a different path on the same host is very
            # likely this same app on an older, rotated URL. "Very likely"
            # is enough to warn about and nowhere near enough to act on:
            # this only ever produces a line of output.
            same_host_others.append(hook)

    plan = {
        "webtrigger_url": webtrigger_url.strip(),
        "desired": dict(DESIRED),
        "same_host_others": same_host_others,
        "duplicates": [],
        "hook_id": None,
        "current": None,
    }

    if len(matches) == 0:
        plan["action"] = ACTION_CREATE
    elif len(matches) == 1:
        plan["action"] = ACTION_UPDATE
        plan["hook_id"] = matches[0].get("id")
        plan["current"] = matches[0]
    else:
        # Never guess which duplicate to keep, and never delete: this script
        # holds instance-wide admin rights for one run, and the extra hook
        # may not even be ours. Refusing costs the admin one click; guessing
        # wrong costs them either double deliveries or a deleted hook.
        plan["action"] = ACTION_REFUSE
        plan["duplicates"] = [h.get("id") for h in matches]

    return plan


def _flag(value):
    if value is True:
        return "on"
    if value is False:
        return "off"
    return "?"


def render_plan(plan, secret):
    """The dry-run output, and the same block printed before a real write."""
    lines = []
    action = plan["action"]

    if action == ACTION_REFUSE:
        ids = ", ".join("#%s" % i for i in plan["duplicates"])
        lines.append("Refusing to continue: %d system hooks already point at "
                     "this URL (%s)." % (len(plan["duplicates"]), ids))
        lines.append("Remove the duplicate in Admin Area > System Hooks, then "
                     "re-run.")
        lines.append("No changes were made.")
        return "\n".join(lines)

    if action == ACTION_UPDATE:
        lines.append("Would UPDATE system hook #%s  - matched on hook URL"
                     % plan["hook_id"])
    else:
        lines.append("Would CREATE a new system hook")
    lines.append("")

    lines.append("  Hook URL      %s" % plan["webtrigger_url"])
    lines.append("  Secret token  %s   (re-sent on every run)"
                 % fingerprint(secret))

    current = plan["current"] or {}
    first = True
    for key, label in EVENT_LABELS:
        prefix = "  Events      " if first else "              "
        first = False
        if action == ACTION_UPDATE:
            lines.append("%s  %-22s %-4s ->  %s"
                         % (prefix, label, _flag(current.get(key)),
                            _flag(plan["desired"][key])))
        else:
            lines.append("%s  %-22s %s"
                         % (prefix, label, _flag(plan["desired"][key])))

    if action == ACTION_UPDATE:
        lines.append("  SSL verify    %-22s %-4s ->  %s"
                     % ("", _flag(current.get("enable_ssl_verification")),
                        _flag(plan["desired"]["enable_ssl_verification"])))
    else:
        lines.append("  SSL verify    %-22s %s"
                     % ("", _flag(plan["desired"]["enable_ssl_verification"])))

    for hook in plan["same_host_others"]:
        lines.append("")
        lines.append("  Note: system hook #%s points at the same host with a "
                     "different path:" % hook.get("id"))
        lines.append("        %s" % hook.get("url"))
        lines.append("        That looks like this app on an older URL. It "
                     "will keep delivering.")
        lines.append("        This script will not touch it - remove it "
                     "yourself if it is stale.")

    return "\n".join(lines)


def check_gitlab_url(url):
    """Rejects a target we would not send an admin token to."""
    parts = urlsplit(url)
    if parts.scheme not in ("http", "https") or not parts.netloc:
        raise Refused("--gitlab-url must be a full URL, for example "
                      "https://gitlab.example.com")
    host = parts.hostname or ""
    if parts.scheme == "http" and host not in ("localhost", "127.0.0.1", "::1"):
        raise Refused(
            "Refusing to send an administrator token over plain http to %s.\n"
            "Use the https URL of your instance." % host)
    return url.rstrip("/")


def check_webtrigger_url(url):
    parts = urlsplit(url)
    if parts.scheme != "https" or not parts.netloc or len(parts.path) < 2:
        raise Refused("That does not look like a web trigger URL. Copy it "
                      "from the app's configuration screen in Jira; it "
                      "starts with https:// and ends in a long path segment.")
    return url.strip()


# --------------------------------------------------------------------------
# Transport. The only code that touches the network.
# --------------------------------------------------------------------------

class _NoRedirect(urllib.request.HTTPRedirectHandler):
    """Refuses every redirect.

    urllib re-sends request headers to the redirect target, so following a
    redirect would hand the PRIVATE-TOKEN header to whatever host the
    response names. An admin token is not something to replay at a location
    chosen by someone else, so a redirect is an error with an instruction
    rather than something handled transparently.
    """

    def redirect_request(self, req, fp, code, msg, headers, newurl):
        raise ApiError(
            "%s redirected to %s.\nUse that URL directly with --gitlab-url; "
            "this script does not follow redirects, because that would send "
            "your token to a host you did not name." % (req.full_url, newurl))


def make_transport(ca_bundle=None):
    """Builds the callable Session uses: (method, url, headers, body) -> tuple.

    Replaced wholesale in selftest.py, which is why the credential handling
    can be tested without a GitLab.
    """
    context = ssl.create_default_context(cafile=ca_bundle)
    opener = urllib.request.build_opener(
        _NoRedirect(), urllib.request.HTTPSHandler(context=context))

    def transport(method, url, headers, body):
        request = urllib.request.Request(url, data=body, headers=headers,
                                         method=method)
        try:
            response = opener.open(request, timeout=30)
        except urllib.error.HTTPError as error:
            # A 4xx is an answer, not a transport failure: read the body so
            # GitLab's own message can be shown.
            return error.code, error.read()
        except urllib.error.URLError as error:
            reason = error.reason
            if isinstance(reason, ssl.SSLError):
                raise TlsError(
                    "Could not verify the TLS certificate of %s (%s).\n"
                    "If your instance uses an internal certificate authority, "
                    "pass its certificate with --ca-bundle /path/to/ca.pem."
                    % (host_of(url), reason))
            raise ApiError("Could not reach %s: %s" % (host_of(url), reason))
        with response:
            return response.getcode(), response.read()

    return transport


class Session:
    """Authenticated GitLab API calls. Holds the admin token; nothing else does.

    read_only=True makes any non-GET raise. That is what --dry-run runs on,
    so "a dry run performs no writes" is enforced here rather than merely
    being true of the current control flow.
    """

    def __init__(self, base_url, token, transport, read_only=False):
        self._base = base_url.rstrip("/")
        self._token = token
        self._transport = transport
        self._read_only = read_only

    def __repr__(self):
        # Explicit, so that no accident of formatting can put the token in a
        # string. The default repr would not have, but this is cheap.
        return "<Session %s read_only=%s>" % (self._base, self._read_only)

    def _call(self, method, path, payload=None):
        if self._read_only and method != "GET":
            raise RuntimeError(
                "read-only session refused a %s request" % method)

        url = self._base + "/api/v4" + path
        headers = {
            "PRIVATE-TOKEN": self._token,  # the token's only destination
            "Accept": "application/json",
            "User-Agent": "jira-gitlab-setup",
        }
        body = None
        if payload is not None:
            body = json.dumps(payload).encode("utf-8")
            headers["Content-Type"] = "application/json"

        status, raw = self._transport(method, url, headers, body)
        try:
            parsed = json.loads(raw.decode("utf-8")) if raw else None
        except ValueError:
            parsed = None

        if status >= 400:
            raise ApiError(_api_message(status, parsed, path))
        return parsed

    def get(self, path):
        return self._call("GET", path)

    def get_all(self, path):
        """Reads every page.

        Paging matters here: the default page size is 20, and a hook that
        exists on page 2 but is not looked for would be silently created a
        second time. Double delivery of every event is exactly the failure
        this script must not produce.
        """
        results = []
        page = 1
        while page <= 50:
            separator = "&" if "?" in path else "?"
            batch = self._call(
                "GET", "%s%sper_page=100&page=%d" % (path, separator, page))
            if not batch:
                break
            results.extend(batch)
            if len(batch) < 100:
                break
            page += 1
        return results

    def post(self, path, payload):
        return self._call("POST", path, payload)

    def put(self, path, payload):
        return self._call("PUT", path, payload)


def _api_message(status, parsed, path):
    detail = ""
    if isinstance(parsed, dict):
        detail = parsed.get("message") or parsed.get("error") or ""
        if not isinstance(detail, str):
            detail = json.dumps(detail)
    if status == 401:
        return ("GitLab rejected the token (401). It is invalid, expired, or "
                "lacks the 'api' scope.")
    if status == 403:
        return ("GitLab refused access (403) on %s. System hooks require an "
                "administrator token; this one is not an administrator's."
                % path)
    if status == 404 and path.startswith("/hooks/"):
        # Two causes, one remedy. Editing a system hook through the API
        # arrived in a later GitLab version than creating one, and
        # self-managed instances lag; the hook may also simply have been
        # deleted since the listing a moment ago. Recreating it fixes both,
        # and deleting-to-work-around is left to the admin rather than done
        # here on a guess.
        return ("PUT %s returned 404: either your GitLab is too old to edit "
                "a system hook through the API, or that hook no longer "
                "exists.\nDelete it in Admin Area > System Hooks if it is "
                "still there, then run this script again to recreate it."
                % path)
    return "GitLab returned %d on %s%s" % (
        status, path, (": " + detail) if detail else "")


# --------------------------------------------------------------------------
# Input. Kept at module level so selftest.py can replace them.
# --------------------------------------------------------------------------

def prompt_visible(text):
    """Reads a visible line, from the terminal even when stdin is a pipe."""
    if sys.stdin.isatty():
        return input(text).strip()
    try:
        tty = open("/dev/tty", "r+")
    except (IOError, OSError):
        raise Refused("No terminal available to ask for the web trigger URL. "
                      "Run this script interactively.")
    try:
        tty.write(text)
        tty.flush()
        return tty.readline().strip()
    finally:
        tty.close()


def prompt_secret(text):
    """No-echo read. getpass uses /dev/tty, so a piped stdin is fine."""
    return getpass.getpass(text).strip()


def read_token_from_stdin():
    line = sys.stdin.readline()
    if not line.strip():
        raise Refused("--token-stdin was given but stdin was empty.")
    return line.strip()


# The three values, in the order --stdin expects them.
STDIN_FIELDS = ("web trigger URL", "shared secret", "administrator token")


def read_all_from_stdin():
    """Reads the three inputs from stdin, one per line, for an unattended run.

    Why this exists as well as the prompts: with prompts only, the script
    cannot run where there is no terminal -- CI, configuration management,
    or a demonstration -- and something that cannot be exercised
    unattended does not get exercised.

    It keeps the same property the prompts have and the reason there is no
    --token flag: the values arrive on a file descriptor, not in the
    argument list, so they stay out of `ps` and out of shell history.
    Feed it from a file or a password manager, never from `echo`, which
    puts the secret in your history itself.
    """
    values = []
    for index, field in enumerate(STDIN_FIELDS):
        line = sys.stdin.readline()
        if not line.strip():
            raise Refused(
                "--stdin expects %d lines: %s.\nLine %d (%s) was empty or "
                "missing." % (len(STDIN_FIELDS), ", ".join(STDIN_FIELDS),
                              index + 1, field))
        values.append(line.strip())
    return values


# --------------------------------------------------------------------------
# The run itself.
# --------------------------------------------------------------------------

def parse_args(argv):
    parser = argparse.ArgumentParser(
        description="Register the Jira integration's GitLab system hook.",
        epilog="The administrator token is used for this run only and is "
               "never stored, printed or written to disk.")
    parser.add_argument("--gitlab-url", required=True,
                        help="Base URL of your GitLab, e.g. "
                             "https://gitlab.example.com")
    parser.add_argument("--dry-run", action="store_true",
                        help="Show exactly what would change and make no "
                             "write calls at all.")
    parser.add_argument("--token-stdin", action="store_true",
                        help="Read the administrator token from stdin instead "
                             "of prompting. The other two are still asked "
                             "for at the terminal.")
    parser.add_argument("--stdin", action="store_true",
                        help="Unattended: read all three inputs from stdin, "
                             "one per line, in the order %s."
                             % ", ".join(STDIN_FIELDS))
    parser.add_argument("--ca-bundle", metavar="PATH",
                        help="Certificate authority bundle, for an instance "
                             "with an internal certificate.")
    args = parser.parse_args(argv)
    if args.stdin and args.token_stdin:
        parser.error("--stdin already reads the token; drop --token-stdin.")
    return args


def _write_hook(session, plan, secret):
    """Creates or updates, with a fallback for older GitLab versions.

    name and description arrived in later GitLab versions. They are worth
    sending -- an unlabelled hook in the admin UI is one a colleague deletes
    to find out what it was -- but not worth failing over, so a 400 is
    retried once without them.
    """
    payload = dict(plan["desired"])
    payload["url"] = plan["webtrigger_url"]
    payload["token"] = secret

    labelled = dict(payload)
    labelled["name"] = HOOK_NAME
    labelled["description"] = HOOK_DESCRIPTION

    if plan["action"] == ACTION_CREATE:
        send = lambda body: session.post("/hooks", body)
    else:
        send = lambda body: session.put("/hooks/%s" % plan["hook_id"], body)

    try:
        return send(labelled)
    except ApiError as error:
        if "returned 400" not in str(error):
            raise
        return send(payload)


def _run(args, creds):
    gitlab_url = check_gitlab_url(args.gitlab_url)

    if args.stdin:
        webtrigger_raw, secret, token = read_all_from_stdin()
        creds["secret"] = secret
        creds["token"] = token
    else:
        print("Paste these two from the app's configuration screen in Jira.")
        webtrigger_raw = prompt_visible("  Web trigger URL: ")
        secret = prompt_secret("  Shared secret (not shown): ")
        creds["secret"] = secret
        if not secret:
            raise Refused("The shared secret is required. It is on the same "
                          "configuration screen as the web trigger URL.")
        if args.token_stdin:
            token = read_token_from_stdin()
        else:
            token = prompt_secret("  GitLab administrator token (not shown): ")
        creds["token"] = token
        if not token:
            raise Refused("An administrator token is required. System hooks "
                          "cannot be registered without one.")

    webtrigger_url = check_webtrigger_url(webtrigger_raw)

    session = Session(gitlab_url, token, make_transport(args.ca_bundle),
                      read_only=args.dry_run)

    version = session.get("/version") or {}
    user = session.get("/user") or {}
    # is_admin is only present for administrators on most versions, so its
    # absence is reported as unknown rather than as "no" -- the GET /hooks
    # below is the real test, and its 403 says so plainly.
    if user.get("is_admin") is True:
        admin = "yes"
    elif user.get("is_admin") is False:
        admin = "no"
    else:
        admin = "unknown"

    print("")
    print("Target instance : %s   (GitLab %s)"
          % (gitlab_url, version.get("version", "unknown")))
    print("Authenticated as: %s   (administrator: %s)"
          % (user.get("username", "unknown"), admin))
    print("")

    hooks = session.get_all("/hooks")
    plan = decide(hooks, webtrigger_url)
    print(render_plan(plan, secret))
    print("")

    if plan["action"] == ACTION_REFUSE:
        return EXIT_REFUSED

    if args.dry_run:
        print("DRY RUN - no changes were made. Re-run without --dry-run to "
              "apply.")
        return EXIT_OK

    result = _write_hook(session, plan, secret) or {}
    hook_id = result.get("id", plan["hook_id"])
    verb = "created" if plan["action"] == ACTION_CREATE else "updated"
    print("Applied. System hook #%s %s. Push and merge request events from "
          "every project on this instance now go to Jira." % (hook_id, verb))
    print("")
    print("The administrator token was used for this run only. It was not "
          "written to disk, never appeared in the command line, and nothing "
          "of it remains once this process exits.")
    print("If you created it for this setup, you can revoke it now:")
    print("  %s/-/user_settings/personal_access_tokens" % gitlab_url)
    return EXIT_OK


def main(argv=None):
    args = parse_args(argv)

    # Holds the two secrets as soon as they are read, so that the failure
    # handlers below can scrub them out of anything they print. Before they
    # are read there is nothing to scrub; after the process exits there is
    # nothing left at all.
    creds = {"token": None, "secret": None}

    try:
        return _run(args, creds)
    except Refused as error:
        print(str(error), file=sys.stderr)
        print("No changes were made.", file=sys.stderr)
        return EXIT_REFUSED
    except TlsError as error:
        print(scrub(str(error), [creds["token"], creds["secret"]]),
              file=sys.stderr)
        print("No changes were made.", file=sys.stderr)
        return EXIT_TLS
    except ApiError as error:
        print(scrub(str(error), [creds["token"], creds["secret"]]),
              file=sys.stderr)
        print("No changes were made.", file=sys.stderr)
        return EXIT_API
    except KeyboardInterrupt:
        print("\nInterrupted. No changes were made.", file=sys.stderr)
        return EXIT_REFUSED
    except Exception:  # noqa: BLE001 - deliberate, see scrub()
        print(scrub(traceback.format_exc(),
                    [creds["token"], creds["secret"]]), file=sys.stderr)
        print("Unexpected error. No changes were made.", file=sys.stderr)
        return EXIT_API


if __name__ == "__main__":
    sys.exit(main())