Skip to content

Commit

Permalink
Add actionlint to workflow linter (#39)
Browse files Browse the repository at this point in the history
* adding actionlint to default workflow-linter rules

* setting fail level to warning at first

* adding error messages

* catching windows installations

* Catching FileNotFound exception

* Adding filename for action lint to run against

* adding a test for actionlint

* adding error message for missing filename

* apply black

* trying actionlint from source

* importing shutil

* separated installation into a separate function

* added an additional test

* removing failure if actionlint fails

* Improve the test coverage for run_actionlint rule

* catching unknown error

* adding package manager to requirements

---------

Co-authored-by: Andy Pixley <[email protected]>
Co-authored-by: Opeyemi Alao <[email protected]>
  • Loading branch information
3 people authored Jan 14, 2025
1 parent 8fc0766 commit bcbdda5
Show file tree
Hide file tree
Showing 11 changed files with 392 additions and 13 deletions.
5 changes: 4 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ Thumbs.db
*.sublime-workspace

# Visual Studio Code
.vscode/*
.vscode
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
Expand All @@ -35,3 +35,6 @@ flake.*
# Python
**/__pycache__/**
*.pyc
.venv/
.pytest_cache
.pytype
7 changes: 4 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
# Bitwarden Workflow Linter

Bitwarden's Workflow Linter is an extensible linter to apply opinionated organization-specific
GitHub Action standards. It was designed to be used alongside tools like
[action-lint](https://github.com/rhysd/actionlint) and
[yamllint](https://github.com/adrienverge/yamllint) to check for correct Action syntax and enforce
GitHub Action standards. It was designed to be used alongside
[yamllint](https://github.com/adrienverge/yamllint) to enforce
specific YAML standards.

To see an example of Workflow Linter in practice in GitHub Action, see the
Expand Down Expand Up @@ -64,6 +63,8 @@ options:

- Python 3.11
- pipenv
- Windows systems: Chocolatey package manager
- Mac OS systems: Homebrew package manager

### Setup

Expand Down
1 change: 1 addition & 0 deletions settings.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -6,5 +6,6 @@ enabled_rules:
- bitwarden_workflow_linter.rules.step_approved.RuleStepUsesApproved
- bitwarden_workflow_linter.rules.step_pinned.RuleStepUsesPinned
- bitwarden_workflow_linter.rules.underscore_outputs.RuleUnderscoreOutputs
- bitwarden_workflow_linter.rules.run_actionlint.RunActionlint

approved_actions_path: default_actions.json
1 change: 1 addition & 0 deletions src/bitwarden_workflow_linter/default_settings.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -6,5 +6,6 @@ enabled_rules:
- bitwarden_workflow_linter.rules.step_approved.RuleStepUsesApproved
- bitwarden_workflow_linter.rules.step_pinned.RuleStepUsesPinned
- bitwarden_workflow_linter.rules.underscore_outputs.RuleUnderscoreOutputs
- bitwarden_workflow_linter.rules.run_actionlint.RunActionlint

approved_actions_path: default_actions.json
24 changes: 19 additions & 5 deletions src/bitwarden_workflow_linter/load.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,20 +39,32 @@ def __load_workflow_from_file(cls, filename: str) -> CommentedMap:
objects (depending on their location in the file).
"""
with open(filename, encoding="utf8") as file:
return yaml.load(file)
if not file:
raise WorkflowBuilderError(f"Could not load {filename}")
try:
return yaml.load(file)
except Exception as e:
raise WorkflowBuilderError(f"Error loading YAML file {filename}: {e}")

@classmethod
def __build_workflow(cls, loaded_yaml: CommentedMap) -> Workflow:
def __build_workflow(cls, filename: str, loaded_yaml: CommentedMap) -> Workflow:
"""Parse the YAML and build out the workflow to run Rules against.
Args:
filename:
The name of the file that the YAML was loaded from
loaded_yaml:
YAML that was loaded from either code or a file
Returns
A Workflow to run linting Rules against
"""
return Workflow.init("", loaded_yaml)
try:
if not loaded_yaml:
raise WorkflowBuilderError("No YAML loaded")
return Workflow.init("", filename, loaded_yaml)
except Exception as e:
raise WorkflowBuilderError(f"Error building workflow: {e}")

@classmethod
def build(
Expand All @@ -76,9 +88,11 @@ def build(
be loaded from disk
"""
if from_file and filename is not None:
return cls.__build_workflow(cls.__load_workflow_from_file(filename))
return cls.__build_workflow(
filename, cls.__load_workflow_from_file(filename)
)
elif not from_file and workflow is not None:
return cls.__build_workflow(workflow)
return cls.__build_workflow("", workflow)

raise WorkflowBuilderError(
"The workflow must either be built from a file or from a CommentedMap"
Expand Down
4 changes: 3 additions & 1 deletion src/bitwarden_workflow_linter/models/workflow.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,14 +23,16 @@ class Workflow:
"""

key: str = ""
filename: Optional[str] = None
name: Optional[str] = None
on: Optional[CommentedMap] = None
jobs: Optional[Dict[str, Job]] = None

@classmethod
def init(cls: Self, key: str, data: CommentedMap) -> Self:
def init(cls: Self, key: str, filename: str, data: CommentedMap) -> Self:
init_data = {
"key": key,
"filename": filename,
"name": data["name"] if "name" in data else None,
"on": data["on"] if "on" in data else None,
}
Expand Down
108 changes: 108 additions & 0 deletions src/bitwarden_workflow_linter/rules/run_actionlint.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
"""A Rule to run actionlint on workflows."""

from typing import Optional, Tuple
import subprocess
import platform
import urllib.request
import os

from ..rule import Rule
from ..models.workflow import Workflow
from ..utils import LintLevels, Settings


def install_actionlint(platform_system: str) -> Tuple[bool, str]:
"""If actionlint is not installed, detects OS platform
and installs actionlint"""

error = f"An error occurred when installing Actionlint on {platform_system}"

if platform_system.startswith("Linux"):
return install_actionlint_source(error)
elif platform_system == "Darwin":
try:
subprocess.run(["brew", "install", "actionlint"], check=True)
return True, ""
except (FileNotFoundError, subprocess.CalledProcessError):
return False, f"{error} : check Brew installation"
elif platform_system.startswith("Win"):
try:
subprocess.run(["choco", "install", "actionlint", "-y"], check=True)
return True, ""
except (FileNotFoundError, subprocess.CalledProcessError):
return False, f"{error} : check Choco installation"
return False, error


def install_actionlint_source(error) -> Tuple[bool, str]:
"""Install Actionlint Binary from provided script"""
url = "https://raw.githubusercontent.com/rhysd/actionlint/main/scripts/download-actionlint.bash"
version = "1.6.17"
request = urllib.request.urlopen(url)
with open("download-actionlint.bash", "wb+") as fp:
fp.write(request.read())
try:
subprocess.run(["bash", "download-actionlint.bash", version], check=True)
return True, os.getcwd()
except (FileNotFoundError, subprocess.CalledProcessError):
return False, error


def check_actionlint(platform_system: str) -> Tuple[bool, str]:
"""Check if the actionlint is in the system's PATH."""
try:
subprocess.run(
["actionlint", "--version"],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
check=True,
)
return True, ""
except subprocess.CalledProcessError:
return (
False,
"Failed to install Actionlint, \
please check your package installer or manually install it",
)
except FileNotFoundError:
return install_actionlint(platform_system)


class RunActionlint(Rule):
"""Rule to run actionlint as part of workflow linter V2."""

def __init__(self, settings: Optional[Settings] = None) -> None:
self.message = "Actionlint must pass without errors"
self.on_fail = LintLevels.WARNING
self.compatibility = [Workflow]
self.settings = settings

def fn(self, obj: Workflow) -> Tuple[bool, str]:
if not obj or not obj.filename:
raise AttributeError(
"Running actionlint without a filename is not currently supported"
)

installed, location = check_actionlint(platform.system())
if installed:
if location:
result = subprocess.run(
[location + "/actionlint", obj.filename],
capture_output=True,
text=True,
check=False,
)
else:
result = subprocess.run(
["actionlint", obj.filename],
capture_output=True,
text=True,
check=False,
)
if result.returncode == 1:
return False, result.stdout
if result.returncode > 1:
return False, result.stdout
return True, ""
else:
return False, self.message
32 changes: 32 additions & 0 deletions tests/fixtures/test_workflow.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
name: test
on:
workflow_dispatch:
pull_request:

jobs:
job-key:
name: Test
runs-on: ubuntu-latest
steps:
- name: Test
run: echo test

call-workflow:
uses: bitwarden/server/.github/workflows/workflow-linter.yml@master

test-normal-action:
name: Download Latest
runs-on: ubuntu-20.04
steps:
- name: Checkout
uses: actions/checkout@2541b1294d2704b0964813337f33b291d3f8596b

- run: |
echo test
test-local-action:
name: Testing a local action call
runs-on: ubuntu-20.04
steps:
- name: local-action
uses: ./version-bump
36 changes: 36 additions & 0 deletions tests/fixtures/test_workflow_incorrect.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
name: test
on:
push:
branches:
-
path:
- "src/**"
workflow_dispatch:

jobs:
job-key:
name: Test
runs-on: ubuntu-latest
steps:
- name: Test
run: echo test

call-workflow:
uses: bitwarden/server/.github/workflows/workflow-linter.yml@master

test-normal-action:
name: Download Latest
runs-on: ubuntu-20.04
steps:
- name: Checkout
uses: actions/checkout@2541b1294d2704b0964813337f33b291d3f8596b

- run: |
echo test
test-local-action:
name: Testing a local action call
runs-on: ubuntu-20.04
steps:
- name: local-action
uses: ./version-bump
Loading

0 comments on commit bcbdda5

Please sign in to comment.