Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
142 changes: 142 additions & 0 deletions .github/actions/ansible.ansible-lint/action.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
name: Ansible - ansible-lint
description: Lint Ansible playbooks and roles with `ansible-lint`. On failure, the findings are added to the job summary. Requires the `ansible-lint` CLI on the `PATH`.


inputs:
target_dir:
required: false
default: ./
description: "(Optional) The Ansible project directory to lint, where `.ansible-lint` and `ansible.cfg` live. Defaults to `./`."
requirements_file:
required: false
description: "(Optional) The path to a Galaxy requirements file, relative to the repository root (e.g. `ansible/requirements.yaml`). When set, its collections are installed outside the checkout and exposed to `ansible-lint` through `ANSIBLE_COLLECTIONS_PATH`, so they are resolvable but never linted. When omitted, `ansible-lint` installs a `requirements.yml` found in the project directory on its own."
annotations_enabled:
required: false
default: "true"
description: "(Optional) Whether to annotate the reported lines in the pull request when `ansible-lint` fails, by re-running it with `-f json` and turning each finding into a workflow error, warning, or notice. Defaults to `true`."

outputs:
skipped:
value: ${{ steps.target.outputs.skipped }}
description: "`true` when `target_dir` holds no YAML files and `ansible-lint` was not run. Empty otherwise."
# When the collection install fails, `ansible-lint` does not run, so the outputs come from the command that failed.
stdout:
value: ${{ steps.install-collections.outcome == 'failure' && steps.install-collections.outputs.stdout || steps.ansible-lint.outputs.stdout }}
description: "The STDOUT stream of the call to `ansible-lint`, or to `ansible-galaxy collection install` when it failed. Empty when skipped."
stderr:
value: ${{ steps.install-collections.outcome == 'failure' && steps.install-collections.outputs.stderr || steps.ansible-lint.outputs.stderr }}
description: "The STDERR stream of the call to `ansible-lint`, or to `ansible-galaxy collection install` when it failed. Empty when skipped."
exitcode:
value: ${{ steps.install-collections.outcome == 'failure' && steps.install-collections.outputs.exitcode || steps.ansible-lint.outputs.exitcode }}
description: "The exit code of the call to `ansible-lint`, or to `ansible-galaxy collection install` when it failed. Empty when skipped. The action still fails on a non-zero exit code, so use `continue-on-error: true` to inspect it."


runs:
using: composite

steps:
- name: Resolve Target
id: target
shell: bash
env:
TARGET_DIR: ${{ inputs.target_dir }}
REQUIREMENTS_FILE: ${{ inputs.requirements_file }}
run: |
target_dir="${TARGET_DIR%/}"
target_dir="${target_dir:-.}"
echo "target_dir=$target_dir" >> "$GITHUB_OUTPUT"

# `ansible-lint` exits 0 on a directory without YAML files, which would report a vacuous pass.
if [ -z "$(find "$target_dir" -path '*/.git' -prune -o -type f \( -name '*.yml' -o -name '*.yaml' \) -print -quit)" ]; then
echo "::notice::Skipping ansible-lint: no YAML files in $target_dir."
echo "skipped=true" >> "$GITHUB_OUTPUT"
exit 0
fi

# Collections go outside the checkout, so they resolve for linting but are never linted themselves.
if [ -n "$REQUIREMENTS_FILE" ]; then
echo "collections_path=$RUNNER_TEMP/ansible/collections" >> "$GITHUB_OUTPUT"
fi

ansible-lint --version

- name: Install Galaxy Collections
id: install-collections
if: steps.target.outputs.skipped != 'true' && inputs.requirements_file != ''
uses: tedilabs/github-actions/.github/actions/shell.run@main
env:
REQUIREMENTS_FILE: ${{ inputs.requirements_file }}
COLLECTIONS_PATH: ${{ steps.target.outputs.collections_path }}
with:
run: |
# Restrict the search path so collections already present elsewhere on the runner are not skipped.
ANSIBLE_COLLECTIONS_PATH="$COLLECTIONS_PATH" \
ansible-galaxy collection install -r "$REQUIREMENTS_FILE" -p "$COLLECTIONS_PATH"

- name: Add Failure Details to Job Summary
id: install-collections-summary
if: always() && steps.install-collections.outcome == 'failure'
uses: tedilabs/github-actions/.github/actions/github.step-summary@main
with:
title: "❌ ansible-galaxy collection install · ${{ inputs.requirements_file }}"
file: ${{ steps.install-collections.outputs.log_file }}
lang: text

- name: Run ansible-lint
id: ansible-lint
if: steps.target.outputs.skipped != 'true'
uses: tedilabs/github-actions/.github/actions/shell.run@main
env:
COLLECTIONS_PATH: ${{ steps.target.outputs.collections_path }}
with:
# Run inside the project so `.ansible-lint`, `ansible.cfg`, and relative `roles_path` resolve against it.
working_directory: ${{ steps.target.outputs.target_dir }}
run: |
if [ -n "$COLLECTIONS_PATH" ]; then
export ANSIBLE_COLLECTIONS_PATH="$COLLECTIONS_PATH"
fi

