Skip to content
Closed
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
98 changes: 98 additions & 0 deletions .github/actions/packer.fmt/action.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
name: Packer - Format
description: Check that Packer HCL templates are formatted with `packer fmt`. On failure, the diff is added to the job summary. Requires the `packer` CLI on the `PATH`.


inputs:
target_dir:
required: false
default: ./
description: "(Optional) The directory to check formatting in. Defaults to `./`."
recursive:
required: false
default: "true"
description: "(Optional) Whether to also check subdirectories. Defaults to `true`."
annotations_enabled:
required: false
default: "true"
description: "(Optional) Whether to annotate the unformatted lines in the pull request when `packer fmt` fails, from the hunks of the diff it printed. Defaults to `true`."

outputs:
skipped:
value: ${{ steps.target.outputs.skipped }}
description: "`true` when `target_dir` holds no Packer HCL files and `packer fmt` was not run. Empty otherwise."
stdout:
value: ${{ steps.fmt.outputs.stdout }}
description: "The STDOUT stream of the call to `packer fmt`. Empty when skipped."
stderr:
value: ${{ steps.fmt.outputs.stderr }}
description: "The STDERR stream of the call to `packer fmt`. Empty when skipped."
exitcode:
value: ${{ steps.fmt.outputs.exitcode }}
description: "The exit code of the call to `packer fmt`. 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 }}
RECURSIVE: ${{ inputs.recursive }}
run: |
target_dir="${TARGET_DIR%/}"
target_dir="${target_dir:-.}"
echo "target_dir=$target_dir" >> "$GITHUB_OUTPUT"

# `packer fmt` exits 0 on a directory without Packer HCL files, which would report a vacuous pass.
depth_args=()
[ "$RECURSIVE" = "true" ] || depth_args=(-maxdepth 1)
if [ -z "$(find "$target_dir" "${depth_args[@]}" -type f \( -name '*.pkr.hcl' -o -name '*.pkrvars.hcl' \) -print -quit)" ]; then
echo "::notice::Skipping packer fmt: no Packer HCL files in $target_dir."
echo "skipped=true" >> "$GITHUB_OUTPUT"
fi

- name: Check Packer Format
id: fmt
if: steps.target.outputs.skipped != 'true'
uses: tedilabs/github-actions/.github/actions/shell.run@main
env:
TARGET_DIR: ${{ steps.target.outputs.target_dir }}
RECURSIVE: ${{ inputs.recursive }}
with:
run: |
args=(-check -diff)
if [ "$RECURSIVE" = "true" ]; then
args+=(-recursive)
fi

packer fmt "${args[@]}" "$TARGET_DIR"

# The diff already names each file (`+++ new/<file>`) and the first line of every hunk (`@@ -a,b +c,d @@`), so
# the annotations are read from the captured output. `packer fmt` prints the paths as given, relative to the
# repository.
- name: Annotate Findings
id: fmt-annotate
if: always() && inputs.annotations_enabled == 'true' && steps.fmt.outcome == 'failure'
shell: bash
env:
LOG_FILE: ${{ steps.fmt.outputs.log_file }}
run: |
awk '
/^\+\+\+ new\// { file = substr($0, 9); next }
/^@@ / && file != "" {
line = $3; sub(/^\+/, "", line); sub(/,.*/, "", line)
printf "::error file=%s,line=%s,title=packer fmt · Not formatted::Run `packer fmt` to format this file.\n", file, line
}
' "$LOG_FILE"

- name: Add Failure Details to Job Summary
id: fmt-summary
if: always() && steps.fmt.outcome == 'failure'
uses: tedilabs/github-actions/.github/actions/github.step-summary@main
with:
title: "❌ packer fmt · ${{ steps.target.outputs.target_dir }}"
file: ${{ steps.fmt.outputs.log_file }}
lang: diff
114 changes: 114 additions & 0 deletions .github/actions/packer.validate/action.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
name: Packer - Validate
description: Install the required plugins of a Packer template directory with `packer init` and validate it with `packer validate`. On failure, the output is added to the job summary. Requires the `packer` CLI on the `PATH`.


