From c368ab492d3f517d5019f87b17ddaf75cf4d214e Mon Sep 17 00:00:00 2001 From: Byungjin Park Date: Mon, 14 Sep 2026 21:48:01 +0900 Subject: [PATCH 1/7] feat(actions): add packer.fmt composite action - Check formatting with `packer fmt -check -diff` - Support `recursive` input to also check subdirectories (default `true`) - Add the diff to the job summary on failure via github.step-summary --- .github/actions/packer.fmt/action.yaml | 41 ++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 .github/actions/packer.fmt/action.yaml diff --git a/.github/actions/packer.fmt/action.yaml b/.github/actions/packer.fmt/action.yaml new file mode 100644 index 0000000..225218f --- /dev/null +++ b/.github/actions/packer.fmt/action.yaml @@ -0,0 +1,41 @@ +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`." + + +runs: + using: composite + + steps: + - name: Check Packer Format + id: fmt + shell: bash + env: + TARGET_DIR: ${{ inputs.target_dir }} + RECURSIVE: ${{ inputs.recursive }} + run: | + args=(-check -diff) + if [ "$RECURSIVE" = "true" ]; then + args+=(-recursive) + fi + + packer fmt "${args[@]}" "$TARGET_DIR" 2>&1 | tee "$RUNNER_TEMP/packer-fmt.log" + + - name: Add Failure Details to Job Summary + id: fmt-summary + if: steps.fmt.outcome == 'failure' + uses: tedilabs/github-actions/.github/actions/github.step-summary@main + with: + title: "❌ packer fmt · ${{ inputs.target_dir }}" + file: ${{ runner.temp }}/packer-fmt.log + lang: diff From 92d194c702bc57d66483bf9108df829c68824e66 Mon Sep 17 00:00:00 2001 From: Byungjin Park Date: Mon, 14 Sep 2026 21:48:01 +0900 Subject: [PATCH 2/7] feat(actions): add packer.validate composite action - Install required plugins with `packer init` and run `packer validate` - Export `github_token` as `PACKER_GITHUB_API_TOKEN` to avoid anonymous GitHub API rate limits when downloading plugins - Add init/validate output to the job summary on failure via github.step-summary --- .github/actions/packer.validate/action.yaml | 54 +++++++++++++++++++++ 1 file changed, 54 insertions(+) create mode 100644 .github/actions/packer.validate/action.yaml diff --git a/.github/actions/packer.validate/action.yaml b/.github/actions/packer.validate/action.yaml new file mode 100644 index 0000000..e37083a --- /dev/null +++ b/.github/actions/packer.validate/action.yaml @@ -0,0 +1,54 @@ +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`." + + +runs: + using: composite + + steps: + - name: Packer Init + id: init + shell: bash + env: + PACKER_GITHUB_API_TOKEN: ${{ inputs.github_token }} + TARGET_DIR: ${{ inputs.target_dir }} + run: | + packer version + packer init "$TARGET_DIR" 2>&1 | tee "$RUNNER_TEMP/packer-validate-init.log" + + - name: Add Failure Details to Job Summary + id: init-summary + if: steps.init.outcome == 'failure' + uses: tedilabs/github-actions/.github/actions/github.step-summary@main + with: + title: "❌ packer init · ${{ inputs.target_dir }}" + file: ${{ runner.temp }}/packer-validate-init.log + lang: text + + - name: Packer Validate + id: validate + shell: bash + env: + TARGET_DIR: ${{ inputs.target_dir }} + run: | + packer validate "$TARGET_DIR" 2>&1 | tee "$RUNNER_TEMP/packer-validate.log" + + - name: Add Failure Details to Job Summary + id: validate-summary + if: steps.validate.outcome == 'failure' + uses: tedilabs/github-actions/.github/actions/github.step-summary@main + with: + title: "❌ packer validate · ${{ inputs.target_dir }}" + file: ${{ runner.temp }}/packer-validate.log + lang: text From b8ea7cd379bbdfc255d4e8477cd98e76edcc92dd Mon Sep 17 00:00:00 2001 From: Byungjin Park Date: Mon, 14 Sep 2026 21:48:01 +0900 Subject: [PATCH 3/7] feat(actions): add ansible.ansible-lint composite action - Run `ansible-lint` from the project directory so `.ansible-lint` and `ansible.cfg` are discovered as usual - Optionally install Galaxy collections from `requirements_file` outside the checkout and expose them via `ANSIBLE_COLLECTIONS_PATH` - Rely on ansible-lint's built-in GitHub Actions annotations - Add findings to the job summary on failure via github.step-summary --- .../actions/ansible.ansible-lint/action.yaml | 66 +++++++++++++++++++ 1 file changed, 66 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..d8aa0eb --- /dev/null +++ b/.github/actions/ansible.ansible-lint/action.yaml @@ -0,0 +1,66 @@ +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." + + +runs: + using: composite + + steps: + - name: Install Galaxy Collections + id: install-collections + if: inputs.requirements_file != '' + shell: bash + env: + REQUIREMENTS_FILE: ${{ inputs.requirements_file }} + run: | + collections_path="$RUNNER_TEMP/ansible/collections" + + # 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" 2>&1 | tee "$RUNNER_TEMP/ansible-galaxy.log" + echo "collections_path=$collections_path" >> "$GITHUB_OUTPUT" + + - name: Add Failure Details to Job Summary + id: install-collections-summary + if: 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: ${{ runner.temp }}/ansible-galaxy.log + lang: text + + - name: Run ansible-lint + id: ansible-lint + shell: bash + env: + TARGET_DIR: ${{ inputs.target_dir }} + COLLECTIONS_PATH: ${{ steps.install-collections.outputs.collections_path }} + run: | + if [ -n "$COLLECTIONS_PATH" ]; then + export ANSIBLE_COLLECTIONS_PATH="$COLLECTIONS_PATH" + fi + + # Run inside the project so `.ansible-lint`, `ansible.cfg`, and relative `roles_path` resolve against it. + # Under GitHub Actions, `ansible-lint` emits workflow annotations on its own. + cd "$TARGET_DIR" + ansible-lint --version + ansible-lint --nocolor 2>&1 | tee "$RUNNER_TEMP/ansible-lint.log" + + - name: Add Failure Details to Job Summary + id: ansible-lint-summary + if: steps.ansible-lint.outcome == 'failure' + uses: tedilabs/github-actions/.github/actions/github.step-summary@main + with: + title: "❌ ansible-lint · ${{ inputs.target_dir }}" + file: ${{ runner.temp }}/ansible-lint.log + lang: text From 791826d08f08614a08de7f4779516e4e823dd07c Mon Sep 17 00:00:00 2001 From: Byungjin Park Date: Tue, 15 Sep 2026 16:27:55 +0900 Subject: [PATCH 4/7] feat(workflows): add packer.templates.integration reusable workflow - Detect changed Packer template directories under builds/** - Run packer fmt and packer init/validate per directory with continue-on-error so all checks run - Collect per-directory outcomes via github.matrix-report and publish an aggregated report to the job summary and a sticky PR comment - Add pr_comment_enabled input (default true) to toggle the PR comment - Write default tool versions to a throwaway global mise config pointed at by `MISE_GLOBAL_CONFIG_FILE`, so repository pins in `mise.toml` / `.tool-versions` win and a self-hosted runner's config is left alone - Export `AWS_DEFAULT_REGION` so Amazon builders validate without AWS config --- .../packer.templates.integration.yaml | 172 ++++++++++++++++++ 1 file changed, 172 insertions(+) create mode 100644 .github/workflows/packer.templates.integration.yaml diff --git a/.github/workflows/packer.templates.integration.yaml b/.github/workflows/packer.templates.integration.yaml new file mode 100644 index 0000000..04a3b0c --- /dev/null +++ b/.github/workflows/packer.templates.integration.yaml @@ -0,0 +1,172 @@ +name: Packer Templates - 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"' + + paths: + type: string + required: false + default: | + builds/** + description: "(Optional) File and directory patterns used to detect changed Packer template directories, one per line. Defaults to `builds/**`." + paths_max_depth: + type: string + required: false + default: "2" + description: "(Optional) The maximum depth of the changed directories to check. For example, `builds/foo/source.pkr.hcl` with a max depth of `2` is checked as `builds/foo`. Defaults to `2`." + + packer_version: + type: string + required: false + default: latest + description: "(Optional) The version of `packer` to install when the repository does not pin one in `mise.toml` or `.tool-versions`. Defaults to `latest`." + aws_region: + type: string + required: false + default: us-east-1 + description: "(Optional) The region exported as `AWS_DEFAULT_REGION` so that Amazon builders without an explicit `region` pass `packer validate` on a runner with no AWS configuration. No credentials are needed. Defaults to `us-east-1`." + + fmt_enabled: + type: boolean + required: false + default: true + description: "(Optional) Whether to check formatting with `packer fmt`. Defaults to `true`." + validate_enabled: + type: boolean + required: false + default: true + description: "(Optional) Whether to run `packer init` and `packer validate`. Defaults to `true`." + + pr_comment_enabled: + type: boolean + required: false + default: true + description: "(Optional) Whether to post the integration report as a single sticky comment on pull requests. Requires the `pull-requests: write` permission on the caller. The report is always written to the job summary. Defaults to `true`." + + +jobs: + changed: + name: Detect Changed Directories + runs-on: ${{ fromJson(inputs.runs_on) }} + + outputs: + has_directories: ${{ steps.changed-dirs.outputs.has_directories }} + directories: ${{ steps.changed-dirs.outputs.directories }} + + steps: + - name: Checkout + id: checkout + uses: actions/checkout@v7 + with: + fetch-depth: 0 + + - name: Get Changed Directories + id: changed-dirs + uses: tedilabs/github-actions/.github/actions/git.changed-dirs@main + with: + paths: ${{ inputs.paths }} + max_depth: ${{ inputs.paths_max_depth }} + + + lint: + name: Lint (${{ matrix.path }}) + needs: + - changed + if: needs.changed.outputs.has_directories == 'true' + runs-on: ${{ fromJson(inputs.runs_on) }} + + strategy: + fail-fast: false + matrix: + path: ${{ fromJson(needs.changed.outputs.directories) }} + + env: + AWS_DEFAULT_REGION: ${{ inputs.aws_region }} + + 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: + PACKER_VERSION: ${{ inputs.packer_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: Check Format + id: fmt + if: inputs.fmt_enabled + continue-on-error: true + uses: tedilabs/github-actions/.github/actions/packer.fmt@main + with: + target_dir: ${{ matrix.path }} + + - name: Validate + id: validate + if: inputs.validate_enabled + continue-on-error: true + uses: tedilabs/github-actions/.github/actions/packer.validate@main + with: + target_dir: ${{ matrix.path }} + + - name: Collect Results + id: results + if: always() + uses: tedilabs/github-actions/.github/actions/github.matrix-report@main + with: + mode: collect + id: ${{ matrix.path }} + id_label: Directory + artifact_prefix: packer-integration + job_status: ${{ job.status }} + results: | + { + "fmt": "${{ steps.fmt.outcome }}", + "validate": "${{ steps.validate.outcome }}" + } + + + report: + name: Report + needs: + - changed + - lint + if: always() && needs.changed.outputs.has_directories == 'true' + runs-on: ${{ fromJson(inputs.runs_on) }} + + steps: + - name: Publish Report + id: report + uses: tedilabs/github-actions/.github/actions/github.matrix-report@main + with: + mode: publish + id_label: Directory + artifact_prefix: packer-integration + title: Packer Integration + pr_comment_enabled: ${{ inputs.pr_comment_enabled }} + pr_comment_marker: packer-integration From 2af4dc6364b735d84b347ef58fb55d2ed497b025 Mon Sep 17 00:00:00 2001 From: Byungjin Park Date: Tue, 15 Sep 2026 16:27:55 +0900 Subject: [PATCH 5/7] feat(workflows): add ansible.integration reusable workflow - Install python via mise and `ansible-lint` via pip (version pinnable) - Lint the Ansible project directory with the ansible.ansible-lint action - Optionally install Galaxy collections from `requirements_file` - Write default tool versions to a throwaway global mise config pointed at by `MISE_GLOBAL_CONFIG_FILE`, so repository pins in `mise.toml` / `.tool-versions` win and a self-hosted runner's config is left alone --- .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 1c0468dc8448633ff6229e058d775383a1d8db94 Mon Sep 17 00:00:00 2001 From: Byungjin Park Date: Sun, 20 Sep 2026 00:58:10 +0900 Subject: [PATCH 6/7] refactor(actions): align the Packer and Ansible checks with the check-action interface - Run `packer fmt`, `packer init`, `packer validate`, `ansible-galaxy collection install`, and `ansible-lint` through `shell.run`, and expose `skipped`, `stdout`, `stderr`, and `exitcode` like the Terraform and GitHub Actions checks - Resolve the target in a dedicated step: strip the trailing slash, skip with a notice when the directory holds no Packer HCL, template, or YAML files, and print the tool version there so it stays out of `stdout` - Guard the failure summary steps with `always()`, since the implicit `success()` otherwise skips them after the check fails - Report `skipped` and drop disabled checks in the Packer integration report, as the Terraform integration workflows do --- .../actions/ansible.ansible-lint/action.yaml | 89 +++++++++++++------ .github/actions/packer.fmt/action.yaml | 53 +++++++++-- .github/actions/packer.validate/action.yaml | 65 +++++++++++--- .../packer.templates.integration.yaml | 6 +- 4 files changed, 163 insertions(+), 50 deletions(-) diff --git a/.github/actions/ansible.ansible-lint/action.yaml b/.github/actions/ansible.ansible-lint/action.yaml index d8aa0eb..7806939 100644 --- a/.github/actions/ansible.ansible-lint/action.yaml +++ b/.github/actions/ansible.ansible-lint/action.yaml @@ -11,56 +11,95 @@ inputs: 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." +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: Install Galaxy Collections - id: install-collections - if: inputs.requirements_file != '' + - name: Resolve Target + id: target shell: bash env: + TARGET_DIR: ${{ inputs.target_dir }} REQUIREMENTS_FILE: ${{ inputs.requirements_file }} run: | - collections_path="$RUNNER_TEMP/ansible/collections" + 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 - # 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" 2>&1 | tee "$RUNNER_TEMP/ansible-galaxy.log" - echo "collections_path=$collections_path" >> "$GITHUB_OUTPUT" + # 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: steps.install-collections.outcome == 'failure' + 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: ${{ runner.temp }}/ansible-galaxy.log + file: ${{ steps.install-collections.outputs.log_file }} lang: text - name: Run ansible-lint id: ansible-lint - shell: bash + if: steps.target.outputs.skipped != 'true' + uses: tedilabs/github-actions/.github/actions/shell.run@main env: - TARGET_DIR: ${{ inputs.target_dir }} - COLLECTIONS_PATH: ${{ steps.install-collections.outputs.collections_path }} - run: | - if [ -n "$COLLECTIONS_PATH" ]; then - export ANSIBLE_COLLECTIONS_PATH="$COLLECTIONS_PATH" - fi - + COLLECTIONS_PATH: ${{ steps.target.outputs.collections_path }} + with: # Run inside the project so `.ansible-lint`, `ansible.cfg`, and relative `roles_path` resolve against it. - # Under GitHub Actions, `ansible-lint` emits workflow annotations on its own. - cd "$TARGET_DIR" - ansible-lint --version - ansible-lint --nocolor 2>&1 | tee "$RUNNER_TEMP/ansible-lint.log" + 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 workflow annotations on its own. + ansible-lint --nocolor - name: Add Failure Details to Job Summary id: ansible-lint-summary - if: steps.ansible-lint.outcome == 'failure' + if: always() && steps.ansible-lint.outcome == 'failure' uses: tedilabs/github-actions/.github/actions/github.step-summary@main with: - title: "❌ ansible-lint · ${{ inputs.target_dir }}" - file: ${{ runner.temp }}/ansible-lint.log + title: "❌ ansible-lint · ${{ steps.target.outputs.target_dir }}" + file: ${{ steps.ansible-lint.outputs.log_file }} lang: text diff --git a/.github/actions/packer.fmt/action.yaml b/.github/actions/packer.fmt/action.yaml index 225218f..8f12fa7 100644 --- a/.github/actions/packer.fmt/action.yaml +++ b/.github/actions/packer.fmt/action.yaml @@ -12,30 +12,65 @@ inputs: default: "true" description: "(Optional) Whether to also check subdirectories. 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: Check Packer Format - id: fmt + - name: Resolve Target + id: target shell: bash env: TARGET_DIR: ${{ inputs.target_dir }} RECURSIVE: ${{ inputs.recursive }} run: | - args=(-check -diff) - if [ "$RECURSIVE" = "true" ]; then - args+=(-recursive) + 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 - packer fmt "${args[@]}" "$TARGET_DIR" 2>&1 | tee "$RUNNER_TEMP/packer-fmt.log" + - 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" - name: Add Failure Details to Job Summary id: fmt-summary - if: steps.fmt.outcome == 'failure' + if: always() && steps.fmt.outcome == 'failure' uses: tedilabs/github-actions/.github/actions/github.step-summary@main with: - title: "❌ packer fmt · ${{ inputs.target_dir }}" - file: ${{ runner.temp }}/packer-fmt.log + title: "❌ packer fmt · ${{ steps.target.outputs.target_dir }}" + file: ${{ steps.fmt.outputs.log_file }} lang: diff diff --git a/.github/actions/packer.validate/action.yaml b/.github/actions/packer.validate/action.yaml index e37083a..d8aced3 100644 --- a/.github/actions/packer.validate/action.yaml +++ b/.github/actions/packer.validate/action.yaml @@ -12,43 +12,80 @@ inputs: 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`." +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: Packer Init - id: init + - name: Resolve Target + id: target shell: bash env: - PACKER_GITHUB_API_TOKEN: ${{ inputs.github_token }} 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 - packer init "$TARGET_DIR" 2>&1 | tee "$RUNNER_TEMP/packer-validate-init.log" + + - 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: steps.init.outcome == 'failure' + if: always() && steps.init.outcome == 'failure' uses: tedilabs/github-actions/.github/actions/github.step-summary@main with: - title: "❌ packer init · ${{ inputs.target_dir }}" - file: ${{ runner.temp }}/packer-validate-init.log + title: "❌ packer init · ${{ steps.target.outputs.target_dir }}" + file: ${{ steps.init.outputs.log_file }} lang: text - name: Packer Validate id: validate - shell: bash + if: steps.target.outputs.skipped != 'true' + uses: tedilabs/github-actions/.github/actions/shell.run@main env: - TARGET_DIR: ${{ inputs.target_dir }} - run: | - packer validate "$TARGET_DIR" 2>&1 | tee "$RUNNER_TEMP/packer-validate.log" + TARGET_DIR: ${{ steps.target.outputs.target_dir }} + with: + run: | + packer validate "$TARGET_DIR" - name: Add Failure Details to Job Summary id: validate-summary - if: steps.validate.outcome == 'failure' + if: always() && steps.validate.outcome == 'failure' uses: tedilabs/github-actions/.github/actions/github.step-summary@main with: - title: "❌ packer validate · ${{ inputs.target_dir }}" - file: ${{ runner.temp }}/packer-validate.log + title: "❌ packer validate · ${{ steps.target.outputs.target_dir }}" + file: ${{ steps.validate.outputs.log_file }} lang: text diff --git a/.github/workflows/packer.templates.integration.yaml b/.github/workflows/packer.templates.integration.yaml index 04a3b0c..d54ec54 100644 --- a/.github/workflows/packer.templates.integration.yaml +++ b/.github/workflows/packer.templates.integration.yaml @@ -144,10 +144,12 @@ jobs: id_label: Directory artifact_prefix: packer-integration job_status: ${{ job.status }} + # An empty value keeps a check that is turned off out of the report; a check that ran but had + # nothing to do reports `skipped`. results: | { - "fmt": "${{ steps.fmt.outcome }}", - "validate": "${{ steps.validate.outcome }}" + "fmt": "${{ inputs.fmt_enabled && (steps.fmt.outputs.skipped == 'true' && 'skipped' || steps.fmt.outcome) || '' }}", + "validate": "${{ inputs.validate_enabled && (steps.validate.outputs.skipped == 'true' && 'skipped' || steps.validate.outcome) || '' }}" } From 8c28b1c006e83e1b892a07567d6e0cb5a63e1a9c Mon Sep 17 00:00:00 2001 From: Byungjin Park Date: Sun, 20 Sep 2026 00:59:18 +0900 Subject: [PATCH 7/7] test: exercise the aligned Packer and Ansible checks on a runner --- .../workflows/test.packer-ansible-checks.yaml | 139 ++++++++++++++++++ 1 file changed, 139 insertions(+) create mode 100644 .github/workflows/test.packer-ansible-checks.yaml diff --git a/.github/workflows/test.packer-ansible-checks.yaml b/.github/workflows/test.packer-ansible-checks.yaml new file mode 100644 index 0000000..c5bc938 --- /dev/null +++ b/.github/workflows/test.packer-ansible-checks.yaml @@ -0,0 +1,139 @@ +name: Test - Packer and Ansible checks + + +on: + pull_request: + branches: + - "**" + + +jobs: + test: + name: Test + runs-on: ubuntu-latest + + steps: + - name: Checkout + id: checkout + uses: actions/checkout@v7 + + - name: Set up tools + id: setup-tools + uses: tedilabs/github-actions/.github/actions/mise.setup-tools@main + with: + mise_toml: | + [tools] + packer = "latest" + python = "3.12" + + - name: Install ansible-lint + id: install-ansible-lint + run: python -m pip install --quiet ansible-lint + + - name: Prepare Fixtures + id: fixtures + run: | + mkdir -p "$RUNNER_TEMP/pk/ok" "$RUNNER_TEMP/pk/bad" "$RUNNER_TEMP/pk/empty" "$RUNNER_TEMP/an/ok" "$RUNNER_TEMP/an/bad" "$RUNNER_TEMP/an/empty" + cat > "$RUNNER_TEMP/pk/ok/build.pkr.hcl" <<'HCL' + source "null" "example" { + communicator = "none" + } + + build { + sources = ["source.null.example"] + } + HCL + printf 'source "null" "example" {\n communicator = "none"\n}\n\nbuild {\n sources = ["source.null.example"]\n}\n' > "$RUNNER_TEMP/pk/bad/build.pkr.hcl" + printf -- '---\n- name: Example\n hosts: localhost\n gather_facts: false\n tasks:\n - name: Say hello\n ansible.builtin.debug:\n msg: hello\n' > "$RUNNER_TEMP/an/ok/playbook.yml" + printf -- '---\n- hosts: localhost\n tasks:\n - debug: msg=hello\n' > "$RUNNER_TEMP/an/bad/playbook.yml" + + - name: packer fmt (formatted) + id: fmt-ok + continue-on-error: true + uses: tedilabs/github-actions/.github/actions/packer.fmt@feat/packer-workflows + with: + target_dir: ${{ runner.temp }}/pk/ok/ + + - name: packer fmt (unformatted) + id: fmt-bad + continue-on-error: true + uses: tedilabs/github-actions/.github/actions/packer.fmt@feat/packer-workflows + with: + target_dir: ${{ runner.temp }}/pk/bad + + - name: packer fmt (empty) + id: fmt-empty + continue-on-error: true + uses: tedilabs/github-actions/.github/actions/packer.fmt@feat/packer-workflows + with: + target_dir: ${{ runner.temp }}/pk/empty + + - name: packer validate (ok) + id: validate-ok + continue-on-error: true + uses: tedilabs/github-actions/.github/actions/packer.validate@feat/packer-workflows + with: + target_dir: ${{ runner.temp }}/pk/ok + + - name: packer validate (empty) + id: validate-empty + continue-on-error: true + uses: tedilabs/github-actions/.github/actions/packer.validate@feat/packer-workflows + with: + target_dir: ${{ runner.temp }}/pk/empty + + - name: ansible-lint (ok) + id: lint-ok + continue-on-error: true + uses: tedilabs/github-actions/.github/actions/ansible.ansible-lint@feat/packer-workflows + with: + target_dir: ${{ runner.temp }}/an/ok/ + + - name: ansible-lint (bad) + id: lint-bad + continue-on-error: true + uses: tedilabs/github-actions/.github/actions/ansible.ansible-lint@feat/packer-workflows + with: + target_dir: ${{ runner.temp }}/an/bad + + - name: ansible-lint (empty) + id: lint-empty + continue-on-error: true + uses: tedilabs/github-actions/.github/actions/ansible.ansible-lint@feat/packer-workflows + with: + target_dir: ${{ runner.temp }}/an/empty + + - name: Assert + id: assert + env: + FMT_OK: ${{ steps.fmt-ok.outcome }}/${{ steps.fmt-ok.outputs.exitcode }}/${{ steps.fmt-ok.outputs.skipped }} + FMT_BAD: ${{ steps.fmt-bad.outcome }}/${{ steps.fmt-bad.outputs.exitcode }}/${{ steps.fmt-bad.outputs.skipped }} + FMT_BAD_STDOUT: ${{ steps.fmt-bad.outputs.stdout }} + FMT_EMPTY: ${{ steps.fmt-empty.outcome }}/${{ steps.fmt-empty.outputs.exitcode }}/${{ steps.fmt-empty.outputs.skipped }} + VALIDATE_OK: ${{ steps.validate-ok.outcome }}/${{ steps.validate-ok.outputs.exitcode }}/${{ steps.validate-ok.outputs.skipped }} + VALIDATE_OK_STDOUT: ${{ steps.validate-ok.outputs.stdout }} + VALIDATE_EMPTY: ${{ steps.validate-empty.outcome }}/${{ steps.validate-empty.outputs.exitcode }}/${{ steps.validate-empty.outputs.skipped }} + LINT_OK: ${{ steps.lint-ok.outcome }}/${{ steps.lint-ok.outputs.exitcode }}/${{ steps.lint-ok.outputs.skipped }} + LINT_BAD: ${{ steps.lint-bad.outcome }}/${{ steps.lint-bad.outputs.exitcode }}/${{ steps.lint-bad.outputs.skipped }} + LINT_BAD_OUTPUT: ${{ steps.lint-bad.outputs.stdout }}${{ steps.lint-bad.outputs.stderr }} + LINT_EMPTY: ${{ steps.lint-empty.outcome }}/${{ steps.lint-empty.outputs.exitcode }}/${{ steps.lint-empty.outputs.skipped }} + run: | + for v in FMT_OK FMT_BAD FMT_EMPTY VALIDATE_OK VALIDATE_EMPTY LINT_OK LINT_BAD LINT_EMPTY; do + printf '%s=<%s>\n' "$v" "${!v}" + done + printf 'FMT_BAD_STDOUT=<%s>\nVALIDATE_OK_STDOUT=<%s>\nLINT_BAD_OUTPUT=<%s>\n' "$FMT_BAD_STDOUT" "$VALIDATE_OK_STDOUT" "$(printf '%s' "$LINT_BAD_OUTPUT" | head -c 400)" + + failed=0 + check() { if [ "$1" = "$2" ]; then echo "PASS $3"; else echo "FAIL $3: got '$1', want '$2'"; failed=1; fi; } + check "$FMT_OK" "success/0/" "packer fmt passes on a formatted template (trailing slash honored)" + check "$FMT_BAD" "failure/3/" "packer fmt fails with exit 3 on an unformatted template" + case "$FMT_BAD_STDOUT" in *communicator*) echo "PASS packer fmt diff is in stdout";; *) echo "FAIL packer fmt diff missing from stdout"; failed=1;; esac + check "$FMT_EMPTY" "success//true" "packer fmt skips an empty directory" + check "$VALIDATE_OK" "success/0/" "packer init and validate pass on a null-builder template" + case "$VALIDATE_OK_STDOUT" in *"configuration is valid"*) echo "PASS packer validate stdout is captured";; *) echo "FAIL packer validate stdout missing"; failed=1;; esac + check "$VALIDATE_EMPTY" "success//true" "packer validate skips an empty directory" + check "$LINT_OK" "success/0/" "ansible-lint passes on a clean playbook (trailing slash honored)" + check "$LINT_BAD" "failure/2/" "ansible-lint fails with exit 2 on a playbook with findings" + case "$LINT_BAD_OUTPUT" in *name*) echo "PASS ansible-lint findings are in the captured output";; *) echo "FAIL ansible-lint findings missing"; failed=1;; esac + check "$LINT_EMPTY" "success//true" "ansible-lint skips a directory without YAML files" + exit "$failed"