# Under GitHub Actions, `ansible-lint` emits annotations on its own, with paths relative to this directory
# rather than the repository and without the tool in the title, so they are turned off here and produced
# from its JSON output in the next step instead.
GITHUB_ACTIONS=false ansible-lint --nocolor

# GitHub only renders annotations from `::error` lines, so the findings are re-read as JSON. Paths are relative
# to the project directory, so `target_dir` is prefixed.
- name: Annotate Findings
id: ansible-lint-annotate
if: always() && inputs.annotations_enabled == 'true' && steps.ansible-lint.outcome == 'failure'
shell: bash
env:
TARGET_DIR: ${{ steps.target.outputs.target_dir }}
COLLECTIONS_PATH: ${{ steps.target.outputs.collections_path }}
run: |
if [ -n "$COLLECTIONS_PATH" ]; then
export ANSIBLE_COLLECTIONS_PATH="$COLLECTIONS_PATH"
fi

prefix=""
if [ "$TARGET_DIR" != "." ]; then
prefix="$TARGET_DIR/"
fi

# A workflow command takes one line, so newlines and the property separators are percent-encoded the way
# the runner decodes them.
cd "$TARGET_DIR"
GITHUB_ACTIONS=false ansible-lint --nocolor -f json 2>/dev/null | jq -r --arg prefix "$prefix" '
def esc: gsub("%"; "%25") | gsub("\r"; "%0D") | gsub("\n"; "%0A");
def prop: esc | gsub(":"; "%3A") | gsub(","; "%2C");
.[]
| (if .severity == "minor" then "warning" elif .severity == "info" then "notice" else "error" end) as $severity
| (if (.location.lines.begin // 0) > 0 then ",line=\(.location.lines.begin)" else "" end) as $line
| "::\($severity) file=\($prefix + .location.path)\($line),title=\("ansible-lint · " + .check_name | prop)::\(.description | esc)"
' || true

- name: Add Failure Details to Job Summary
id: ansible-lint-summary
if: always() && steps.ansible-lint.outcome == 'failure'
uses: tedilabs/github-actions/.github/actions/github.step-summary@main
with:
title: "❌ ansible-lint · ${{ steps.target.outputs.target_dir }}"
file: ${{ steps.ansible-lint.outputs.log_file }}
lang: text
73 changes: 73 additions & 0 deletions .github/workflows/ansible.integration.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
name: Ansible - Integration


on:
workflow_call:
inputs:
runs_on:
description: >
JSON-encoded runs-on value.
Examples:
- '"ubuntu-latest"'
- '["self-hosted","linux","x64"]'
required: false
type: string
default: '"ubuntu-latest"'

target_dir:
type: string
required: false
default: ./
description: "(Optional) The Ansible project directory to lint, where `.ansible-lint` and `ansible.cfg` live. Defaults to `./`."
requirements_file:
type: string
required: false
description: "(Optional) The path to a Galaxy requirements file, relative to the repository root (e.g. `ansible/requirements.yaml`). When set, its collections are installed so `ansible-lint` can resolve them. When omitted, `ansible-lint` installs a `requirements.yml` found in the project directory on its own."

ansible_lint_version:
type: string
required: false
default: latest
description: "(Optional) The version of `ansible-lint` to install with `pip` (e.g. `25.1.3`). Defaults to `latest`."


jobs:
lint:
name: Lint (ansible-lint)
runs-on: ${{ fromJson(inputs.runs_on) }}

steps:
- name: Checkout
id: checkout
uses: actions/checkout@v7

# The tools and their versions come from the repository's mise config (`mise.toml` or `.tool-versions`),
# which therefore has to pin `python`. The project directory is passed so a `mise.toml` placed there
# overrides the repository-wide one, and only `python` is installed, since `ansible-lint` comes from
# `pip` below and the other tools a repository pins are never run by a lint job.
- name: Set up tools
id: setup-tools
uses: tedilabs/github-actions/.github/actions/mise.setup-tools@main
with:
working_directory: ${{ inputs.target_dir }}
install_args: python

- name: Install ansible-lint
id: install-ansible-lint
env:
ANSIBLE_LINT_VERSION: ${{ inputs.ansible_lint_version }}
run: |
package="ansible-lint"
if [ "$ANSIBLE_LINT_VERSION" != "latest" ]; then
package="ansible-lint==$ANSIBLE_LINT_VERSION"
fi

python -m pip install --upgrade pip
python -m pip install "$package"

- name: Lint (ansible-lint)
id: ansible-lint
uses: tedilabs/github-actions/.github/actions/ansible.ansible-lint@main
with:
target_dir: ${{ inputs.target_dir }}
requirements_file: ${{ inputs.requirements_file }}
Loading