inputs:
target_dir:
required: false
default: ./
description: "(Optional) The Packer template directory to initialize and validate. Defaults to `./`."
github_token:
required: false
default: ${{ github.token }}
description: "(Optional) The GitHub token used by `packer init` to download plugins from GitHub without hitting the anonymous rate limit, exported as `PACKER_GITHUB_API_TOKEN`. Defaults to the automatically generated `github.token`."
annotations_enabled:
required: false
default: "true"
description: "(Optional) Whether to annotate the failing lines in the pull request when `packer validate` fails, from the `Error:` and `on <file> line <n>` lines it printed. Defaults to `true`."

outputs:
skipped:
value: ${{ steps.target.outputs.skipped }}
description: "`true` when `target_dir` holds no Packer template files and neither `packer init` nor `packer validate` was run. Empty otherwise."
# When `packer init` fails, `packer validate` does not run, so the outputs come from the command that failed.
stdout:
value: ${{ steps.init.outcome == 'failure' && steps.init.outputs.stdout || steps.validate.outputs.stdout }}
description: "The STDOUT stream of the call to `packer validate`, or to `packer init` when it failed. Empty when skipped."
stderr:
value: ${{ steps.init.outcome == 'failure' && steps.init.outputs.stderr || steps.validate.outputs.stderr }}
description: "The STDERR stream of the call to `packer validate`, or to `packer init` when it failed. Empty when skipped."
exitcode:
value: ${{ steps.init.outcome == 'failure' && steps.init.outputs.exitcode || steps.validate.outputs.exitcode }}
description: "The exit code of the call to `packer validate`, or to `packer init` 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 }}
run: |
target_dir="${TARGET_DIR%/}"
target_dir="${target_dir:-.}"
echo "target_dir=$target_dir" >> "$GITHUB_OUTPUT"

# `packer validate` fails on a directory without templates, which would report a failure for an empty target.
if [ -z "$(find "$target_dir" -maxdepth 1 -type f \( -name '*.pkr.hcl' -o -name '*.pkr.json' \) -print -quit)" ]; then
echo "::notice::Skipping packer validate: no Packer template files in $target_dir."
echo "skipped=true" >> "$GITHUB_OUTPUT"
exit 0
fi

packer version

- name: Packer Init
id: init
if: steps.target.outputs.skipped != 'true'
uses: tedilabs/github-actions/.github/actions/shell.run@main
env:
PACKER_GITHUB_API_TOKEN: ${{ inputs.github_token }}
TARGET_DIR: ${{ steps.target.outputs.target_dir }}
with:
run: |
packer init "$TARGET_DIR"

- name: Add Failure Details to Job Summary
id: init-summary
if: always() && steps.init.outcome == 'failure'
uses: tedilabs/github-actions/.github/actions/github.step-summary@main
with:
title: "❌ packer init · ${{ steps.target.outputs.target_dir }}"
file: ${{ steps.init.outputs.log_file }}
lang: text

- name: Packer Validate
id: validate
if: steps.target.outputs.skipped != 'true'
uses: tedilabs/github-actions/.github/actions/shell.run@main
env:
TARGET_DIR: ${{ steps.target.outputs.target_dir }}
with:
run: |
packer validate "$TARGET_DIR"

# `packer validate` has no machine-readable output, but each diagnostic prints `Error: <summary>` followed by
# `on <file> line <n>:` with the path as given, relative to the repository.
- name: Annotate Findings
id: validate-annotate
if: always() && inputs.annotations_enabled == 'true' && steps.validate.outcome == 'failure'
shell: bash
env:
LOG_FILE: ${{ steps.validate.outputs.log_file }}
run: |
awk '
/^(Error|Warning): / { severity = tolower($1); sub(/:$/, "", severity); summary = substr($0, index($0, ": ") + 2); next }
/^[[:space:]]+on .+ line [0-9]+/ && summary != "" {
file = $2; line = $4; sub(/[:,].*$/, "", line)
gsub(/%/, "%25", summary)
printf "::%s file=%s,line=%s,title=packer validate · %s::%s\n", severity, file, line, summary, summary
summary = ""
}
' "$LOG_FILE"

- name: Add Failure Details to Job Summary
id: validate-summary
if: always() && steps.validate.outcome == 'failure'
uses: tedilabs/github-actions/.github/actions/github.step-summary@main
with:
title: "❌ packer validate · ${{ steps.target.outputs.target_dir }}"
file: ${{ steps.validate.outputs.log_file }}
lang: text
Loading
Loading