From bb926557aa537f5542d7c083da08bf5e4320a010 Mon Sep 17 00:00:00 2001 From: Byungjin Park Date: Mon, 21 Sep 2026 15:13:49 +0900 Subject: [PATCH 1/4] feat(actions): add ansible.ansible-lint composite action --- .../actions/ansible.ansible-lint/action.yaml | 142 ++++++++++++++++++ 1 file changed, 142 insertions(+) create mode 100644 .github/actions/ansible.ansible-lint/action.yaml diff --git a/.github/actions/ansible.ansible-lint/action.yaml b/.github/actions/ansible.ansible-lint/action.yaml new file mode 100644 index 0000000..c65f9c9 --- /dev/null +++ b/.github/actions/ansible.ansible-lint/action.yaml @@ -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 From 51290d66574e5a2b577cb1f3fb7ad778681b835b Mon Sep 17 00:00:00 2001 From: Byungjin Park Date: Mon, 21 Sep 2026 15:13:49 +0900 Subject: [PATCH 2/4] feat(workflows): add ansible.integration reusable workflow --- .github/workflows/ansible.integration.yaml | 86 ++++++++++++++++++++++ 1 file changed, 86 insertions(+) create mode 100644 .github/workflows/ansible.integration.yaml diff --git a/.github/workflows/ansible.integration.yaml b/.github/workflows/ansible.integration.yaml new file mode 100644 index 0000000..0c49ed1 --- /dev/null +++ b/.github/workflows/ansible.integration.yaml @@ -0,0 +1,86 @@ +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." + + python_version: + type: string + required: false + default: latest + description: "(Optional) The version of `python` to install when the repository does not pin one in `mise.toml` or `.tool-versions`. `ansible-lint` is installed into it with `pip`. Defaults to `latest`." + 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 + + # Written to a throwaway global mise config, so the repository's own `mise.toml` / `.tool-versions` + # still take precedence while a self-hosted runner's real global config is left untouched. + - name: Set Default Tool Versions + id: default-tools + env: + PYTHON_VERSION: ${{ inputs.python_version }} + run: | + config_file="$RUNNER_TEMP/mise-defaults.toml" + cat > "$config_file" <> "$GITHUB_ENV" + + - name: Set up tools + id: setup-tools + uses: tedilabs/github-actions/.github/actions/mise.setup-tools@main + + - 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 }} From 5aa6360f981f773f60afc842529e5eb26eb991a2 Mon Sep 17 00:00:00 2001 From: Byungjin Park Date: Mon, 21 Sep 2026 16:00:27 +0900 Subject: [PATCH 3/4] fix(workflows): install only python in the Ansible integration workflow Without `install_args`, `mise install` resolves every tool in the calling repository's mise config. `packer-templates` pins `packer` and `terraform` in `.tool-versions`, neither of which a lint job runs, as the Terraform integration workflows already avoid by naming the tools they need. --- .github/workflows/ansible.integration.yaml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/ansible.integration.yaml b/.github/workflows/ansible.integration.yaml index 0c49ed1..ec638dc 100644 --- a/.github/workflows/ansible.integration.yaml +++ b/.github/workflows/ansible.integration.yaml @@ -61,9 +61,13 @@ jobs: echo "MISE_GLOBAL_CONFIG_FILE=$config_file" >> "$GITHUB_ENV" + # Only `python` is installed, since `ansible-lint` comes from `pip` below. A repository that pins other + # tools in its mise config does not pay to install them for a lint job that never runs them. - name: Set up tools id: setup-tools uses: tedilabs/github-actions/.github/actions/mise.setup-tools@main + with: + install_args: python - name: Install ansible-lint id: install-ansible-lint From d8a18be65f24dd4b7f4f305a0c4b0c9356f0c325 Mon Sep 17 00:00:00 2001 From: Byungjin Park Date: Mon, 21 Sep 2026 16:06:03 +0900 Subject: [PATCH 4/4] refactor(workflows): take the python version from the repository's mise config Drop the `python_version` input and the throwaway global mise config it was written to, matching the Terraform integration workflows since #18: the tools and their versions come from the calling repository's own `mise.toml` or `.tool-versions`, with no workflow-side default. A caller therefore has to pin `python` there. Pass the project directory as `working_directory` as well, so a `mise.toml` placed there overrides the repository-wide one. --- .github/workflows/ansible.integration.yaml | 27 ++++------------------ 1 file changed, 5 insertions(+), 22 deletions(-) diff --git a/.github/workflows/ansible.integration.yaml b/.github/workflows/ansible.integration.yaml index ec638dc..201f410 100644 --- a/.github/workflows/ansible.integration.yaml +++ b/.github/workflows/ansible.integration.yaml @@ -24,11 +24,6 @@ on: 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." - python_version: - type: string - required: false - default: latest - description: "(Optional) The version of `python` to install when the repository does not pin one in `mise.toml` or `.tool-versions`. `ansible-lint` is installed into it with `pip`. Defaults to `latest`." ansible_lint_version: type: string required: false @@ -46,27 +41,15 @@ jobs: id: checkout uses: actions/checkout@v7 - # Written to a throwaway global mise config, so the repository's own `mise.toml` / `.tool-versions` - # still take precedence while a self-hosted runner's real global config is left untouched. - - name: Set Default Tool Versions - id: default-tools - env: - PYTHON_VERSION: ${{ inputs.python_version }} - run: | - config_file="$RUNNER_TEMP/mise-defaults.toml" - cat > "$config_file" <> "$GITHUB_ENV" - - # Only `python` is installed, since `ansible-lint` comes from `pip` below. A repository that pins other - # tools in its mise config does not pay to install them for a lint job that never runs them. + # 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