diff --git a/.github/PULL_REQUEST_TEMPLATE/to-main.md b/.github/PULL_REQUEST_TEMPLATE/to-main.md new file mode 100644 index 0000000..74480da --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE/to-main.md @@ -0,0 +1,9 @@ + + +Staging URL: +Exercised: +Result: +Tested commit: + +- [ ] I tested the changes currently on staging diff --git a/.github/scripts/branch_flow_guard.py b/.github/scripts/branch_flow_guard.py new file mode 100644 index 0000000..5747b21 --- /dev/null +++ b/.github/scripts/branch_flow_guard.py @@ -0,0 +1,545 @@ +"""Change-record check for the branch-flow guard. + +The GitHub Linear app only links a branch when the name contains an issue id. +It does not tell Actions whether that issue exists. This script asks Linear. + +Runs as MODE=guard (branch flow and Linear issue) or MODE=staging-test (the +staging record on a pull request from staging into main). +""" + +import json +import os +import re +import sys +import time +import urllib.error +import urllib.request +from datetime import datetime, timezone +from urllib.parse import urlsplit + +LINEAR_URL = "https://api.linear.app/graphql" + +# Linear's own branch matching, the same rules its GitHub integration uses to +# link a branch to an issue. Returns the issue or null. +BRANCH_QUERY = """ +query($branch: String!) { + issueVcsBranchSearch(branchName: $branch) { identifier } +} +""" + +# Fallback by team key and number, for a branch Linear does not match itself. +KEY_QUERY = """ +query($key: String!, $num: Float!) { + issues( + includeArchived: true + filter: { team: { key: { eq: $key } }, number: { eq: $num } } + ) { + nodes { identifier } + } +} +""" + +ATTESTATION = """Staging URL: +Exercised: +Result: +Tested commit: + +- [ ] I tested the changes currently on staging""" + +ACCEPTED_FORM = """Accepted form: each value goes after the label on the same line, or on the +lines directly under it. Bold labels, backticks, and are fine. +Staging URL is an http(s) URL. Tested commit is the 40-character head SHA of +this pull request. Tick the box.""" + +LABELS = ("Staging URL", "Exercised", "Result", "Tested commit") +CHECKBOX_TEXT = "I tested the changes currently on staging" + +# Branch order per flow. A pull request whose head is later in the flow than +# its base is a back-merge (for example main into dev after a hotfix). The +# hotfix pull request already carried the ticket, so the back-merge needs none. +FLOWS = { + "three": ("dev", "staging", "main"), + "two": ("staging", "main"), + "zorya": ("main",), +} + +RETRIES = 3 +SLEEP = time.sleep + + +class LinearError(Exception): + pass + + +class PullRequestLookupError(Exception): + pass + + +class TransientHTTPError(Exception): + pass + + +# A key starts on a boundary anywhere in the name, in either case: +# adrianp/ENG-149-title, adrianp/eng-149-title, adrianp/fix-ENG-149-login. +KEY_ON_BOUNDARY = re.compile(r"(?= 500: + raise TransientHTTPError(str(error.code)) from error + raise + except OSError as error: + # URLError (DNS, refused) and a read timeout (socket.timeout) are both OSError. + raise TransientHTTPError("network") from error + + +def _with_retries(request): + """Retry transient failures a few times. The gate stays closed on real errors.""" + last = None + for attempt in range(max(RETRIES, 1)): + try: + return _post_json(request) + except TransientHTTPError as error: + last = error + if attempt + 1 < RETRIES: + SLEEP(2**attempt) + raise last + + +def _linear_graphql(api_key, query, variables, what, level="error"): + """Run one Linear query and return its data. Any failure raises LinearError. + + level is the annotation printed on failure. A lookup that has a fallback + passes "warning" so a run that still passes does not carry an error. + """ + payload = json.dumps({"query": query, "variables": variables}).encode() + request = urllib.request.Request( + LINEAR_URL, + data=payload, + headers={ + "Authorization": api_key, + "Content-Type": "application/json", + "Accept": "application/json", + }, + method="POST", + ) + try: + raw = _with_retries(request) + except urllib.error.HTTPError as error: + print(f"::{level}::Linear API returned HTTP {error.code} while checking {what}.", file=sys.stderr) + raise LinearError(str(error.code)) from error + except TransientHTTPError as error: + print( + f"::{level}::Linear API did not answer after {RETRIES} attempts while checking {what} ({error}).", + file=sys.stderr, + ) + raise LinearError(str(error)) from error + + try: + data = json.loads(raw) + except json.JSONDecodeError as error: + print(f"::{level}::Linear API returned a response that was not JSON while checking {what}.", file=sys.stderr) + raise LinearError("json") from error + + errors = data.get("errors") or [] + if errors: + message = errors[0].get("message") or "unknown error" + print(f"::{level}::Linear rejected the lookup for {what}: {message}", file=sys.stderr) + raise LinearError(message) + return data.get("data") or {} + + +def linear_branch_lookup(api_key, branch): + """Ask Linear which issue this branch name belongs to. None when it matches nothing.""" + # The key lookup is the fallback, so a failure here is a warning, not an error. + data = _linear_graphql(api_key, BRANCH_QUERY, {"branch": branch}, f"branch '{branch}'", level="warning") + issue = data.get("issueVcsBranchSearch") + if not isinstance(issue, dict): + return None + return issue.get("identifier") or None + + +def linear_lookup(api_key, team, number): + """Ask Linear whether TEAM-number exists, archived issues included.""" + data = _linear_graphql(api_key, KEY_QUERY, {"key": team, "num": int(number)}, f"{team}-{number}") + nodes = (data.get("issues") or {}).get("nodes") or [] + if not nodes: + return None + return nodes[0].get("identifier") or f"{team}-{number}" + + +DEPENDABOT_AUTHORS = ("dependabot[bot]", "dependabot-preview[bot]") + + +def check_first_hop(base, head, body, api_key, lookup, author="", branch_lookup=None): + if branch_lookup is None: + branch_lookup = linear_branch_lookup + # The branch name is not proof. Anyone can open dependabot/manual-change. + # The author comes from the same live API response as the branches. + if re.fullmatch(r"dependabot/.+", head) or re.fullmatch(r"dependabot-preview/.+", head): + if author in DEPENDABOT_AUTHORS: + print(f"OK: Dependabot pull request from '{head}' needs no Linear issue.") + return 0 + + lowered = head.lower() + if lowered == "nolinear" or lowered.startswith("nolinear/"): + reason = head.split("/", 1)[1] if "/" in head else "" + if not reason.strip(): + print( + "::error::Use nolinear/. The text after the slash is the change name.", + file=sys.stderr, + ) + return 1 + if not body.strip(): + print( + "::error::nolinear/ branches are the no-ticket path, so the pull request description is the change record. Describe what this change is and why.", + file=sys.stderr, + ) + return 1 + print(f"OK: '{head}' is a no-ticket change and the pull request has a description.") + return 0 + + keys = extract_keys(head) + if not keys: + print( + f"::error::Pull requests into '{base}' must come from a branch that names a real Linear issue (for example adrianp/ENG-149-short-title), from nolinear/ with a pull request description, or from Dependabot. '{head}' does not.", + file=sys.stderr, + ) + return 1 + + if not api_key.strip(): + print( + "::error::LINEAR_API_KEY is not available to this repository, so the Linear issue cannot be verified.", + file=sys.stderr, + ) + return 1 + + # Linear matches the branch name with its own rules first. That is the + # same match its GitHub integration uses to link this branch to an issue. + # If that query fails, the key lookup below is the second chance. Both + # failing still fails closed. + try: + identifier = branch_lookup(api_key, head) + except LinearError: + identifier = None + if identifier: + print(f"OK: Linear links branch '{head}' to issue {identifier}.") + return 0 + + checked = [] + for team, number, key in keys: + checked.append(key) + try: + identifier = lookup(api_key, team, number) + except LinearError: + return 1 + if identifier: + print(f"OK: Linear issue {identifier} exists.") + return 0 + + joined = ", ".join(checked) + print( + f"::error::Linear has no issue for branch '{head}' ({joined} does not exist). Create the issue, or rename the branch to nolinear/ and describe the change in the pull request.", + file=sys.stderr, + ) + return 1 + + +def exact(base, head, expected, flow_name): + if head != expected: + print( + f"::error::Branch flow violation: PRs into '{base}' must come from '{expected}', but this PR is from '{head}'. The required flow is {flow_name}.", + file=sys.stderr, + ) + return 1 + print(f"OK: '{head}' -> '{base}' follows the {flow_name} flow.") + return 0 + + +def enforce(flow, base, head, body, api_key, lookup=linear_lookup, author="", same_repo=True, branch_lookup=None): + order = FLOWS.get(flow) + if order is None: + print(f"::error::Unknown branch flow '{flow}'.", file=sys.stderr) + return 1 + flow_name = " -> ".join(order) + if base not in order: + if flow == "zorya": + print(f"::error::Zorya deploys only from main; PR base was '{base}'.", file=sys.stderr) + return 1 + print(f"No branch-flow rule for base '{base}'; skipping.") + return 0 + if head in order and not same_repo: + # A fork can name a branch main or staging. Only this repository's + # branches are the protected ones, on back-merges and forward hops. + print( + f"::error::Branch '{head}' comes from another repository, or GitHub did not return both repository ids, so it is not this repository's '{head}'. Pull requests from forks need a Linear issue in the branch name or nolinear/ with a description.", + file=sys.stderr, + ) + return 1 + if head in order and order.index(head) > order.index(base): + # A back-merge after a hotfix carries no ticket. The hotfix PR had one. + print(f"OK: '{head}' -> '{base}' is a back-merge and needs no Linear issue.") + return 0 + position = order.index(base) + if position == 0: + return check_first_hop(base, head, body, api_key, lookup, author, branch_lookup) + return exact(base, head, order[position - 1], flow_name) + + +def _clean_value(raw): + value = raw.strip() + # Closing emphasis and quote markers are formatting. Hyphens and + # underscores stay: they are legal in URLs and SHAs are unaffected. + value = re.sub(r"[>*`]+\Z", "", value).strip() + # Backticks and autolink brackets around the whole value are formatting too. + value = value.strip("`<>").strip() + return value + + +def _label_line(label): + # Allows **Label:** value, **Label**: value, > Label: value, `Label`: value. + # Case-insensitive, like OTHER_LABEL, since the template is copied by hand. + return re.compile( + rf"^[ \t>*_`-]*{re.escape(label)}[ \t*_`]*:[ \t>*_`-]*(.*?)\s*$", + re.IGNORECASE, + ) + + +OTHER_LABEL = re.compile( + r"^[ \t>*_`-]*(?:" + "|".join(re.escape(label) for label in LABELS) + r")[ \t*_`]*:", + re.IGNORECASE, +) +CHECKBOX_LINE = re.compile(r"^[ \t]*[-*][ \t]+\[[ xX]\]") + + +def field_value(body, label): + """The first non-empty value for a label. + + The value may sit on the label line, or on the following lines up to the + next blank line, label, or checkbox. That accepts a bulleted list under + Exercised:. A blank template above a filled copy does not hide the copy. + """ + lines = body.splitlines() + pattern = _label_line(label) + for index, line in enumerate(lines): + match = pattern.match(line) + if not match: + continue + value = _clean_value(match.group(1)) + if value: + return value + collected = [] + for following in lines[index + 1:]: + if not following.strip(): + break + if OTHER_LABEL.match(following) or CHECKBOX_LINE.match(following): + break + item = _clean_value(re.sub(r"^[ \t>]*[-*+][ \t]+", "", following)) + if item: + collected.append(item) + if collected: + return "\n".join(collected) + return "" + + +def field_present(body, label): + return bool(field_value(body, label)) + + +def staging_url_ok(value): + """An http(s) URL with a real hostname. Query strings and fragments are fine.""" + value = (value or "").strip() + if not value or re.search(r"\s", value): + return False + try: + parts = urlsplit(value) + except ValueError: + return False + host = parts.hostname or "" + return parts.scheme in ("http", "https") and re.search(r"[A-Za-z0-9]", host) is not None + + +def staging_attestation_required(base, head, required): + return base == "main" and head == "staging" and required == "true" + + +def staging_test(base, head, body, required, actor, head_sha=""): + if not staging_attestation_required(base, head, required): + print("Staging attestation is not required for this pull request.") + return 0 + missing = [] + if not staging_url_ok(field_value(body, "Staging URL")): + missing.append("Staging URL as an http(s) URL") + for label in ("Exercised", "Result"): + if not field_present(body, label): + missing.append(label) + tested = field_value(body, "Tested commit") + if re.fullmatch(r"[0-9a-fA-F]{40}", tested) is None: + missing.append("Tested commit as one 40-character SHA") + checkbox = re.search( + rf"(?m)^[ \t]*[-*][ \t]+\[[xX]\][ \t]+{re.escape(CHECKBOX_TEXT)}[ \t]*\.?[ \t]*\r?$", + body, + ) + if checkbox is None: + missing.append("the ticked staging checkbox") + if missing: + print( + "::error::This pull request promotes staging to main. These are missing from the description or are not in the accepted form: " + + ", ".join(missing) + + ".", + file=sys.stderr, + ) + print(ACCEPTED_FORM) + print(ATTESTATION) + return 1 + expected = (head_sha or "").strip().lower() + if re.fullmatch(r"[0-9a-fA-F]{40}", expected) is None: + print( + "::error::GitHub did not return the current pull request head SHA, so the record cannot be tied to a commit.", + file=sys.stderr, + ) + return 1 + if tested.lower() != expected: + print( + f"::error::Tested commit {tested} is not the current pull request head {expected}. A new commit needs a new staging test. Update Tested commit after you re-test.", + file=sys.stderr, + ) + return 1 + now = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") + print(f"Staging attestation accepted by {actor} at {now} for {expected}.") + return 0 + + +def pull_request_fields(data): + """Live description, head SHA, branch names, author, and whether the head + branch lives in this repository, all from one response.""" + if not isinstance(data, dict): + raise PullRequestLookupError("json") + body = data.get("body") + if not isinstance(body, str): + body = "" + head = data.get("head") if isinstance(data.get("head"), dict) else {} + base = data.get("base") if isinstance(data.get("base"), dict) else {} + user = data.get("user") if isinstance(data.get("user"), dict) else {} + + def text(source, key): + raw = source.get(key) + return raw.strip() if isinstance(raw, str) else "" + + def repo_id(source): + repo = source.get("repo") + if not isinstance(repo, dict): + return None + value = repo.get("id") + return value if isinstance(value, int) else None + + head_repo = repo_id(head) + base_repo = repo_id(base) + # Missing ids fail closed: a branch is only trusted when both sides are known and equal. + same_repo = head_repo is not None and head_repo == base_repo + + return body, text(head, "sha"), text(base, "ref"), text(head, "ref"), text(user, "login"), same_repo + + +def pull_request(): + """Read the pull request from GitHub now. A rerun keeps the old event payload.""" + token = (os.environ.get("GH_TOKEN") or os.environ.get("GITHUB_TOKEN") or "").strip() + repo = (os.environ.get("GITHUB_REPOSITORY") or "").strip() + number = (os.environ.get("PR_NUMBER") or "").strip() + if not token or "/" not in repo or not number.isdigit(): + print( + "::error::The workflow did not provide the pull request number, repository, or token, so the current pull request cannot be read.", + file=sys.stderr, + ) + raise PullRequestLookupError("missing") + url = f"https://api.github.com/repos/{repo}/pulls/{number}" + request = urllib.request.Request( + url, + headers={ + "Authorization": f"Bearer {token}", + "Accept": "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", + "User-Agent": "opacity-branch-flow-guard", + }, + ) + try: + raw = _with_retries(request) + except urllib.error.HTTPError as error: + print( + f"::error::GitHub returned HTTP {error.code} while reading the pull request.", + file=sys.stderr, + ) + raise PullRequestLookupError(str(error.code)) from error + except TransientHTTPError as error: + print( + f"::error::GitHub did not return the pull request after {RETRIES} attempts ({error}).", + file=sys.stderr, + ) + raise PullRequestLookupError(str(error)) from error + try: + data = json.loads(raw) + except json.JSONDecodeError as error: + print( + "::error::GitHub returned a pull request response that was not JSON.", + file=sys.stderr, + ) + raise PullRequestLookupError("json") from error + return pull_request_fields(data) + + +def main(): + mode = os.environ.get("MODE", "guard") + # A rerun keeps the event's base, head, and SHA from the original run. + # Retargeting the pull request does not update that event, so both jobs + # read the current branches before choosing a rule. + try: + body, head_sha, base, head, author, same_repo = pull_request() + except PullRequestLookupError: + return 1 + if not base or not head: + print( + "::error::GitHub did not return the current base and head branches, so this run cannot tell which rules apply.", + file=sys.stderr, + ) + return 1 + if mode == "staging-test": + return staging_test( + base, + head, + body, + os.environ.get("REQUIRE_STAGING_TEST", ""), + os.environ.get("ACTOR") or os.environ.get("GITHUB_ACTOR") or "", + head_sha, + ) + return enforce( + os.environ.get("FLOW", ""), + base, + head, + body, + os.environ.get("LINEAR_API_KEY", ""), + author=author, + same_repo=same_repo, + ) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.github/scripts/test_branch_flow_guard.py b/.github/scripts/test_branch_flow_guard.py new file mode 100644 index 0000000..9fc694e --- /dev/null +++ b/.github/scripts/test_branch_flow_guard.py @@ -0,0 +1,571 @@ +import io +import json +import os +import urllib.error + +import unittest + +import branch_flow_guard as guard + +guard.linear_branch_lookup = lambda api_key, branch: None + + +def test_extract_keys(): + assert guard.extract_keys("adrianp/ENG-149-some-title") == [("ENG", "149", "ENG-149")] + assert guard.extract_keys("adrianp/eng-149-some-title") == [("ENG", "149", "ENG-149")] + assert guard.extract_keys("ENG-149") == [("ENG", "149", "ENG-149")] + assert guard.extract_keys("dijar/DEVOPS-162-rollout") == [("DEVOPS", "162", "DEVOPS-162")] + # A key anywhere on a boundary counts. + assert guard.extract_keys("adrianp/fix-ENG-149-login") == [("ENG", "149", "ENG-149")] + assert guard.extract_keys("foo/bar-ENG-7") == [("ENG", "7", "ENG-7")] + assert guard.extract_keys("somethingENG-149") == [("SOMETHINGENG", "149", "SOMETHINGENG-149")] + assert guard.extract_keys("plain-branch") == [] + assert guard.extract_keys("dijar/change-record-guard") == [] + assert guard.extract_keys("adrianp/ENG-149-fix-2") == [("ENG", "149", "ENG-149")] + # Looks like a key. Linear answers that it does not exist. + assert guard.extract_keys("release-2024") == [("RELEASE", "2024", "RELEASE-2024")] + + +def test_linear_branch_match_wins(): + lookup, calls = fake_lookup(set()) + by_branch = lambda api_key, branch: "ENG-149" if "eng-149" in branch.lower() else None + # Linear links the branch: no key lookup needed. + assert guard.check_first_hop("dev", "adrianp/fix-eng-149-login", "", "secret", lookup, branch_lookup=by_branch) == 0 + assert calls == [] + # Linear does not link it, the key does not exist either. + assert guard.check_first_hop("dev", "adrianp/ENG-999999-nope", "", "secret", lookup, branch_lookup=by_branch) == 1 + assert calls == [("secret", "ENG", "999999")] + # Linear does not link it, but the key exists: fallback passes. + lookup2, calls2 = fake_lookup({("ENG", "149")}) + assert guard.check_first_hop("dev", "weird~ENG-149", "", "secret", lookup2, branch_lookup=lambda k, b: None) == 0 + assert calls2 == [("secret", "ENG", "149")] + # No key shape at all: no API call. + assert guard.check_first_hop("dev", "wip", "", "secret", lookup2, branch_lookup=lambda k, b: "ENG-1") == 1 + assert calls2 == [("secret", "ENG", "149")] + + def down(api_key, branch): + raise guard.LinearError("500") + + # The branch query fails but the key exists: the key lookup is the second chance. + lookup3, calls3 = fake_lookup({("ENG", "149")}) + assert guard.check_first_hop("dev", "adrianp/ENG-149-title", "", "secret", lookup3, branch_lookup=down) == 0 + assert calls3 == [("secret", "ENG", "149")] + # The branch query fails and the key does not exist: fail. + lookup4, calls4 = fake_lookup(set()) + assert guard.check_first_hop("dev", "adrianp/ENG-149-title", "", "secret", lookup4, branch_lookup=down) == 1 + assert calls4 == [("secret", "ENG", "149")] + + # Both queries fail: fail closed. + def key_down(api_key, team, number): + raise guard.LinearError("500") + + assert guard.check_first_hop("dev", "adrianp/ENG-149-title", "", "secret", key_down, branch_lookup=down) == 1 + + +def fake_lookup(found): + calls = [] + + def lookup(api_key, team, number): + calls.append((api_key, team, number)) + if (team, number) in found: + return f"{team}-{number}" + return None + + return lookup, calls + + +def test_first_hop_paths(): + lookup, calls = fake_lookup({("ENG", "149")}) + bot = "dependabot[bot]" + assert guard.check_first_hop("dev", "dependabot/npm_and_yarn/left-pad-1.2.3", "", "", lookup, bot) == 0 + assert guard.check_first_hop("dev", "dependabot/manual-change", "", "", lookup, bot) == 0 + assert guard.check_first_hop("dev", "dependabot-preview/npm/left-pad-1.2.3", "", "", lookup, "dependabot-preview[bot]") == 0 + assert calls == [] + # The branch name alone is not proof. + assert guard.check_first_hop("dev", "dependabot/manual-change", "", "", lookup, "dijar") == 1 + assert guard.check_first_hop("dev", "dependabot/manual-change", "", "", lookup) == 1 + assert guard.check_first_hop("dev", "nolinear/docs-typo", "fix the typo", "secret", lookup) == 0 + assert guard.check_first_hop("dev", "NoLinear/docs-typo", "fix the typo", "secret", lookup) == 0 + assert guard.check_first_hop("dev", "nolinear/ENG-999999-still-no-ticket", "described", "secret", lookup) == 0 + assert calls == [] + assert guard.check_first_hop("dev", "nolinear/docs-typo", " ", "secret", lookup) == 1 + assert guard.check_first_hop("dev", "nolinear", "described", "secret", lookup) == 1 + assert guard.check_first_hop("dev", "nolinear/", "described", "secret", lookup) == 1 + assert guard.check_first_hop("dev", "wip-feature", "", "secret", lookup) == 1 + assert guard.check_first_hop("dev", "adrianp/ENG-149-title", "", "", lookup) == 1 + assert calls == [] + assert guard.check_first_hop("dev", "adrianp/ENG-149-title", "", "secret", lookup) == 0 + assert calls == [("secret", "ENG", "149")] + assert guard.check_first_hop("dev", "adrianp/ENG-999999-nope", "", "secret", lookup) == 1 + + +def test_linear_error_fails_closed(): + def lookup(api_key, team, number): + raise guard.LinearError("401") + + assert guard.check_first_hop("dev", "adrianp/ENG-149-title", "", "secret", lookup) == 1 + + +def test_flows_do_not_call_linear_on_later_hops(): + lookup, calls = fake_lookup({("ENG", "149")}) + assert guard.enforce("three", "main", "staging", "", "", lookup) == 0 + assert guard.enforce("three", "staging", "dev", "", "", lookup) == 0 + assert guard.enforce("three", "main", "dev", "", "", lookup) == 1 + assert guard.enforce("three", "main", "nolinear/hotfix", "desc", "secret", lookup) == 1 + assert guard.enforce("three", "staging", "nolinear/hotfix", "desc", "secret", lookup) == 1 + assert guard.enforce("three", "dev", "adrianp/ENG-149-title", "", "secret", lookup) == 0 + assert guard.enforce("two", "main", "staging", "", "", lookup) == 0 + assert guard.enforce("two", "staging", "adrianp/ENG-149-title", "", "secret", lookup) == 0 + assert guard.enforce("two", "main", "adrianp/ENG-149-title", "", "secret", lookup) == 1 + assert guard.enforce("two", "dev", "adrianp/ENG-149-title", "", "secret", lookup) == 0 + assert guard.enforce("zorya", "main", "adrianp/ENG-149-title", "", "secret", lookup) == 0 + assert guard.enforce("zorya", "staging", "adrianp/ENG-149-title", "", "secret", lookup) == 1 + assert guard.enforce("nope", "main", "staging", "", "", lookup) == 1 + assert calls + + +def test_back_merges_need_no_ticket(): + lookup, calls = fake_lookup(set()) + assert guard.enforce("three", "dev", "main", "", "", lookup) == 0 + assert guard.enforce("three", "dev", "staging", "", "", lookup) == 0 + assert guard.enforce("three", "staging", "main", "", "", lookup) == 0 + assert guard.enforce("two", "staging", "main", "", "", lookup) == 0 + # Forward hops stay exact. + assert guard.enforce("three", "main", "dev", "", "", lookup) == 1 + assert guard.enforce("two", "main", "dev", "", "", lookup) == 1 + assert guard.enforce("zorya", "main", "staging", "", "secret", lookup) == 1 + assert calls == [("secret", "STAGING", "")] or calls == [] + + +def test_fork_branches_are_not_protected_branches(): + lookup, calls = fake_lookup(set()) + # Back-merge names from a fork get no exemption. + assert guard.enforce("three", "dev", "main", "", "", lookup, same_repo=False) == 1 + assert guard.enforce("three", "dev", "staging", "", "", lookup, same_repo=False) == 1 + assert guard.enforce("two", "staging", "main", "", "", lookup, same_repo=False) == 1 + # Forward hops from a fork are not the real promotion either. + assert guard.enforce("three", "main", "staging", "", "", lookup, same_repo=False) == 1 + assert guard.enforce("three", "staging", "dev", "", "", lookup, same_repo=False) == 1 + assert guard.enforce("two", "main", "staging", "", "", lookup, same_repo=False) == 1 + assert calls == [] + # Feature branches from a fork still go through the first-hop rules. + assert guard.enforce("three", "dev", "nolinear/docs", "described", "", lookup, same_repo=False) == 0 + assert guard.enforce("three", "dev", "wip", "", "secret", lookup, same_repo=False) == 1 + # The same names from this repository stay trusted. + assert guard.enforce("three", "dev", "main", "", "", lookup, same_repo=True) == 0 + assert guard.enforce("three", "main", "staging", "", "", lookup, same_repo=True) == 0 + + +def test_dependabot_author_from_api(): + lookup, calls = fake_lookup(set()) + assert guard.enforce("three", "dev", "dependabot/pip/requests-2.32.0", "", "", lookup, "dependabot[bot]") == 0 + assert guard.enforce("three", "dev", "dependabot/pip/requests-2.32.0", "", "", lookup, "someone") == 1 + assert calls == [] + + +def test_staging_attestation(): + sha = "a" * 40 + other = "b" * 40 + good = f"""Staging URL: https://staging.example +Exercised: opened the dashboard +Result: the new page loaded +Tested commit: {sha} + +- [x] I tested the changes currently on staging +""" + assert guard.staging_test("dev", "feature", good, "true", "dijar") == 0 + assert guard.staging_test("main", "staging", good, "", "dijar") == 0 + assert guard.staging_test("main", "staging", good, "false", "dijar") == 0 + assert guard.staging_test("main", "staging", "", "true", "dijar", sha) == 1 + assert guard.staging_test("main", "staging", good, "true", "dijar", sha) == 0 + assert guard.staging_test("main", "staging", good, "true", "dijar", other) == 1 + assert guard.staging_test("main", "staging", good, "true", "dijar", "") == 1 + assert guard.staging_test("main", "staging", guard.ATTESTATION, "true", "dijar", sha) == 1 + bold = f"""**Staging URL:** https://staging.example +**Exercised:** clicked around +**Result:** worked +**Tested commit:** {sha} +- [X] I tested the changes currently on staging +""" + assert guard.staging_test("main", "staging", bold, "true", "dijar", sha.upper()) == 0 + unchecked = good.replace("- [x]", "- [ ]") + assert guard.staging_test("main", "staging", unchecked, "true", "dijar", sha) == 1 + empty_bold = f"""**Staging URL:** +**Exercised:** * +**Result:** ** +**Tested commit:** {sha} +- [x] I tested the changes currently on staging +""" + assert guard.staging_test("main", "staging", empty_bold, "true", "dijar", sha) == 1 + crlf = good.replace("\n", "\r\n") + assert guard.staging_test("main", "staging", crlf, "true", "dijar", sha) == 0 + bad_url = good.replace("https://staging.example", "not-a-url") + assert guard.staging_test("main", "staging", bad_url, "true", "dijar", sha) == 1 + assert guard.staging_url_ok("https://staging.example/path") + assert guard.staging_url_ok("http://staging.internal:8443") + assert guard.staging_url_ok("https://staging.example.com?preview=123") + assert guard.staging_url_ok("https://staging.example.com#section") + assert guard.staging_url_ok("https://staging.example.com/path?preview=123#section") + assert guard.staging_url_ok("https://staging_host.internal/app") + assert guard.staging_url_ok("http://[::1]:8080/") + assert guard.staging_test( + "main", + "staging", + good.replace("https://staging.example", "https://staging.example.com?preview=123"), + "true", + "dijar", + sha, + ) == 0 + assert not guard.staging_url_ok("not-a-url") + assert not guard.staging_url_ok("https://") + assert not guard.staging_url_ok("https://...") + assert not guard.staging_url_ok("https://staging.example.com?preview=123 more") + assert not guard.staging_url_ok("ftp://staging.example.com") + assert not guard.staging_url_ok("staging.example.com") + assert not guard.staging_url_ok("http://[::1") + + +def test_staging_record_formatting_variants(): + sha = "a" * 40 + formatted = f"""**Staging URL**: +**Exercised**: login +`Result`: fine +Tested commit: `{sha}` + +- [x] I tested the changes currently on staging. +""" + assert guard.staging_test("main", "staging", formatted, "true", "dijar", sha) == 0 + assert guard.field_value(formatted, "Staging URL") == "https://staging.example.com/app" + assert guard.field_value(formatted, "Tested commit") == sha + + bulleted = f"""Staging URL: https://staging.example.com +Exercised: +- login +- signup +Result: +> both flows completed +Tested commit: {sha} + +- [x] I tested the changes currently on staging +""" + assert guard.field_value(bulleted, "Exercised") == "login\nsignup" + assert guard.field_present(bulleted, "Result") + assert guard.staging_test("main", "staging", bulleted, "true", "dijar", sha) == 0 + + # A label followed directly by the next label is still empty. + hollow = f"""Staging URL: https://staging.example.com +Exercised: +Result: fine +Tested commit: {sha} + +- [x] I tested the changes currently on staging +""" + assert not guard.field_present(hollow, "Exercised") + assert guard.staging_test("main", "staging", hollow, "true", "dijar", sha) == 1 + + # A label followed by the checkbox is empty too. + hollow_end = f"""Staging URL: https://staging.example.com +Exercised: login +Result: fine +Tested commit: +- [x] I tested the changes currently on staging +""" + assert guard.field_value(hollow_end, "Tested commit") == "" + + # The first non-empty match wins, so a blank template above does not hide a filled copy. + pasted = guard.ATTESTATION + "\n\n" + f"""Staging URL: https://staging.example.com +Exercised: login +Result: fine +Tested commit: {sha} + +- [x] I tested the changes currently on staging +""" + assert guard.staging_test("main", "staging", pasted, "true", "dijar", sha) == 0 + + # Trailing underscores and hyphens are content, not markers. + underscored = formatted.replace("", "https://staging.example.com/a_b-") + assert guard.field_value(underscored, "Staging URL") == "https://staging.example.com/a_b-" + assert guard.staging_test("main", "staging", underscored, "true", "dijar", sha) == 0 + + # Labels are matched case-insensitively, for lookup and as a stop line. + lower = f"""staging url: https://staging.example.com +exercised: login +result: +tested commit: {sha} + +- [x] I tested the changes currently on staging +""" + assert guard.field_value(lower, "Staging URL") == "https://staging.example.com" + assert not guard.field_present(lower, "Result") + assert guard.staging_test("main", "staging", lower, "true", "dijar", sha) == 1 + + # Multi-line Tested commit is not one SHA. + two_shas = bulleted.replace(f"Tested commit: {sha}", f"Tested commit:\n- {sha}\n- {'b' * 40}") + assert guard.staging_test("main", "staging", two_shas, "true", "dijar", sha) == 1 + + +def test_pull_request_fields(): + sha = "a" * 40 + body, found, base, head, author, same = guard.pull_request_fields( + { + "body": "hi", + "base": {"ref": " main ", "repo": {"id": 7}}, + "head": {"sha": f" {sha} ", "ref": " staging ", "repo": {"id": 7}}, + "user": {"login": "dependabot[bot]"}, + } + ) + assert body == "hi" + assert found == sha + assert base == "main" + assert head == "staging" + assert author == "dependabot[bot]" + assert same is True + # A fork: same branch name, different repository. + *_, same = guard.pull_request_fields( + {"base": {"ref": "dev", "repo": {"id": 7}}, "head": {"ref": "main", "repo": {"id": 99}}} + ) + assert same is False + # Missing ids are not trusted. + *_, same = guard.pull_request_fields({"base": {"ref": "dev"}, "head": {"ref": "main"}}) + assert same is False + *_, same = guard.pull_request_fields({"base": {"ref": "dev", "repo": {"id": 7}}, "head": {"ref": "main", "repo": None}}) + assert same is False + body, found, base, head, author, same = guard.pull_request_fields({"body": None, "head": {}}) + assert (body, found, base, head, author, same) == ("", "", "", "", "", False) + body, found, base, head, author, same = guard.pull_request_fields({"body": "x", "head": "staging", "user": "x"}) + assert (body, found, base, head, author, same) == ("x", "", "", "", "", False) + + +class FakeResponse(io.BytesIO): + def __enter__(self): + return self + + def __exit__(self, *args): + return False + + +def test_retries_transient_errors_only(): + sleeps = [] + attempts = [] + original_open = guard.urllib.request.urlopen + original_sleep = guard.SLEEP + guard.SLEEP = sleeps.append + + def flaky(request, timeout=0): + attempts.append(1) + if len(attempts) < 3: + raise urllib.error.HTTPError(request.full_url, 503, "down", {}, None) + return FakeResponse(json.dumps({"data": {"issues": {"nodes": [{"identifier": "ENG-149"}]}}}).encode()) + + def forbidden(request, timeout=0): + attempts.append(1) + raise urllib.error.HTTPError(request.full_url, 401, "nope", {}, None) + + def offline(request, timeout=0): + attempts.append(1) + raise urllib.error.URLError("offline") + + def quiet(request, timeout=0): + attempts.append(1) + raise TimeoutError("read timed out") + + try: + guard.urllib.request.urlopen = flaky + assert guard.linear_lookup("secret", "ENG", "149") == "ENG-149" + assert len(attempts) == 3 + assert sleeps == [1, 2] + + attempts.clear() + sleeps.clear() + guard.urllib.request.urlopen = forbidden + try: + guard.linear_lookup("secret", "ENG", "149") + assert False, "401 must fail" + except guard.LinearError: + pass + assert len(attempts) == 1 + assert sleeps == [] + + attempts.clear() + guard.urllib.request.urlopen = offline + try: + guard.linear_lookup("secret", "ENG", "149") + assert False, "network failure must fail closed" + except guard.LinearError: + pass + assert len(attempts) == guard.RETRIES + + attempts.clear() + guard.urllib.request.urlopen = quiet + try: + guard.linear_lookup("secret", "ENG", "149") + assert False, "read timeout must fail closed after retries" + except guard.LinearError: + pass + assert len(attempts) == guard.RETRIES + + attempts.clear() + sleeps.clear() + env = {"GH_TOKEN": "t", "GITHUB_REPOSITORY": "OpacityLabs/sdk", "PR_NUMBER": "901"} + previous = {key: os.environ.get(key) for key in env} + os.environ.update(env) + try: + guard.urllib.request.urlopen = offline + try: + guard.pull_request() + assert False, "GitHub outage must fail closed" + except guard.PullRequestLookupError: + pass + assert len(attempts) == guard.RETRIES + finally: + _restore_env(previous) + finally: + guard.urllib.request.urlopen = original_open + guard.SLEEP = original_sleep + + +def _restore_env(previous): + for key, value in previous.items(): + if value is None: + os.environ.pop(key, None) + else: + os.environ[key] = value + + +ENV_KEYS = ("MODE", "FLOW", "LINEAR_API_KEY", "REQUIRE_STAGING_TEST", "ACTOR") + + +def test_main_reads_current_pull_request(): + previous = {key: os.environ.get(key) for key in ENV_KEYS} + original = guard.pull_request + try: + os.environ["MODE"] = "guard" + os.environ["FLOW"] = "three" + os.environ["LINEAR_API_KEY"] = "" + guard.pull_request = lambda: ("ship the docs", "", "dev", "nolinear/docs", "dijar", True) + assert guard.main() == 0 + # Author from the API decides the Dependabot path. + guard.pull_request = lambda: ("", "", "dev", "dependabot/pip/x-1.0", "dependabot[bot]", True) + assert guard.main() == 0 + guard.pull_request = lambda: ("", "", "dev", "dependabot/pip/x-1.0", "dijar", True) + assert guard.main() == 1 + # Back-merge after a hotfix. + guard.pull_request = lambda: ("", "", "dev", "main", "dijar", True) + assert guard.main() == 0 + # The same name from a fork is not a back-merge. + guard.pull_request = lambda: ("", "", "dev", "main", "dijar", False) + assert guard.main() == 1 + + def fail(): + raise guard.PullRequestLookupError("down") + + guard.pull_request = fail + assert guard.main() == 1 + finally: + guard.pull_request = original + _restore_env(previous) + + +def test_rules_follow_the_live_target(): + previous = {key: os.environ.get(key) for key in ENV_KEYS} + original = guard.pull_request + sha = "a" * 40 + attested = f"""Staging URL: https://staging.example.com?preview=123 +Exercised: opened the dashboard +Result: the new page loaded +Tested commit: {sha} + +- [x] I tested the changes currently on staging +""" + try: + os.environ["ACTOR"] = "dijar" + os.environ["LINEAR_API_KEY"] = "" + os.environ["MODE"] = "guard" + os.environ["FLOW"] = "three" + # Retargeted from dev to main: the live target decides. + guard.pull_request = lambda: ("ship the docs", "", "main", "nolinear/docs", "dijar", True) + assert guard.main() == 1 + guard.pull_request = lambda: ("ship the docs", "", "main", "staging", "dijar", True) + assert guard.main() == 0 + guard.pull_request = lambda: ("ship the docs", "", "", "staging", "dijar", True) + assert guard.main() == 1 + + os.environ["MODE"] = "staging-test" + os.environ["REQUIRE_STAGING_TEST"] = "true" + guard.pull_request = lambda: ("", "", "dev", "feature", "dijar", True) + assert guard.main() == 0 + guard.pull_request = lambda: ("", sha, "main", "staging", "dijar", True) + assert guard.main() == 1 + guard.pull_request = lambda: (attested, sha, "main", "staging", "dijar", True) + assert guard.main() == 0 + os.environ["REQUIRE_STAGING_TEST"] = "false" + guard.pull_request = lambda: ("", "", "main", "staging", "dijar", True) + assert guard.main() == 0 + + def fail(): + raise guard.PullRequestLookupError("down") + + guard.pull_request = fail + assert guard.main() == 1 + finally: + guard.pull_request = original + _restore_env(previous) + + +def test_staging_rerun_uses_api_sha(): + sha = "a" * 40 + current = "b" * 40 + body = f"""Staging URL: https://staging.example +Exercised: opened the dashboard +Result: the new page loaded +Tested commit: {sha} + +- [x] I tested the changes currently on staging +""" + previous = {key: os.environ.get(key) for key in ENV_KEYS} + original = guard.pull_request + try: + os.environ["MODE"] = "staging-test" + os.environ["REQUIRE_STAGING_TEST"] = "true" + os.environ["ACTOR"] = "dijar" + guard.pull_request = lambda: (body, current, "main", "staging", "dijar", True) + assert guard.main() == 1 + guard.pull_request = lambda: (body, sha, "main", "staging", "dijar", True) + assert guard.main() == 0 + guard.pull_request = lambda: (body, "", "main", "staging", "dijar", True) + assert guard.main() == 1 + finally: + guard.pull_request = original + _restore_env(previous) + + +ALL = [ + test_extract_keys, + test_linear_branch_match_wins, + test_first_hop_paths, + test_linear_error_fails_closed, + test_flows_do_not_call_linear_on_later_hops, + test_back_merges_need_no_ticket, + test_fork_branches_are_not_protected_branches, + test_dependabot_author_from_api, + test_staging_attestation, + test_staging_record_formatting_variants, + test_pull_request_fields, + test_retries_transient_errors_only, + test_main_reads_current_pull_request, + test_rules_follow_the_live_target, + test_staging_rerun_uses_api_sha, +] + + + +class BranchFlowGuardTests(unittest.TestCase): + """One unittest per case so `python3 -m unittest discover -s .github/scripts` reports them.""" + + +for _test in ALL: + setattr(BranchFlowGuardTests, _test.__name__, staticmethod(_test)) + + +if __name__ == "__main__": + unittest.main() diff --git a/.github/workflows/branch-flow-guard.yaml b/.github/workflows/branch-flow-guard.yaml index bb3d032..c43b5ee 100644 --- a/.github/workflows/branch-flow-guard.yaml +++ b/.github/workflows/branch-flow-guard.yaml @@ -1,41 +1,86 @@ name: Branch flow guard - -# ----------------------------------------------------------------------------- -# Enforces the branching strategy: dev -> staging -> main. -# * PRs into `main` must originate from `staging` -# * PRs into `staging` must originate from `dev` +# Enforces dev -> staging -> main, and checks the change record on the first hop. # -# Add this workflow's check as a REQUIRED status check on the `main` and -# `staging` branch rules so a non-conforming PR cannot be merged. +# * PRs into main must originate from staging +# * PRs into staging must originate from dev +# * PRs into dev must name a real Linear issue, use nolinear/ with a +# pull request description, or come from Dependabot # -# Emergency changes: a repo admin may temporarily make this check non-required -# (per the Change Management Plan emergency procedure) to merge a hotfix. +# The GitHub Linear app only links a branch. LINEAR_API_KEY is what lets this +# job ask Linear whether that issue exists. A missing secret fails closed. +# The value is the key itself. Linear personal API keys are not Bearer tokens. # -# This file is standardized and identical across all in-scope repos. -# ----------------------------------------------------------------------------- - +# A back-merge (main into dev, main into staging, staging into dev) needs no +# ticket. The pull request that landed the change already carried one. +# +# staging-test runs only when the head branch is staging and the repository +# variable REQUIRE_STAGING_TEST is true. A skipped job satisfies a required +# check. The head branch of a pull request never changes and the variable is +# read when the job starts, so neither condition goes stale on a rerun. The +# base branch can change (retarget), so the script reads it live. The record +# has to name the current head SHA, so a later commit invalidates it. edited is +# listed so updating the description runs the check again. +# +# The jobs run as pull_request_target. That event runs in the context of the +# repository default branch, so this file and the script it checks out both +# come from there. A pull request cannot replace the check with one that skips +# the record. Both jobs read the pull request from the GitHub API before +# choosing a rule: base branch, head branch, head SHA, author, and description. +# The author fills the staging record in the description. This check only +# reads it. Open the promotion with ?template=to-main.md to get the fields. +# +# The org ruleset Branch Flow Guard Status Check decides which jobs are +# required. This file has to be on the default branch of every release-pipeline +# repo before that ruleset includes dev or staging-test. +# +# Emergency: a repo admin may temporarily make the check non-required. on: - pull_request: + pull_request_target: + types: [opened, synchronize, reopened, edited] branches: - main - staging + - dev + +permissions: + contents: read + pull-requests: read jobs: guard: runs-on: ubuntu-latest + timeout-minutes: 5 steps: + - name: Check out the guard script from the default branch + uses: actions/checkout@v7 + with: + ref: ${{ github.event.repository.default_branch }} + sparse-checkout: .github/scripts + persist-credentials: false - name: Enforce dev -> staging -> main env: - BASE: ${{ github.base_ref }} - HEAD: ${{ github.head_ref }} - run: | - case "$BASE" in - main) expected="staging" ;; - staging) expected="dev" ;; - *) echo "No branch-flow rule for base '$BASE'; skipping."; exit 0 ;; - esac - if [ "$HEAD" != "$expected" ]; then - echo "::error::Branch flow violation: PRs into '$BASE' must come from '$expected', but this PR is from '$HEAD'. The required flow is dev -> staging -> main." - exit 1 - fi - echo "OK: '$HEAD' -> '$BASE' follows the dev -> staging -> main flow." + MODE: guard + FLOW: three + LINEAR_API_KEY: ${{ secrets.LINEAR_API_KEY }} + GH_TOKEN: ${{ github.token }} + PR_NUMBER: ${{ github.event.pull_request.number }} + run: python3 .github/scripts/branch_flow_guard.py + + staging-test: + if: github.event.pull_request.head.ref == 'staging' && vars.REQUIRE_STAGING_TEST == 'true' + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Check out the guard script from the default branch + uses: actions/checkout@v7 + with: + ref: ${{ github.event.repository.default_branch }} + sparse-checkout: .github/scripts + persist-credentials: false + - name: Record the staging test + env: + MODE: staging-test + REQUIRE_STAGING_TEST: ${{ vars.REQUIRE_STAGING_TEST }} + GH_TOKEN: ${{ github.token }} + PR_NUMBER: ${{ github.event.pull_request.number }} + run: python3 .github/scripts/branch_flow_guard.py diff --git a/yarn.lock b/yarn.lock index 1b89157..0292ffd 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4462,6 +4462,20 @@ __metadata: languageName: node linkType: hard +"async-function@npm:^1.0.0": + version: 1.0.0 + resolution: "async-function@npm:1.0.0" + checksum: 9102e246d1ed9b37ac36f57f0a6ca55226876553251a31fc80677e71471f463a54c872dc78d5d7f80740c8ba624395cccbe8b60f7b690c4418f487d8e9fd1106 + languageName: node + linkType: hard + +"async-generator-function@npm:^1.0.0": + version: 1.0.0 + resolution: "async-generator-function@npm:1.0.0" + checksum: 74a71a4a2dd7afd06ebb612f6d612c7f4766a351bedffde466023bf6dae629e46b0d2cd38786239e0fbf245de0c7df76035465e16d1213774a0efb22fec0d713 + languageName: node + linkType: hard + "async-limiter@npm:~1.0.0": version: 1.0.1 resolution: "async-limiter@npm:1.0.1" @@ -4634,6 +4648,15 @@ __metadata: languageName: node linkType: hard +"baseline-browser-mapping@npm:^2.11.20": + version: 2.11.21 + resolution: "baseline-browser-mapping@npm:2.11.21" + bin: + baseline-browser-mapping: dist/cli.cjs + checksum: 686840029c0130aa93e103d9fa8a47dc888e81b08a00f6a02a085b7c4568d188a250e7da6bd3b18b6e4cf424ef35061f3827551442d07dda386fb75c544e2e29 + languageName: node + linkType: hard + "big-integer@npm:1.6.x": version: 1.6.52 resolution: "big-integer@npm:1.6.52" @@ -4653,22 +4676,22 @@ __metadata: linkType: hard "body-parser@npm:^1.20.3": - version: 1.20.3 - resolution: "body-parser@npm:1.20.3" + version: 1.20.9 + resolution: "body-parser@npm:1.20.9" dependencies: - bytes: 3.1.2 + bytes: ~3.1.2 content-type: ~1.0.5 debug: 2.6.9 depd: 2.0.0 - destroy: 1.2.0 - http-errors: 2.0.0 - iconv-lite: 0.4.24 - on-finished: 2.4.1 - qs: 6.13.0 - raw-body: 2.5.2 + destroy: ~1.2.0 + http-errors: ~2.0.1 + iconv-lite: ~0.4.24 + on-finished: ~2.4.1 + qs: ~6.16.0 + raw-body: ~2.5.3 type-is: ~1.6.18 - unpipe: 1.0.0 - checksum: 1a35c59a6be8d852b00946330141c4f142c6af0f970faa87f10ad74f1ee7118078056706a05ae3093c54dabca9cd3770fa62a170a85801da1a4324f04381167d + unpipe: ~1.0.0 + checksum: c3ef15619b0fb8538fb8c02737011ad07b942c6fc87604a5c92e01e239d80244383820ac63f394eb7f25b5ca07a7acc9406bb4e02d57923da6b69083bbcb6fdf languageName: node linkType: hard @@ -4691,12 +4714,12 @@ __metadata: linkType: hard "brace-expansion@npm:^1.1.7": - version: 1.1.11 - resolution: "brace-expansion@npm:1.1.11" + version: 1.1.21 + resolution: "brace-expansion@npm:1.1.21" dependencies: balanced-match: ^1.0.0 concat-map: 0.0.1 - checksum: faf34a7bb0c3fcf4b59c7808bc5d2a96a40988addf2e7e09dfbb67a2251800e0d14cd2bfc1aa79174f2f5095c54ff27f46fb1289fe2d77dac755b5eb3434cc07 + checksum: b841b4198a0ab8a1b433920ed65b46a9b4368c0ad1f95b2ed229a682c7d7d5b5839620204b5644a05c967d29e66bdf0a59b1115e74e0f27761d0dc02f5e0d835 languageName: node linkType: hard @@ -4718,31 +4741,18 @@ __metadata: languageName: node linkType: hard -"browserslist@npm:^4.20.4, browserslist@npm:^4.23.1, browserslist@npm:^4.23.3": - version: 4.23.3 - resolution: "browserslist@npm:4.23.3" - dependencies: - caniuse-lite: ^1.0.30001646 - electron-to-chromium: ^1.5.4 - node-releases: ^2.0.18 - update-browserslist-db: ^1.1.0 - bin: - browserslist: cli.js - checksum: 7906064f9970aeb941310b2fcb8b4ace4a1b50aa657c986677c6f1553a8cabcc94ee9c5922f715baffbedaa0e6cf0831b6fed7b059dde6873a4bfadcbe069c7e - languageName: node - linkType: hard - -"browserslist@npm:^4.24.0, browserslist@npm:^4.24.3": - version: 4.24.4 - resolution: "browserslist@npm:4.24.4" +"browserslist@npm:^4.20.4, browserslist@npm:^4.23.1, browserslist@npm:^4.23.3, browserslist@npm:^4.24.0, browserslist@npm:^4.24.3": + version: 4.28.9 + resolution: "browserslist@npm:4.28.9" dependencies: - caniuse-lite: ^1.0.30001688 - electron-to-chromium: ^1.5.73 - node-releases: ^2.0.19 - update-browserslist-db: ^1.1.1 + baseline-browser-mapping: ^2.11.20 + caniuse-lite: ^1.0.30001810 + electron-to-chromium: ^1.5.420 + node-releases: ^2.0.54 + update-browserslist-db: ^1.3.2 bin: browserslist: cli.js - checksum: 64074bf6cf0a9ae3094d753270e3eae9cf925149db45d646f0bc67bacc2e46d7ded64a4e835b95f5fdcf0350f63a83c3755b32f80831f643a47f0886deb8a065 + checksum: 598ea7c768c8d5044ba65b2ac9b235ed6851815dec0abb8fc1ee59c97a1bcaf8ab4aa38f0ec4265d4a5dac15763f6d0043a1c0f062317973d5d4d7b26c9fca40 languageName: node linkType: hard @@ -4779,7 +4789,7 @@ __metadata: languageName: node linkType: hard -"bytes@npm:3.1.2": +"bytes@npm:~3.1.2": version: 3.1.2 resolution: "bytes@npm:3.1.2" checksum: e4bcd3948d289c5127591fbedf10c0b639ccbf00243504e4e127374a15c3bc8eed0d28d4aaab08ff6f1cf2abc0cce6ba3085ed32f4f90e82a5683ce0014e1b6e @@ -4806,6 +4816,16 @@ __metadata: languageName: node linkType: hard +"call-bind-apply-helpers@npm:^1.0.1, call-bind-apply-helpers@npm:^1.0.2": + version: 1.0.2 + resolution: "call-bind-apply-helpers@npm:1.0.2" + dependencies: + es-errors: ^1.3.0 + function-bind: ^1.1.2 + checksum: b2863d74fcf2a6948221f65d95b91b4b2d90cfe8927650b506141e669f7d5de65cea191bf788838bc40d13846b7886c5bc5c84ab96c3adbcf88ad69a72fcdc6b + languageName: node + linkType: hard + "call-bind@npm:^1.0.2, call-bind@npm:^1.0.5, call-bind@npm:^1.0.6, call-bind@npm:^1.0.7": version: 1.0.7 resolution: "call-bind@npm:1.0.7" @@ -4819,6 +4839,16 @@ __metadata: languageName: node linkType: hard +"call-bound@npm:^1.0.2": + version: 1.0.4 + resolution: "call-bound@npm:1.0.4" + dependencies: + call-bind-apply-helpers: ^1.0.2 + get-intrinsic: ^1.3.0 + checksum: 2f6399488d1c272f56306ca60ff696575e2b7f31daf23bc11574798c84d9f2759dceb0cb1f471a85b77f28962a7ac6411f51d283ea2e45319009a19b6ccab3b2 + languageName: node + linkType: hard + "caller-callsite@npm:^2.0.0": version: 2.0.0 resolution: "caller-callsite@npm:2.0.0" @@ -4877,10 +4907,10 @@ __metadata: languageName: node linkType: hard -"caniuse-lite@npm:^1.0.30001646, caniuse-lite@npm:^1.0.30001688": - version: 1.0.30001780 - resolution: "caniuse-lite@npm:1.0.30001780" - checksum: c8b7974ed3e1c94bebc0456783f4e23941085d25438ed6e75379bfd4f644652f6b466e8c22e753ccc8e3051b382371b47f7ddd6f04af8ccb923f521f389d50ab +"caniuse-lite@npm:^1.0.30001810": + version: 1.0.30001810 + resolution: "caniuse-lite@npm:1.0.30001810" + checksum: 4ff2dae8df59eb5f5ae71c4803666cf581459a9432c7cf016afd85f4f110774fb9c6469a62ff69a73ed7be5a7eae27988656c3acf56bceed7b0f24358426a5a8 languageName: node linkType: hard @@ -5403,14 +5433,14 @@ __metadata: languageName: node linkType: hard -"depd@npm:2.0.0": +"depd@npm:2.0.0, depd@npm:~2.0.0": version: 2.0.0 resolution: "depd@npm:2.0.0" checksum: abbe19c768c97ee2eed6282d8ce3031126662252c58d711f646921c9623f9052e3e1906443066beec1095832f534e57c523b7333f8e7e0d93051ab6baef5ab3a languageName: node linkType: hard -"destroy@npm:1.2.0": +"destroy@npm:1.2.0, destroy@npm:~1.2.0": version: 1.2.0 resolution: "destroy@npm:1.2.0" checksum: 0acb300b7478a08b92d810ab229d5afe0d2f4399272045ab22affa0d99dbaf12637659411530a6fcd597a9bdac718fc94373a61a95b4651bbc7b83684a565e38 @@ -5442,6 +5472,17 @@ __metadata: languageName: node linkType: hard +"dunder-proto@npm:^1.0.1": + version: 1.0.1 + resolution: "dunder-proto@npm:1.0.1" + dependencies: + call-bind-apply-helpers: ^1.0.1 + es-errors: ^1.3.0 + gopd: ^1.2.0 + checksum: 149207e36f07bd4941921b0ca929e3a28f1da7bd6b6ff8ff7f4e2f2e460675af4576eeba359c635723dc189b64cdd4787e0255897d5b135ccc5d15cb8685fc90 + languageName: node + linkType: hard + "eastasianwidth@npm:^0.2.0": version: 0.2.0 resolution: "eastasianwidth@npm:0.2.0" @@ -5456,17 +5497,10 @@ __metadata: languageName: node linkType: hard -"electron-to-chromium@npm:^1.5.4": - version: 1.5.19 - resolution: "electron-to-chromium@npm:1.5.19" - checksum: 459b47ab828cbeb2d09767c93cd181bdccbda008e1d7fc92d078d72ecf4cac3107d2deb424016a63aadc484765ec98c84f339546b0a627892d24eb286f9f0adb - languageName: node - linkType: hard - -"electron-to-chromium@npm:^1.5.73": - version: 1.5.80 - resolution: "electron-to-chromium@npm:1.5.80" - checksum: b13b553345df4de777051a68fdea3912ef7e9024c7d065feb989a31ad940716d8fee44d98cd24a55555e540a624ba0f0511538df98fb53d4ae07ee49d82da65d +"electron-to-chromium@npm:^1.5.420": + version: 1.5.422 + resolution: "electron-to-chromium@npm:1.5.422" + checksum: 4d613b3d89579cca61db5d4dbfbcbd38ff6faa86de6b30f438b04020dc0fcfd67345e5492c99c793d643a9df39fb5911cb77b2ced1212d6dbb69573455b31b5c languageName: node linkType: hard @@ -5630,6 +5664,13 @@ __metadata: languageName: node linkType: hard +"es-define-property@npm:^1.0.1": + version: 1.0.1 + resolution: "es-define-property@npm:1.0.1" + checksum: 0512f4e5d564021c9e3a644437b0155af2679d10d80f21adaf868e64d30efdfbd321631956f20f42d655fedb2e3a027da479fad3fa6048f768eb453a80a5f80a + languageName: node + linkType: hard + "es-errors@npm:^1.2.1, es-errors@npm:^1.3.0": version: 1.3.0 resolution: "es-errors@npm:1.3.0" @@ -5668,6 +5709,15 @@ __metadata: languageName: node linkType: hard +"es-object-atoms@npm:^1.1.1": + version: 1.1.2 + resolution: "es-object-atoms@npm:1.1.2" + dependencies: + es-errors: ^1.3.0 + checksum: b821f39e4f48bd85b13fee80aea9068a674bcb78b95ff01267aa4da1111f83e6944049b3329b2ffdb3acaa266fb5b8b885476fe8cada05034dc7c87c8016c86d + languageName: node + linkType: hard + "es-set-tostringtag@npm:^2.0.3": version: 2.0.3 resolution: "es-set-tostringtag@npm:2.0.3" @@ -5699,7 +5749,7 @@ __metadata: languageName: node linkType: hard -"escalade@npm:^3.1.1, escalade@npm:^3.1.2, escalade@npm:^3.2.0": +"escalade@npm:^3.1.1, escalade@npm:^3.2.0": version: 3.2.0 resolution: "escalade@npm:3.2.0" checksum: 47b029c83de01b0d17ad99ed766347b974b0d628e848de404018f3abee728e987da0d2d370ad4574aa3d5b5bfc368754fd085d69a30f8e75903486ec4b5b709e @@ -6222,6 +6272,13 @@ __metadata: languageName: node linkType: hard +"generator-function@npm:^2.0.0": + version: 2.0.1 + resolution: "generator-function@npm:2.0.1" + checksum: 3bf87f7b0230de5d74529677e6c3ceb3b7b5d9618b5a22d92b45ce3876defbaf5a77791b25a61b0fa7d13f95675b5ff67a7769f3b9af33f096e34653519e873d + languageName: node + linkType: hard + "gensync@npm:^1.0.0-beta.2": version: 1.0.0-beta.2 resolution: "gensync@npm:1.0.0-beta.2" @@ -6249,6 +6306,27 @@ __metadata: languageName: node linkType: hard +"get-intrinsic@npm:^1.2.5, get-intrinsic@npm:^1.3.0": + version: 1.3.1 + resolution: "get-intrinsic@npm:1.3.1" + dependencies: + async-function: ^1.0.0 + async-generator-function: ^1.0.0 + call-bind-apply-helpers: ^1.0.2 + es-define-property: ^1.0.1 + es-errors: ^1.3.0 + es-object-atoms: ^1.1.1 + function-bind: ^1.1.2 + generator-function: ^2.0.0 + get-proto: ^1.0.1 + gopd: ^1.2.0 + has-symbols: ^1.1.0 + hasown: ^2.0.2 + math-intrinsics: ^1.1.0 + checksum: c02b3b6a445f9cd53e14896303794ac60f9751f58a69099127248abdb0251957174c6524245fc68579dc8e6a35161d3d94c93e665f808274716f4248b269436a + languageName: node + linkType: hard + "get-package-type@npm:^0.1.0": version: 0.1.0 resolution: "get-package-type@npm:0.1.0" @@ -6256,6 +6334,16 @@ __metadata: languageName: node linkType: hard +"get-proto@npm:^1.0.1": + version: 1.0.1 + resolution: "get-proto@npm:1.0.1" + dependencies: + dunder-proto: ^1.0.1 + es-object-atoms: ^1.0.0 + checksum: 4fc96afdb58ced9a67558698b91433e6b037aaa6f1493af77498d7c85b141382cf223c0e5946f334fb328ee85dfe6edd06d218eaf09556f4bc4ec6005d7f5f7b + languageName: node + linkType: hard + "get-stream@npm:^5.0.0": version: 5.2.0 resolution: "get-stream@npm:5.2.0" @@ -6395,6 +6483,13 @@ __metadata: languageName: node linkType: hard +"gopd@npm:^1.2.0": + version: 1.2.0 + resolution: "gopd@npm:1.2.0" + checksum: cc6d8e655e360955bdccaca51a12a474268f95bb793fc3e1f2bdadb075f28bfd1fd988dab872daf77a61d78cbaf13744bc8727a17cfb1d150d76047d805375f3 + languageName: node + linkType: hard + "graceful-fs@npm:^4.1.3, graceful-fs@npm:^4.1.6, graceful-fs@npm:^4.2.0, graceful-fs@npm:^4.2.10, graceful-fs@npm:^4.2.4, graceful-fs@npm:^4.2.6, graceful-fs@npm:^4.2.9": version: 4.2.11 resolution: "graceful-fs@npm:4.2.11" @@ -6460,6 +6555,13 @@ __metadata: languageName: node linkType: hard +"has-symbols@npm:^1.1.0": + version: 1.1.0 + resolution: "has-symbols@npm:1.1.0" + checksum: b2316c7302a0e8ba3aaba215f834e96c22c86f192e7310bdf689dd0e6999510c89b00fbc5742571507cebf25764d68c988b3a0da217369a73596191ac0ce694b + languageName: node + linkType: hard + "has-tostringtag@npm:^1.0.0, has-tostringtag@npm:^1.0.2": version: 1.0.2 resolution: "has-tostringtag@npm:1.0.2" @@ -6539,6 +6641,19 @@ __metadata: languageName: node linkType: hard +"http-errors@npm:~2.0.1": + version: 2.0.1 + resolution: "http-errors@npm:2.0.1" + dependencies: + depd: ~2.0.0 + inherits: ~2.0.4 + setprototypeof: ~1.2.0 + statuses: ~2.0.2 + toidentifier: ~1.0.1 + checksum: 155d1a100a06e4964597013109590b97540a177b69c3600bbc93efc746465a99a2b718f43cdf76b3791af994bbe3a5711002046bf668cdc007ea44cea6df7ccd + languageName: node + linkType: hard + "http-proxy-agent@npm:^7.0.0": version: 7.0.2 resolution: "http-proxy-agent@npm:7.0.2" @@ -6583,15 +6698,6 @@ __metadata: languageName: node linkType: hard -"iconv-lite@npm:0.4.24": - version: 0.4.24 - resolution: "iconv-lite@npm:0.4.24" - dependencies: - safer-buffer: ">= 2.1.2 < 3" - checksum: bd9f120f5a5b306f0bc0b9ae1edeb1577161503f5f8252a20f1a9e56ef8775c9959fd01c55f2d3a39d9a8abaf3e30c1abeb1895f367dcbbe0a8fd1c9ca01c4f6 - languageName: node - linkType: hard - "iconv-lite@npm:^0.6.2": version: 0.6.3 resolution: "iconv-lite@npm:0.6.3" @@ -6601,6 +6707,15 @@ __metadata: languageName: node linkType: hard +"iconv-lite@npm:~0.4.24": + version: 0.4.24 + resolution: "iconv-lite@npm:0.4.24" + dependencies: + safer-buffer: ">= 2.1.2 < 3" + checksum: bd9f120f5a5b306f0bc0b9ae1edeb1577161503f5f8252a20f1a9e56ef8775c9959fd01c55f2d3a39d9a8abaf3e30c1abeb1895f367dcbbe0a8fd1c9ca01c4f6 + languageName: node + linkType: hard + "ieee754@npm:^1.1.13": version: 1.2.1 resolution: "ieee754@npm:1.2.1" @@ -6677,7 +6792,7 @@ __metadata: languageName: node linkType: hard -"inherits@npm:2, inherits@npm:2.0.4, inherits@npm:^2.0.3, inherits@npm:^2.0.4, inherits@npm:~2.0.3": +"inherits@npm:2, inherits@npm:2.0.4, inherits@npm:^2.0.3, inherits@npm:^2.0.4, inherits@npm:~2.0.3, inherits@npm:~2.0.4": version: 2.0.4 resolution: "inherits@npm:2.0.4" checksum: 4a48a733847879d6cf6691860a6b1e3f0f4754176e4d71494c41f3475553768b10f84b5ce1d40fbd0e34e6bfbb864ee35858ad4dd2cf31e02fc4a154b724d7f1 @@ -7287,15 +7402,15 @@ __metadata: linkType: hard "joi@npm:^17.2.1": - version: 17.13.3 - resolution: "joi@npm:17.13.3" + version: 17.13.8 + resolution: "joi@npm:17.13.8" dependencies: "@hapi/hoek": ^9.3.0 "@hapi/topo": ^5.1.0 "@sideway/address": ^4.1.5 "@sideway/formula": ^3.0.1 "@sideway/pinpoint": ^2.0.0 - checksum: 66ed454fee3d8e8da1ce21657fd2c7d565d98f3e539d2c5c028767e5f38cbd6297ce54df8312d1d094e62eb38f9452ebb43da4ce87321df66cf5e3f128cbc400 + checksum: 8ba537317e0a972b053edb0f656c4bb4207e3869d585b9d7fe92c8f4ba07812780d67dd727346611d2d3aa9b499c7d7af8cfd4d8c08c3453e03d6890f2fa61ac languageName: node linkType: hard @@ -7307,14 +7422,14 @@ __metadata: linkType: hard "js-yaml@npm:^3.13.1": - version: 3.14.1 - resolution: "js-yaml@npm:3.14.1" + version: 3.15.2 + resolution: "js-yaml@npm:3.15.2" dependencies: argparse: ^1.0.7 esprima: ^4.0.0 bin: js-yaml: bin/js-yaml.js - checksum: bef146085f472d44dee30ec34e5cf36bf89164f5d585435a3d3da89e52622dff0b188a580e4ad091c3341889e14cb88cac6e4deb16dc5b1e9623bb0601fc255c + checksum: 774d62285fa9aad3883603a7186c58619b582f0257ccf4d7b518dc2dc5c7f88f14b649c1149112b4494da832ba824c35a3e2fda776a12dbe02e99f7adc3a8a47 languageName: node linkType: hard @@ -7461,12 +7576,12 @@ __metadata: linkType: hard "launch-editor@npm:^2.9.1": - version: 2.11.1 - resolution: "launch-editor@npm:2.11.1" + version: 2.14.2 + resolution: "launch-editor@npm:2.14.2" dependencies: picocolors: ^1.1.1 - shell-quote: ^1.8.3 - checksum: 95a2e0a50ce15425a87fd035bdef2de37e13c2aee9cd62756783efb286a6e36a341cfcbaecb0d578131a5411c6a1c74c422f9c5b6cb6f4c8284d6078967e08b4 + shell-quote: ^1.10.0 + checksum: fc5e7cfc8732f20f76e75746aaafdb14b0d1c3dd646a73be3f0576a5928ac8ef80efbf445332de295e79f7d1fb9b32811950ee75cb630308e8db613e5d8c735f languageName: node linkType: hard @@ -7642,6 +7757,13 @@ __metadata: languageName: node linkType: hard +"math-intrinsics@npm:^1.1.0": + version: 1.1.0 + resolution: "math-intrinsics@npm:1.1.0" + checksum: 0e513b29d120f478c85a70f49da0b8b19bc638975eca466f2eeae0071f3ad00454c621bf66e16dd435896c208e719fc91ad79bbfba4e400fe0b372e7c1c9c9a2 + languageName: node + linkType: hard + "media-typer@npm:0.3.0": version: 0.3.0 resolution: "media-typer@npm:0.3.0" @@ -8173,17 +8295,10 @@ __metadata: languageName: node linkType: hard -"node-releases@npm:^2.0.18": - version: 2.0.18 - resolution: "node-releases@npm:2.0.18" - checksum: ef55a3d853e1269a6d6279b7692cd6ff3e40bc74947945101138745bfdc9a5edabfe72cb19a31a8e45752e1910c4c65c77d931866af6357f242b172b7283f5b3 - languageName: node - linkType: hard - -"node-releases@npm:^2.0.19": - version: 2.0.19 - resolution: "node-releases@npm:2.0.19" - checksum: 917dbced519f48c6289a44830a0ca6dc944c3ee9243c468ebd8515a41c97c8b2c256edb7f3f750416bc37952cc9608684e6483c7b6c6f39f6bd8d86c52cfe658 +"node-releases@npm:^2.0.54": + version: 2.0.54 + resolution: "node-releases@npm:2.0.54" + checksum: 34dd32cb489b71306e25014ccc5ddd2a1793ba13aed75dcbb6f7ec144fa093030cb91781d1d296fb7105987f42f23d3877a1ec0913702be4412f2493fdfe3102 languageName: node linkType: hard @@ -8263,6 +8378,13 @@ __metadata: languageName: node linkType: hard +"object-inspect@npm:^1.13.3, object-inspect@npm:^1.13.4": + version: 1.13.4 + resolution: "object-inspect@npm:1.13.4" + checksum: 582810c6a8d2ef988ea0a39e69e115a138dad8f42dd445383b394877e5816eb4268489f316a6f74ee9c4e0a984b3eab1028e3e79d62b1ed67c726661d55c7a8b + languageName: node + linkType: hard + "object-keys@npm:^1.1.1": version: 1.1.1 resolution: "object-keys@npm:1.1.1" @@ -8334,6 +8456,15 @@ __metadata: languageName: node linkType: hard +"on-finished@npm:~2.4.1": + version: 2.4.2 + resolution: "on-finished@npm:2.4.2" + dependencies: + ee-first: 1.1.1 + checksum: 1893d4a51d0e0b67b16fb1e27092d8ebcf19c578fc43a3187a5f99bc9e276207e1593afad614444e97755c46793bf1def40d9d0e1e0b8ef67dfb0d51293a6dd0 + languageName: node + linkType: hard + "on-headers@npm:~1.0.2": version: 1.0.2 resolution: "on-headers@npm:1.0.2" @@ -8546,7 +8677,7 @@ __metadata: languageName: node linkType: hard -"picocolors@npm:^1.0.0, picocolors@npm:^1.0.1": +"picocolors@npm:^1.0.0": version: 1.1.0 resolution: "picocolors@npm:1.1.0" checksum: a64d653d3a188119ff45781dfcdaeedd7625583f45280aea33fcb032c7a0d3959f2368f9b192ad5e8aade75b74dbd954ffe3106c158509a45e4c18ab379a2acd @@ -8681,12 +8812,13 @@ __metadata: languageName: node linkType: hard -"qs@npm:6.13.0": - version: 6.13.0 - resolution: "qs@npm:6.13.0" +"qs@npm:~6.16.0": + version: 6.16.1 + resolution: "qs@npm:6.16.1" dependencies: - side-channel: ^1.0.6 - checksum: e9404dc0fc2849245107108ce9ec2766cde3be1b271de0bf1021d049dc5b98d1a2901e67b431ac5509f865420a7ed80b7acb3980099fe1c118a1c5d2e1432ad8 + es-define-property: ^1.0.1 + side-channel: ^1.1.1 + checksum: 56d617a5e3ea475eaafd1e860a66c0aa362c0d58c23e787ad71a137d67fb52fd9a9fff6aea2d38612443f24825fb23ea4d01bb7222e9d2ac32a5b6007027747c languageName: node linkType: hard @@ -8720,15 +8852,15 @@ __metadata: languageName: node linkType: hard -"raw-body@npm:2.5.2": - version: 2.5.2 - resolution: "raw-body@npm:2.5.2" +"raw-body@npm:~2.5.3": + version: 2.5.3 + resolution: "raw-body@npm:2.5.3" dependencies: - bytes: 3.1.2 - http-errors: 2.0.0 - iconv-lite: 0.4.24 - unpipe: 1.0.0 - checksum: ba1583c8d8a48e8fbb7a873fdbb2df66ea4ff83775421bfe21ee120140949ab048200668c47d9ae3880012f6e217052690628cf679ddfbd82c9fc9358d574676 + bytes: ~3.1.2 + http-errors: ~2.0.1 + iconv-lite: ~0.4.24 + unpipe: ~1.0.0 + checksum: 16aa51e504318ebeef7f84a4d884c0f273cb0b7f3f14ea88788f92f5f488870617c97d4f886e84f119f21a2d6cdda3c4554821f8b18ed6be0d731ecb5a063d2a languageName: node linkType: hard @@ -9454,7 +9586,7 @@ __metadata: languageName: node linkType: hard -"setprototypeof@npm:1.2.0": +"setprototypeof@npm:1.2.0, setprototypeof@npm:~1.2.0": version: 1.2.0 resolution: "setprototypeof@npm:1.2.0" checksum: be18cbbf70e7d8097c97f713a2e76edf84e87299b40d085c6bf8b65314e994cc15e2e317727342fa6996e38e1f52c59720b53fe621e2eb593a6847bf0356db89 @@ -9477,6 +9609,13 @@ __metadata: languageName: node linkType: hard +"shell-quote@npm:^1.10.0": + version: 1.12.0 + resolution: "shell-quote@npm:1.12.0" + checksum: bbb3b6b244069da4114c5c0d2192786ff146123e1c29c298348871a3b8219cf45bdc9540c2f948e8874e2f7309186480a7ac0097159d0958aaafec25c5074731 + languageName: node + linkType: hard + "shell-quote@npm:^1.6.1, shell-quote@npm:^1.7.3": version: 1.8.1 resolution: "shell-quote@npm:1.8.1" @@ -9484,10 +9623,38 @@ __metadata: languageName: node linkType: hard -"shell-quote@npm:^1.8.3": - version: 1.8.3 - resolution: "shell-quote@npm:1.8.3" - checksum: 550dd84e677f8915eb013d43689c80bb114860649ec5298eb978f40b8f3d4bc4ccb072b82c094eb3548dc587144bb3965a8676f0d685c1cf4c40b5dc27166242 +"side-channel-list@npm:^1.0.1": + version: 1.0.1 + resolution: "side-channel-list@npm:1.0.1" + dependencies: + es-errors: ^1.3.0 + object-inspect: ^1.13.4 + checksum: 3499671cd52adaee739eac1e14d07530b8e3530192741aeb05e7fe4ad1b51d1368ceea2cd3c21b0f62b05410a5c70a7c4d997ba4b143303ef73d0c65dfd1c252 + languageName: node + linkType: hard + +"side-channel-map@npm:^1.0.1": + version: 1.0.1 + resolution: "side-channel-map@npm:1.0.1" + dependencies: + call-bound: ^1.0.2 + es-errors: ^1.3.0 + get-intrinsic: ^1.2.5 + object-inspect: ^1.13.3 + checksum: 42501371cdf71f4ccbbc9c9e2eb00aaaab80a4c1c429d5e8da713fd4d39ef3b8d4a4b37ed4f275798a65260a551a7131fd87fe67e922dba4ac18586d6aab8b06 + languageName: node + linkType: hard + +"side-channel-weakmap@npm:^1.0.2": + version: 1.0.2 + resolution: "side-channel-weakmap@npm:1.0.2" + dependencies: + call-bound: ^1.0.2 + es-errors: ^1.3.0 + get-intrinsic: ^1.2.5 + object-inspect: ^1.13.3 + side-channel-map: ^1.0.1 + checksum: a815c89bc78c5723c714ea1a77c938377ea710af20d4fb886d362b0d1f8ac73a17816a5f6640f354017d7e292a43da9c5e876c22145bac00b76cfb3468001736 languageName: node linkType: hard @@ -9503,6 +9670,19 @@ __metadata: languageName: node linkType: hard +"side-channel@npm:^1.1.1": + version: 1.1.1 + resolution: "side-channel@npm:1.1.1" + dependencies: + es-errors: ^1.3.0 + object-inspect: ^1.13.4 + side-channel-list: ^1.0.1 + side-channel-map: ^1.0.1 + side-channel-weakmap: ^1.0.2 + checksum: e0f217140c463636ee556260bbc8f47ba2931f1248826f58e1502704135814c763b325dd9ab122a04176aa45b4a4f8cc556415bcab7a0179d85250fb7812f063 + languageName: node + linkType: hard + "signal-exit@npm:^3.0.2, signal-exit@npm:^3.0.3, signal-exit@npm:^3.0.7": version: 3.0.7 resolution: "signal-exit@npm:3.0.7" @@ -9715,6 +9895,13 @@ __metadata: languageName: node linkType: hard +"statuses@npm:~2.0.2": + version: 2.0.2 + resolution: "statuses@npm:2.0.2" + checksum: 6927feb50c2a75b2a4caab2c565491f7a93ad3d8dbad7b1398d52359e9243a20e2ebe35e33726dee945125ef7a515e9097d8a1b910ba2bbd818265a2f6c39879 + languageName: node + linkType: hard + "stream-buffers@npm:2.2.x": version: 2.2.0 resolution: "stream-buffers@npm:2.2.0" @@ -9984,7 +10171,7 @@ __metadata: languageName: node linkType: hard -"toidentifier@npm:1.0.1": +"toidentifier@npm:1.0.1, toidentifier@npm:~1.0.1": version: 1.0.1 resolution: "toidentifier@npm:1.0.1" checksum: 952c29e2a85d7123239b5cfdd889a0dde47ab0497f0913d70588f19c53f7e0b5327c95f4651e413c74b785147f9637b17410ac8c846d5d4a20a5a33eb6dc3a45 @@ -10217,30 +10404,16 @@ __metadata: languageName: node linkType: hard -"unpipe@npm:1.0.0, unpipe@npm:~1.0.0": +"unpipe@npm:~1.0.0": version: 1.0.0 resolution: "unpipe@npm:1.0.0" checksum: 4fa18d8d8d977c55cb09715385c203197105e10a6d220087ec819f50cb68870f02942244f1017565484237f1f8c5d3cd413631b1ae104d3096f24fdfde1b4aa2 languageName: node linkType: hard -"update-browserslist-db@npm:^1.1.0": - version: 1.1.0 - resolution: "update-browserslist-db@npm:1.1.0" - dependencies: - escalade: ^3.1.2 - picocolors: ^1.0.1 - peerDependencies: - browserslist: ">= 4.21.0" - bin: - update-browserslist-db: cli.js - checksum: 7b74694d96f0c360f01b702e72353dc5a49df4fe6663d3ee4e5c628f061576cddf56af35a3a886238c01dd3d8f231b7a86a8ceaa31e7a9220ae31c1c1238e562 - languageName: node - linkType: hard - -"update-browserslist-db@npm:^1.1.1": - version: 1.1.2 - resolution: "update-browserslist-db@npm:1.1.2" +"update-browserslist-db@npm:^1.3.2": + version: 1.3.2 + resolution: "update-browserslist-db@npm:1.3.2" dependencies: escalade: ^3.2.0 picocolors: ^1.1.1 @@ -10248,7 +10421,7 @@ __metadata: browserslist: ">= 4.21.0" bin: update-browserslist-db: cli.js - checksum: 088d2bad8ddeaeccd82d87d3f6d736d5256d697b725ffaa2b601dfd0ec16ba5fad20db8dcdccf55396e1a36194236feb69e3f5cce772e5be15a5e4261ff2815d + checksum: 68b13d54c6da73d22fe36820ed3d32226811b3837386ec9cf2cf866d3c958fc5522946c20123867456cc199dcb952103a1d2a26d6b3b6eb47a317551ea24ea72 languageName: node linkType: hard