gateway/gateway.py (102 lines of code) (raw):

import os from datetime import date, timedelta from pathlib import Path from typing import Dict, NotRequired, TypedDict import yaml class RefDetails(TypedDict): """ Type definition for reference details of GitHub Actions for actions.yml Attributes: expires_at: After this date the reference will be removed keep: Optional flag to retain the reference regardless of expiry """ expires_at: date keep: NotRequired[bool] ActionRefs = Dict[str, RefDetails] """Dictionary mapping action references to their details""" ActionsYAML = Dict[str, ActionRefs] """Dictionary mapping action names to their reference details""" def calculate_expiry(weeks=4): """ Calculate an expiration date from today. Args: weeks: Number of weeks from today (default: 4) Returns: date: The calculated expiry date """ return date.today() + timedelta(weeks=weeks) def load_yaml(path: Path) -> dict: """ Load and parse a YAML file. Args: path: Path to the YAML file Returns: dict: Parsed YAML content """ with open(path, "r") as file: actions = yaml.safe_load(file) return actions class IndentDumper(yaml.Dumper): """ Custom YAML dumper that maintains indentation for improved readability. """ def increase_indent(self, flow=False, indentless=False): return super(IndentDumper, self).increase_indent(flow, False) def write_yaml(path: Path, yaml_dict: dict | list): """ Write data as YAML to a file using custom indentation. Args: path: Path to write the YAML file yaml_dict: Data to write as YAML """ with open(path, "w") as file: yaml.dump(yaml_dict, file, Dumper=IndentDumper, sort_keys=False) def write_str(path: Path, content: str): with open(path, "w") as file: file.write(content) def on_gha(): """ Check if the code is running in a GitHub Actions environment. Returns: bool: True if running in GitHub Actions, False otherwise """ return os.environ.get("GITHUB_ACTION") is not None def gha_print(content: str, title: str = ""): """ Print content in GitHub Actions with group formatting. Does nothing if not running in GitHub Actions. Args: content: The content to print title: Optional title for the group (default: empty string) """ if not on_gha(): return print(f"::group::{title}") print(content) print("::endgroup::") def generate_workflow(actions: ActionsYAML) -> str: """ Generate a GitHub workflow file as a string from the actions.yml dictionary. Args: actions: Dictionary of actions and their references Returns: str: Generated workflow file content """ # Github Workflow 'yaml' has slight deviations from the yaml spec. (e.g. keys with no values) # Because of that it's much easier to generate this as a string rather # then use pyyaml to dump this from a dict. header = """name: Dummy Workflow on: workflow_dispatch: jobs: dummy: if: false runs-on: ubuntu-latest steps: """ steps = [] steps.extend( f" - uses: {name}@{ref}" for name, refs in actions.items() for ref, details in refs.items() # exclude actions that entered expiry range, use gt to also exclude actions that were expired today. if details["expires_at"] > calculate_expiry() and not details.get("keep") # Exclude refs with "keep" ) return header + "\n".join(steps) def update_refs( dummy_steps: list[dict[str, str]], action_refs: ActionsYAML ) -> ActionsYAML: """ Update action references based on steps from a dummy workflow. Args: dummy_steps: List of steps from a dummy workflow action_refs: Current action references Returns: ActionsYAML: Updated action references """ for step in dummy_steps: name, new_ref = step["uses"].split("@") if name not in action_refs: action_refs[name] = {} refs = action_refs[name] if new_ref not in refs: for _, details in refs.items(): # expire old versions in 12 weeks this will also bump already expired refs further # this allows projects some more time in the case of rapid releases # CVE releases should be handled manually by removing old versions explicitly details["expires_at"] = calculate_expiry(12) refs[new_ref] = {"expires_at": date(2100, 1, 1), "keep": False} return action_refs def update_actions(dummy_path: Path, actions_path: Path): """ Update actions file based on a dummy workflow. Args: dummy_path: Path to the dummy workflow file actions_path: Path to the actions list file """ dummy = load_yaml(dummy_path) steps: list[dict[str, str]] = dummy["jobs"]["dummy"]["steps"] actions: ActionsYAML = load_yaml(actions_path) update_refs(steps, actions) gha_print(yaml.safe_dump(actions), "Generated List") write_yaml(actions_path, actions) def create_pattern(actions: ActionsYAML) -> list[str]: """ Create a pattern list of valid action references. Args: actions: Dictionary of actions and their references Returns: list[str]: List of action patterns (name@ref) """ pattern: list[str] = [] pattern.extend( f"{name}@{ref}" for name, refs in actions.items() for ref, details in refs.items() if date.today() < details.get("expires_at") or details.get("keep") ) return pattern def update_patterns(pattern_path: Path, list_path: Path): """ Update the patterns file based on the actions list. This will overwrite the existing file, so any manual changes will be lost! Args: pattern_path: Path to write the patterns file list_path: Path to the actions list file """ actions: ActionsYAML = load_yaml(list_path) patterns = create_pattern(actions) comment = "# This is a generated file. DO NOT UPDATE MANUALLY.\n" patterns_str = comment + yaml.safe_dump(patterns) gha_print(patterns_str, "Generated Patterns") write_str(pattern_path, patterns_str) def update_workflow(dummy_path: Path, list_path: Path): """ Update the dummy workflow file based on the actions list. This will overwrite the existing file, so any manual changes will be lost! Args: dummy_path: Path to write the dummy workflow file list_path: Path to the actions list file """ actions: ActionsYAML = load_yaml(list_path) workflow = generate_workflow(actions) gha_print(workflow, "Generated Workflow") write_str(dummy_path, workflow) def remove_expired_refs(actions: ActionsYAML): """ Remove expired references from the actions dictionary. Args: actions: Dictionary of actions and their references """ refs_to_remove: list[tuple[str, str]] = [] for name, action in actions.items(): refs_to_remove.extend( (name, ref) for ref, details in action.items() if details["expires_at"] <= date.today() and not details.get("keep") ) # Changing the iterable during iteration raises a RuntimeError for name, ref in refs_to_remove: del actions[name][ref] # remove Actions without refs if not actions[name]: del actions[name] def clean_actions(actions_path: Path): """ Clean up expired actions from the actions file. Args: actions_path: Path to the actions list file """ actions: ActionsYAML = load_yaml(actions_path) remove_expired_refs(actions) gha_print(yaml.safe_dump(actions), "Cleaned Actions") write_yaml(actions_path, actions)