Developer Guide

This developer guide includes more complex interactions like contributing modular updaters. If you haven’t read Installation you should do that first.

Linting

To lint your code, you can install pre-commit and other dependencies to your environment:

$ pip install -r .github/dev-requirements.txt

And run:

$ pre-commit run --all-files

Or install as a hook:

$ pre-commit install

Developing an Updater

Each updater is required to have one file, update.py that uses the UpdaterBase class and has one function to detect. The easiest way to get this structure is to copy another updater completely, and use it as a template.

Updater Class

Your updater class is discovered based on the module folder name. The class should be the uppercase of that, with Updater as a suffix. E.g.,:

  • setoutput -> SetoutputUpdater

  • version -> VersionUpdater

If you don’t follow this convention, we won’t be able to discover it and use it! You’ll also get errors and know very quickly.

Updater Metadata

You are required to have:

  • description

  • name (typically same as the folder name, but not required) must be all lowercase and only - for special characters

Optionally, if you provide a schema (from jsonschema) and set to the schema attribute, this will validate any user settings that your updater accepts. An example class definition is shown below:

schema = {
    "type": "object",
    "properties": {
        # Allow these orgs to use major version strings
        "major_orgs": {"type": "array", "items": {"type": "string"}}
    },
    "additionalProperties": False,
}

class VersionUpdater(UpdaterBase):

    name = "version"
    description = "update action versions"
    schema = schema
    cache = {"tags": {}}

In the example above, notice we also have a “cache” that is used to store tags between runs. We can do this because the updater is instantiated at the beginning of a run, and then the same class used across workflow files. This means that if multiple files use actions/checkout (and thus the version updater needs to look up latest tags) we only do that once!

Updater Settings

As shown above, when an updater defines a schema, this is matched to a block in settings. If you want your settings to have defaults, add the nested block under updaters-><name>. Here is an example in action_updater/settings.yml for the schema above:

updaters:
  version:
    # Repository orgs to allow a major version (and not commit)
    major_orgs:
      - actions
      - docker

Since a developer user will likely be reading this file, it’s recommended to put some comments to explain different fields.

Updater Detect

Your updater class has a main function detect that must exist. Any and all other classes are largely optional (and of course encouraged to have a modular design)! The function should expect an action (action_updater.main.action.GitHubAction) to be provided, and to look through the action.jobs and make any appropriate changes. Here is a basic example. Note that we:

  • Keep track of self.count, setting it to 0 in the beginning, and incrementing it for each change.

  • Make changes directly to job.steps. Since this is a copy of the original config, this is what will be changed (and saved to file, if desired).

  • Return a boolean to indicate if changes were detected.

def detect(self, action):
    """
    An example detection function
    """
    # Set the count to 0
    self.count = 0

    # No point if we don't have jobs!
    if not action.jobs:
        return False

    # For each job, look for steps->updater versions
    for job_name, job in action.jobs.items():

        # These are matched to steps
        for step in job.get("steps", []):

            # Get a "run" section
            run = step.get('run')

            # Get some new updated content
            # Perform checks for syntax, etc. here!
            updated_content = self.do_update(run)

            # Ensure to do a check to see if there are change
            if updated_content != step['run']:
                self.count += 1
                # To then update with changes:
                step["run"] = updated_content

    return self.count != 0

The client will handle displaying changes and otherwise saving updates, so you do not need to worry about that. The UpdaterClass also has several courtesy functions for gettings tags (get_tags_lookup or get_tags along with releases (get_releases) and you can take advantage of them, or add additional API calls if needed to the base class. The updater will also be automatically detected and registered, and included in basic testing, however you do need to add a “before” and “after” set of yaml files, discussed next.

Testing

Each updater should have a <name>-before.yaml and <name>-after.yaml in action_updater/tests/data. The format is simple - it should be a GitHub workflow (any of your choosing!) before and after running an update. The easiest way to make this is to create a “before” file manually (with updates you know need to happen) (in Python) create a client, run detect, and then write to an after file. And be sure to check that your updater worked as you would like! Here is an example (what I used for my test cases):

from action_updater.main import get_client
cli = get_client()

# Before and after files (assuming in present working directory)
before_file = "save-state-before.yaml"
after_file = "save-state-after.yaml"

# Run detect *only* for the updater you care about
action = cli.detect(before_file, updaters=['savestate'])

# Write changes to new file (then check it!)
action[before_file].write(after_file)

And then visually check it - and you should be done! These files will be used in testing, along with testing basic output and metadata for your updater. If you have an idea for an updater but don’t have bandwidth to add? Please ping @vsoch by opening an issue!

Updating Comments

The action.jobs objects that you interact with are actually annotated with comments! If you want to update them, I’ve found a good way to do this is to interact with the step.ca.items (or other json attribte). Basically, this is a lookup of items (based on the key index) for which there is a list that corresponds to the comment location. I found that what works is to define a new set of empty comments, and then to use the provided function (by ruamel) to set one:

# Update the uses step with some new content
step["uses"] = updated.strip()

# Delete all locations of comments for it
step.ca.items["uses"] = [None, None, None, None]

# Add the "end of line" comment (the third one) - will add a CommentedToken
step.yaml_add_eol_comment(f"# {comment}\n", "uses", column=0)

Note that you might want to do something more elegant (e.g., grab the previous comments in positions 0,1,3) or whichever you want to preserve, to save before writing the new EOL comment. You can look at the version updater for this full example. It is how we annotate the end of the line with a new commented version.