Coverage for .github / scripts / apply_live_validation_label.py: 100.00%

48 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-10-28 16:19 +0000

1#!/usr/bin/env python3 

2"""Label a pull request that touches a deployed path ``live-validation-required``. 

3 

4docs/LIVE_RELEASE_VALIDATION.md decides when a change needs a live release 

5validation run, and the pull-request template asks the author to record that 

6decision. Nothing pointed a reviewer at the decision before merge, so a weekly 

7Dependabot batch that bumped the AWS SDK clients bundled into the inference 

8streaming Lambda merged on green like a CI-only update. 

9 

10This adds the label when any changed file sits on a path that ships in, or 

11drives, a deployment (``DEPLOYED_PREFIXES`` and ``DEPLOYED_FILES``), and prints 

12which files matched. It is a prompt, not the decision: the docs' rule is 

13risk-based, so a docstring-only edit under ``gco/`` is labelled too, and the 

14author still records "Not required" with a reason in the template. 

15 

16Scope is deliberately narrow. The script only ever adds the one label it owns 

17and never removes it, so a maintainer who applies it by hand, or Dependabot's 

18``runtime`` group, which applies it from ``.github/dependabot.yml``, is never 

19overridden. Markdown files are ignored everywhere: a README next to a Lambda 

20does not ship. 

21 

22Usage:: 

23 

24 apply_live_validation_label.py --pr 490 # add the label if needed 

25 apply_live_validation_label.py --pr 490 --dry-run # print the plan only 

26 

27``GH_TOKEN`` and ``GH_REPO`` come from the environment, as the ``gh`` CLI 

28expects. The changed-file list is read through ``gh api`` with only the PR 

29number, so no pull-request text reaches a shell. 

30""" 

31 

32from __future__ import annotations 

33 

34import argparse 

35import json 

36import subprocess 

37import sys 

38 

39#: The label this script owns. Dependabot's ``runtime`` group applies the same 

40#: label from ``.github/dependabot.yml``; a test keeps the two in agreement. 

41LABEL = "live-validation-required" 

42 

43#: Directories whose contents ship in a deployment: the CDK app and services 

44#: (``gco/``), every Lambda (``lambda/``) and the service images 

45#: (``dockerfiles/``). 

46DEPLOYED_PREFIXES: tuple[str, ...] = ("gco/", "lambda/", "dockerfiles/") 

47 

48#: Single files that change what a deployment synthesizes or runs: the CDK 

49#: entry point and context, the Python runtime graph, the root npm graph (it 

50#: carries the AWS CDK CLI), and the deploy/destroy orchestration. 

51DEPLOYED_FILES: frozenset[str] = frozenset( 

52 { 

53 "app.py", 

54 "cdk.json", 

55 "pyproject.toml", 

56 "requirements-lock.txt", 

57 "package.json", 

58 "package-lock.json", 

59 "cli/stacks.py", 

60 } 

61) 

62 

63 

64def deployed_paths(paths: list[str]) -> list[str]: 

65 """Return the changed paths that sit on a deployed path, sorted.""" 

66 matched = { 

67 path 

68 for path in paths 

69 if not path.lower().endswith(".md") 

70 and (path in DEPLOYED_FILES or path.startswith(DEPLOYED_PREFIXES)) 

71 } 

72 return sorted(matched) 

73 

74 

75def _gh(args: list[str]) -> str: 

76 proc = subprocess.run(["gh", *args], capture_output=True, text=True, check=False) 

77 if proc.returncode != 0: 

78 raise RuntimeError(f"gh {' '.join(args)} failed: {proc.stderr.strip()[:400]}") 

79 return proc.stdout 

80 

81 

82def changed_files(number: int) -> list[str]: 

83 """Return every file the pull request changes. 

84 

85 ``gh pr view --json files`` and ``gh pr diff`` both stop short on large pull 

86 requests, so this pages the REST files endpoint instead. ``--jq`` emits one 

87 JSON string per line, which keeps file names with spaces intact. 

88 """ 

89 out = _gh( 

90 [ 

91 "api", 

92 "--paginate", 

93 f"repos/{{owner}}/{{repo}}/pulls/{number}/files?per_page=100", 

94 "--jq", 

95 ".[].filename | tojson", 

96 ] 

97 ) 

98 return [str(json.loads(line)) for line in out.splitlines() if line.strip()] 

99 

100 

101def current_labels(number: int) -> list[str]: 

102 data = json.loads(_gh(["pr", "view", str(number), "--json", "labels"]) or "{}") 

103 return [str(item["name"]) for item in data.get("labels") or []] 

104 

105 

106def add_label(number: int) -> None: 

107 _gh(["pr", "edit", str(number), "--add-label", LABEL]) 

108 

109 

110def main(argv: list[str] | None = None) -> int: 

111 parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) 

112 parser.add_argument("--pr", type=int, required=True, help="Pull request number") 

113 parser.add_argument( 

114 "--dry-run", 

115 action="store_true", 

116 help="Report the plan without changing any labels.", 

117 ) 

118 args = parser.parse_args(argv) 

119 

120 matched = deployed_paths(changed_files(args.pr)) 

121 if not matched: 

122 print(f"PR #{args.pr}: no deployed path changed; leaving labels unchanged.") 

123 return 0 

124 

125 print(f"PR #{args.pr}: {len(matched)} changed file(s) on a deployed path:") 

126 for path in matched[:20]: 

127 print(f" {path}") 

128 if len(matched) > 20: 

129 print(f" ... and {len(matched) - 20} more") 

130 print( 

131 "Record the live release validation decision in the PR template " 

132 "(docs/LIVE_RELEASE_VALIDATION.md)." 

133 ) 

134 if LABEL in current_labels(args.pr): 

135 print(f" '{LABEL}' is already applied; nothing to do") 

136 return 0 

137 if args.dry_run: 

138 print(f" would add: {LABEL} (dry run: no changes made)") 

139 return 0 

140 add_label(args.pr) 

141 print(f" added: {LABEL}") 

142 return 0 

143 

144 

145if __name__ == "__main__": 

146 sys.exit(main())