diff --git a/.github/dependabot.yml b/.github/dependabot.yml
index 09aad73..65f1de4 100644
--- a/.github/dependabot.yml
+++ b/.github/dependabot.yml
@@ -12,3 +12,15 @@ updates:
default-days: 7
schedule:
interval: weekly
+ groups:
+ docker-dependencies:
+ patterns: ["*"]
+ - package-ecosystem: pip
+ directory: /tests/fixture/ansible
+ cooldown:
+ default-days: 7
+ schedule:
+ interval: weekly
+ groups:
+ python-dependencies:
+ patterns: ["*"]
diff --git a/.github/workflows/__shared-ci.yml b/.github/workflows/__shared-ci.yml
index 7cb4546..7100faa 100644
--- a/.github/workflows/__shared-ci.yml
+++ b/.github/workflows/__shared-ci.yml
@@ -36,9 +36,14 @@ jobs:
contents: read
pull-requests: write
uses: $/.github/workflows/__test-actions-vm.yml
+ ansible:
+ permissions:
+ contents: read
+ pull-requests: write
+ uses: $/.github/workflows/__test-actions-ansible.yml
checks:
if: ${{ always() }}
- needs: [tests, linter, vm]
+ needs: [tests, linter, vm, ansible]
runs-on: ubuntu-latest
steps:
- name: Require all checks
diff --git a/.github/workflows/__test-actions-ansible.yml b/.github/workflows/__test-actions-ansible.yml
new file mode 100644
index 0000000..4798836
--- /dev/null
+++ b/.github/workflows/__test-actions-ansible.yml
@@ -0,0 +1,64 @@
+---
+name: Internal - Test Ansible action
+on:
+ workflow_call:
+permissions: {}
+jobs:
+ scenarios:
+ runs-on: ubuntu-latest
+ timeout-minutes: 30
+ permissions:
+ contents: read
+ pull-requests: write
+ strategy:
+ fail-fast: false
+ matrix:
+ mode: [playbook, sanity]
+ result: [success, failure]
+ steps:
+ - name: Checkout fixture
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ with:
+ persist-credentials: false
+ - name: Prepare selected scenario without a consumer Makefile
+ env:
+ MODE: ${{ matrix.mode }}
+ RESULT: ${{ matrix.result }}
+ run: |
+ rm Makefile
+ if [ "$RESULT" = failure ] && [ "$MODE" = sanity ]; then
+ printf '%s\n' '---' 'invalid: [' > tests/fixture/ansible/ansible_collections/example/smoke/roles/smoke/tasks/main.yml
+ fi
+ - name: Test Ansible with upstream actions
+ id: test
+ continue-on-error: true
+ uses: $/actions/ansible/test
+ env:
+ FIXTURE_RESULT: ${{ matrix.result }}
+ with:
+ ansible-requirements: tests/fixture/ansible/requirements.txt
+ test-playbook: ${{ matrix.mode == 'playbook' && 'tests/fixture/ansible/valid.yml' || '' }}
+ collection: ${{ matrix.mode == 'sanity' && 'tests/fixture/ansible/ansible_collections/example/smoke' || '' }}
+ report-name: Ansible tests - ${{ matrix.mode }} - ${{ matrix.result }}
+ - name: Verify check outcomes and native reports
+ if: ${{ always() }}
+ env:
+ MODE: ${{ matrix.mode }}
+ EXPECTED: ${{ matrix.result }}
+ OUTCOME: ${{ steps.test.outcome }}
+ REPORTS: ${{ steps.test.outputs.report-files }}
+ PARSED: ${{ steps.test.outputs.parsed-files }}
+ ARTIFACT: ${{ steps.test.outputs.artifact-id }}
+ uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
+ with:
+ script: |
+ const assert = require('node:assert/strict');
+ const fs = require('node:fs');
+ assert.equal(process.env.OUTCOME, process.env.EXPECTED);
+ const files = process.env.REPORTS.split('\n').filter(Boolean);
+ assert.ok(files.length > 0);
+ assert.deepEqual(JSON.parse(process.env.PARSED).sort(), files.sort());
+ assert.ok(process.env.ARTIFACT);
+ assert.ok(files.every(file => file.endsWith('.xml')));
+ const reports = files.map(file => fs.readFileSync(file, 'utf8')).join('\n');
+ assert.equal(/<(?:failure|error)\b/.test(reports), process.env.EXPECTED === 'failure');
diff --git a/.gitignore b/.gitignore
index fbe2612..d587502 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,6 +1,7 @@
.cache/
.reports/
node_modules/
+tests/fixture/ansible/ansible_collections/*/*/tests/output/
.env
*.tfstate*
*.tfplan
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index f833995..72a5e23 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -58,6 +58,15 @@ comments. Consumer workflows grant comment permissions and always schedule clean
Unit tests exercise the action entrypoints and internal modules directly.
Keep hosted boot checks alongside the mocked unit contracts.
+`__test-actions-ansible.yml` exercises playbook tests and collection sanity success/failure
+scenarios through maintained playbook execution and Ansible community testing actions.
+It verifies native report publication after failures. Adjacent JavaScript only
+validates inputs and collects reports; upstream actions own test execution,
+collection Python setup and containers. Playbook tests use a temporary virtual
+environment and the built-in JUnit callback. Consumers supply their Ansible Core pin,
+Galaxy requirements and selected targets. Dependabot maintains upstream action
+pins and the fixture's requirements file. Linting remains in the separate linter workflow.
+
## Releases and dependencies
Use Conventional Commits and a reviewed pull request. Publish semantic versions
diff --git a/Makefile b/Makefile
index ea484eb..2eadbf5 100644
--- a/Makefile
+++ b/Makefile
@@ -32,6 +32,8 @@ ci: ## Execute local CI tasks
docs: ## Generate action documentation
npm exec --yes --package=@ci-dokumentor/cli@0.5.0 -- ci-dokumentor generate --source actions/vm/create/action.yml actions/vm/test/action.yml actions/vm/destroy/action.yml --repository git
+ # The new Ansible action has no released version for automatic usage examples.
+ npm exec --yes --package=@ci-dokumentor/cli@0.5.0 -- ci-dokumentor generate --source actions/ansible/test/action.yml --repository git --exclude-sections usage
#############################
# Helpers
diff --git a/README.md b/README.md
index 57e2e21..8d6b781 100644
--- a/README.md
+++ b/README.md
@@ -14,7 +14,7 @@
## Overview
-GitHub Actions for testing infrastructure changes in disposable virtual machines.
+GitHub Actions for validating Ansible and testing infrastructure changes in disposable virtual machines.
Create a Lima and QEMU guest from your configuration, run your checks, publish
test and coverage reports, and verify that the guest is removed afterward.
@@ -24,6 +24,13 @@ can run several test steps against the same guest.
## Actions
+### Ansible
+
+#### - [Test Ansible](actions/ansible/test/README.md)
+
+Execute test playbooks and first-party collection sanity through maintained
+Ansible actions. Publish test summaries, annotations, PR comments and report artifacts.
+
### Virtual machines
#### - [Create VM](actions/vm/create/README.md)
@@ -44,7 +51,11 @@ cleanup remains visible and preserves the runtime for a retry.
## Getting started
-Use an ephemeral Linux x86-64 runner with KVM and network access. Check out your
+For Ansible, check out your repository on a Linux runner. Pass its pinned
+Ansible requirements file and test playbook or collection paths to
+[ansible/test](actions/ansible/test/README.md).
+
+For VMs, use an ephemeral Linux x86-64 runner with KVM and network access. Check out your
repository with `persist-credentials: false`, and provide a Lima YAML configuration
and Bash test commands or a script. Checkout files are not mounted into the guest.
diff --git a/actions/ansible/test/README.md b/actions/ansible/test/README.md
new file mode 100644
index 0000000..80185a8
--- /dev/null
+++ b/actions/ansible/test/README.md
@@ -0,0 +1,115 @@
+
+
+#  GitHub Action: Ansible - Test
+
+
+
+
+[](https://github.com/marketplace/actions/ansible---test)
+[](https://github.com/hoverkraft-tech/ci-github-iac/releases)
+[](https://img.shields.io/github/stars/hoverkraft-tech/ci-github-iac?style=social)
+
+
+
+
+## Overview
+
+Run Ansible test playbooks and collection sanity with native reports, PR comments and artifacts.
+
+Run Ansible tests with maintained upstream actions and native JUnit reports.
+
+### Run tests
+
+Check out the consumer on a fresh Linux GitHub-hosted runner. Supply its existing
+requirements file containing an exact `ansible-core==X.Y.Z` pin and either an
+explicit `test-playbook` or one `collection`. Use a matrix for multiple targets.
+Linting and playbook syntax checks belong in separate consumer jobs.
+
+Test playbooks are executed by
+[dawidd6/action-ansible-playbook](https://github.com/dawidd6/action-ansible-playbook).
+The pinned Ansible Core is installed in a temporary virtual environment using
+the runner's Python; that Python must be supported by the selected Ansible Core.
+Ansible's built-in JUnit callback writes task results, including failed assertions.
+Provide test assertions and disposable targets, never a deployment playbook.
+`inventory`, `ansible-config`, `collections-path` and `galaxy-requirements`
+configure test execution. The action does not provision test environments.
+
+Collection sanity uses the maintained Ansible community
+[ansible-test action](https://github.com/ansible-community/ansible-test-gh-action),
+which owns Python setup, test containers and native JUnit generation.
+Its Ansible Core Git tag comes from the same requirements pin. Coverage is
+disabled for sanity checks. The selected upstream revision pins nested actions
+to full commit SHAs. Source stays in the consumer's checked-out collection.
+Sanity covers all supported Python versions unless `target-python-version`
+selects the consumer's supported runtime. These are sanity results, not code coverage.
+
+Paths must stay inside the checkout and use letters, digits, dots, underscores,
+hyphens and slashes. Each collection needs `galaxy.yml`. Use a fresh checkout per
+collection job so native `tests/output/junit` contains only that run's reports.
+
+### Publish results
+
+Hoverkraft's `parse-ci-reports` publishes native JUnit as summaries, annotations
+and a stable PR comment. Test output and task results appear in these reports;
+use synthetic data suitable for the repository's visibility. The collection
+action also publishes its own summary.
+
+Report collection runs after upstream failures, including when the upstream
+action does not export its report output. Reports are uploaded with a unique
+artifact name and seven-day retention, including after parsing or commenting
+errors. Missing reports fail the action. Failed tests remain failures. No custom
+test runner, Docker lifecycle or JUnit generator is maintained here.
+
+Grant `pull-requests: write` for comments. Fork and Dependabot comments are skipped.
+Never run untrusted tests through `pull_request_target`. Use distinct report names
+for matrix entries.
+
+
+
+
+## Inputs
+
+| **Input** | **Description** | **Required** | **Default** |
+| --------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- | ------------ | ---------------------- |
+| **`ansible-requirements`** | Checkout-relative requirements file containing exactly one stable ansible-core==X.Y.Z pin. | **true** | - |
+| **`test-playbook`** | Checkout-relative playbook containing test assertions. This playbook is executed. Supply test-playbook or collection, exclusively. | **false** | - |
+| **`collection`** | One checked-out collection directory containing galaxy.yml. Use a matrix for multiple collections. | **false** | - |
+| **`target-python-version`** | Optional Python major.minor for collection sanity checks. Empty tests all versions supported by Ansible Core. | **false** | - |
+| **`inventory`** | Optional checkout-relative inventory file for test playbook execution. | **false** | - |
+| **`ansible-config`** | Optional checkout-relative ansible.cfg file for test playbook execution. | **false** | - |
+| **`collections-path`** | Optional checkout-relative first-party collection search directory containing ansible_collections. | **false** | - |
+| **`galaxy-requirements`** | Optional checkout-relative Galaxy requirements file for third-party role and collection dependencies. | **false** | - |
+| **`report-name`** | Stable summary and PR comment title, distinct for each test step or matrix entry. | **false** | `Ansible test results` |
+| **`github-token`** | Token for PR comments; requires pull-requests write. Fork and Dependabot comments are skipped. | **false** | `${{ github.token }}` |
+
+
+
+
+
+
+## Outputs
+
+| **Output** | **Description** |
+| ------------------ | -------------------------------------------------------------------------------------------- |
+| **`report-files`** | Newline-separated absolute paths of completed native reports, including after check failure. |
+| **`summary`** | Parsed validation summary. |
+| **`markdown`** | Markdown used for the PR comment. |
+| **`parsed-files`** | JSON array of parsed report paths. |
+| **`artifact-id`** | Uploaded report artifact identifier. |
+
+
+
+
+
+
+
+
+
+
+
+
+---
+
+This documentation was automatically generated by [CI Dokumentor](https://github.com/hoverkraft-tech/ci-dokumentor).
+
+
diff --git a/actions/ansible/test/action.yml b/actions/ansible/test/action.yml
new file mode 100644
index 0000000..71ba775
--- /dev/null
+++ b/actions/ansible/test/action.yml
@@ -0,0 +1,199 @@
+# Run Ansible tests with maintained upstream actions and native JUnit reports.
+#
+# ### Run tests
+#
+# Check out the consumer on a fresh Linux GitHub-hosted runner. Supply its existing
+# requirements file containing an exact `ansible-core==X.Y.Z` pin and either an
+# explicit `test-playbook` or one `collection`. Use a matrix for multiple targets.
+# Linting and playbook syntax checks belong in separate consumer jobs.
+#
+# Test playbooks are executed by
+# [dawidd6/action-ansible-playbook](https://github.com/dawidd6/action-ansible-playbook).
+# The pinned Ansible Core is installed in a temporary virtual environment using
+# the runner's Python; that Python must be supported by the selected Ansible Core.
+# Ansible's built-in JUnit callback writes task results, including failed assertions.
+# Provide test assertions and disposable targets, never a deployment playbook.
+# `inventory`, `ansible-config`, `collections-path` and `galaxy-requirements`
+# configure test execution. The action does not provision test environments.
+#
+# Collection sanity uses the maintained Ansible community
+# [ansible-test action](https://github.com/ansible-community/ansible-test-gh-action),
+# which owns Python setup, test containers and native JUnit generation.
+# Its Ansible Core Git tag comes from the same requirements pin. Coverage is
+# disabled for sanity checks. The selected upstream revision pins nested actions
+# to full commit SHAs. Source stays in the consumer's checked-out collection.
+# Sanity covers all supported Python versions unless `target-python-version`
+# selects the consumer's supported runtime. These are sanity results, not code coverage.
+#
+# Paths must stay inside the checkout and use letters, digits, dots, underscores,
+# hyphens and slashes. Each collection needs `galaxy.yml`. Use a fresh checkout per
+# collection job so native `tests/output/junit` contains only that run's reports.
+#
+# ### Publish results
+#
+# Hoverkraft's `parse-ci-reports` publishes native JUnit as summaries, annotations
+# and a stable PR comment. Test output and task results appear in these reports;
+# use synthetic data suitable for the repository's visibility. The collection
+# action also publishes its own summary.
+#
+# Report collection runs after upstream failures, including when the upstream
+# action does not export its report output. Reports are uploaded with a unique
+# artifact name and seven-day retention, including after parsing or commenting
+# errors. Missing reports fail the action. Failed tests remain failures. No custom
+# test runner, Docker lifecycle or JUnit generator is maintained here.
+#
+# Grant `pull-requests: write` for comments. Fork and Dependabot comments are skipped.
+# Never run untrusted tests through `pull_request_target`. Use distinct report names
+# for matrix entries.
+---
+name: Ansible - Test
+description: Run Ansible test playbooks and collection sanity with native reports, PR comments and artifacts.
+author: Hoverkraft
+branding:
+ icon: check-circle
+ color: purple
+inputs:
+ ansible-requirements:
+ description: Checkout-relative requirements file containing exactly one stable ansible-core==X.Y.Z pin.
+ required: true
+ test-playbook:
+ description: Checkout-relative playbook containing test assertions. This playbook is executed. Supply test-playbook or collection, exclusively.
+ default: ""
+ collection:
+ description: One checked-out collection directory containing galaxy.yml. Use a matrix for multiple collections.
+ default: ""
+ target-python-version:
+ description: Optional Python major.minor for collection sanity checks. Empty tests all versions supported by Ansible Core.
+ default: ""
+ inventory:
+ description: Optional checkout-relative inventory file for test playbook execution.
+ default: ""
+ ansible-config:
+ description: Optional checkout-relative ansible.cfg file for test playbook execution.
+ default: ""
+ collections-path:
+ description: Optional checkout-relative first-party collection search directory containing ansible_collections.
+ default: ""
+ galaxy-requirements:
+ description: Optional checkout-relative Galaxy requirements file for third-party role and collection dependencies.
+ default: ""
+ report-name:
+ description: Stable summary and PR comment title, distinct for each test step or matrix entry.
+ default: Ansible test results
+ github-token:
+ description: Token for PR comments; requires pull-requests write. Fork and Dependabot comments are skipped.
+ default: ${{ github.token }}
+outputs:
+ report-files:
+ description: Newline-separated absolute paths of completed native reports, including after check failure.
+ value: ${{ steps.collect.outputs.report-files }}
+ summary:
+ description: Parsed validation summary.
+ value: ${{ steps.parse.outputs.summary }}
+ markdown:
+ description: Markdown used for the PR comment.
+ value: ${{ steps.parse.outputs.markdown }}
+ parsed-files:
+ description: JSON array of parsed report paths.
+ value: ${{ steps.parse.outputs.parsed-files }}
+ artifact-id:
+ description: Uploaded report artifact identifier.
+ value: ${{ steps.upload.outputs.artifact-id }}
+runs:
+ using: composite
+ steps:
+ - name: Prepare Ansible inputs
+ id: inputs
+ uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
+ env:
+ ANSIBLE_ACTION_PATH: ${{ github.action_path }}
+ ANSIBLE_PHASE: prepare
+ ANSIBLE_REQUIREMENTS: ${{ inputs.ansible-requirements }}
+ ANSIBLE_TEST_PLAYBOOK: ${{ inputs.test-playbook }}
+ ANSIBLE_COLLECTION: ${{ inputs.collection }}
+ ANSIBLE_TARGET_PYTHON_VERSION: ${{ inputs.target-python-version }}
+ ANSIBLE_INVENTORY_FILE: ${{ inputs.inventory }}
+ ANSIBLE_CONFIG_FILE: ${{ inputs.ansible-config }}
+ ANSIBLE_COLLECTIONS_DIRECTORY: ${{ inputs.collections-path }}
+ ANSIBLE_GALAXY_REQUIREMENTS: ${{ inputs.galaxy-requirements }}
+ with:
+ script: require(`${process.env.ANSIBLE_ACTION_PATH}/index.cjs`)({ core });
+
+ - name: Install the pinned Ansible test runtime
+ if: steps.inputs.outputs.mode == 'playbook'
+ shell: bash
+ env:
+ REPORT_DIRECTORY: ${{ steps.inputs.outputs.report-directory }}
+ REQUIREMENTS: ${{ steps.inputs.outputs.constraint }}
+ run: |
+ python3 -m venv "$REPORT_DIRECTORY/venv"
+ "$REPORT_DIRECTORY/venv/bin/pip" install --disable-pip-version-check -r "$REQUIREMENTS"
+
+ - name: Execute Ansible test playbook
+ if: steps.inputs.outputs.mode == 'playbook'
+ uses: dawidd6/action-ansible-playbook@126642a1c6ce512da255ef2b41e8ee90f0077474 # v9
+ env:
+ ANSIBLE_CONFIG: ${{ steps.inputs.outputs.config }}
+ ANSIBLE_INVENTORY: ${{ steps.inputs.outputs.inventory }}
+ ANSIBLE_COLLECTIONS_PATH: ${{ steps.inputs.outputs.collections-path }}
+ ANSIBLE_CALLBACKS_ENABLED: ansible.builtin.junit
+ JUNIT_OUTPUT_DIR: ${{ steps.inputs.outputs.report-directory }}
+ JUNIT_TASK_RELATIVE_PATH: ${{ github.workspace }}
+ JUNIT_HIDE_TASK_ARGUMENTS: "true"
+ with:
+ playbook: ${{ steps.inputs.outputs.test-playbook }}
+ requirements: ${{ steps.inputs.outputs.galaxy }}
+
+ - name: Validate collection sanity with ansible-test
+ id: sanity
+ if: steps.inputs.outputs.mode == 'sanity'
+ uses: ansible-community/ansible-test-gh-action@dc5c5bfa02fa4b6633e6175e65e9f8f53088180a # unstable/v1
+ with:
+ testing-type: sanity
+ ansible-core-version: ${{ steps.inputs.outputs.ansible-core-ref }}
+ collection-src-directory: ${{ github.workspace }}
+ collection-root: ${{ steps.inputs.outputs.collection-root }}
+ target-python-version: ${{ steps.inputs.outputs.target-python-version }}
+ test-deps: ${{ steps.inputs.outputs.galaxy != '' && format('-r {0}', steps.inputs.outputs.galaxy) || '' }}
+ coverage: never
+
+ - name: Collect native Ansible reports
+ id: collect
+ if: ${{ always() && steps.inputs.outcome == 'success' }}
+ uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
+ env:
+ ANSIBLE_ACTION_PATH: ${{ github.action_path }}
+ ANSIBLE_PHASE: collect
+ ANSIBLE_REPORT_DIRECTORY: ${{ steps.inputs.outputs.report-directory }}
+ ANSIBLE_COLLECTION_ROOT: ${{ steps.sanity.outputs.checkout-directory || steps.inputs.outputs.collection }}
+ with:
+ script: require(`${process.env.ANSIBLE_ACTION_PATH}/index.cjs`)({ core });
+
+ - name: Parse Ansible reports
+ if: ${{ always() && steps.collect.outputs.report-files != '' }}
+ id: parse
+ uses: hoverkraft-tech/ci-github-common/actions/parse-ci-reports@44f6108cef0e9884ea7735f3d62932c2310745d4 # 0.39.1
+ with:
+ report-name: ${{ inputs.report-name }}
+ report-paths: ${{ steps.collect.outputs.report-files }}
+ path-mapping: ${{ github.workspace }}:.
+ output-format: summary,markdown,annotations
+
+ - name: Add Ansible PR comment
+ if: >-
+ ${{ always() && github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository && github.actor != 'dependabot[bot]' && steps.parse.outputs.markdown != '' }}
+ uses: hoverkraft-tech/ci-github-common/actions/create-or-update-comment@44f6108cef0e9884ea7735f3d62932c2310745d4 # 0.39.1
+ with:
+ title: ${{ inputs.report-name }}
+ body: ${{ steps.parse.outputs.markdown }}
+ token: ${{ inputs.github-token }}
+
+ - name: Upload Ansible reports
+ if: ${{ always() && steps.collect.outputs.report-files != '' }}
+ id: upload
+ uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
+ with:
+ name: ${{ steps.inputs.outputs.id }}
+ path: ${{ steps.collect.outputs.report-files }}
+ if-no-files-found: error
+ retention-days: 7
diff --git a/actions/ansible/test/index.cjs b/actions/ansible/test/index.cjs
new file mode 100644
index 0000000..0d85b05
--- /dev/null
+++ b/actions/ansible/test/index.cjs
@@ -0,0 +1,13 @@
+const { AnsibleInputs } = require("./inputs.cjs");
+const { AnsibleReports } = require("./reports.cjs");
+
+module.exports = ({ core, env = process.env }) => {
+ try {
+ if (env.ANSIBLE_PHASE === "prepare") new AnsibleInputs(core, env).prepare();
+ else if (env.ANSIBLE_PHASE === "collect")
+ new AnsibleReports(core, env).collect();
+ else throw new Error("Unknown Ansible action phase.");
+ } catch (error) {
+ core.setFailed(error.message);
+ }
+};
diff --git a/actions/ansible/test/inputs.cjs b/actions/ansible/test/inputs.cjs
new file mode 100644
index 0000000..42bae39
--- /dev/null
+++ b/actions/ansible/test/inputs.cjs
@@ -0,0 +1,107 @@
+const fs = require("node:fs");
+const path = require("node:path");
+const os = require("node:os");
+
+class AnsibleInputs {
+ constructor(core, env) {
+ this.core = core;
+ this.env = env;
+ this.workspace = fs.realpathSync(env.GITHUB_WORKSPACE);
+ }
+
+ checkoutPath(value, directory = false) {
+ const resolved = fs.realpathSync(path.resolve(this.workspace, value));
+ const relative = path.relative(this.workspace, resolved);
+ if (
+ relative.startsWith("..") ||
+ path.isAbsolute(relative) ||
+ !/^[\w./-]+$/.test(resolved)
+ ) {
+ throw new Error(
+ "Inputs must stay inside the checkout and use paths without spaces or shell metacharacters.",
+ );
+ }
+ const stat = fs.statSync(resolved);
+ if (directory ? !stat.isDirectory() : !stat.isFile() || stat.size === 0) {
+ throw new Error(
+ `${value} must be a ${directory ? "directory" : "nonempty file"}.`,
+ );
+ }
+ return resolved;
+ }
+
+ prepare() {
+ const playbook = this.env.ANSIBLE_TEST_PLAYBOOK?.trim();
+ const collection = this.env.ANSIBLE_COLLECTION?.trim();
+ const targetPython = this.env.ANSIBLE_TARGET_PYTHON_VERSION?.trim() || "";
+ if (targetPython && (!collection || !/^3\.[0-9]+$/.test(targetPython))) {
+ throw new Error(
+ "target-python-version requires a collection and a Python 3 major.minor version.",
+ );
+ }
+ this.core.setOutput("target-python-version", targetPython);
+ if (Boolean(playbook) === Boolean(collection)) {
+ throw new Error(
+ "Provide test-playbook or one collection, exclusively. Use a matrix for multiple collections.",
+ );
+ }
+ const requirements = this.checkoutPath(this.env.ANSIBLE_REQUIREMENTS);
+ const pins = [
+ ...fs
+ .readFileSync(requirements, "utf8")
+ .matchAll(/^ansible-core==([0-9]+\.[0-9]+\.[0-9]+)\s*(?:#.*)?$/gm),
+ ];
+ if (pins.length !== 1)
+ throw new Error(
+ "ansible-requirements must contain one exact stable ansible-core==X.Y.Z pin.",
+ );
+ this.core.setOutput(
+ "test-playbook",
+ playbook ? this.checkoutPath(playbook) : "",
+ );
+ const collectionRoot = collection
+ ? this.checkoutPath(collection, true)
+ : "";
+ if (collectionRoot)
+ this.checkoutPath(path.join(collectionRoot, "galaxy.yml"));
+ const optionalFiles = {
+ inventory: this.env.ANSIBLE_INVENTORY_FILE,
+ config: this.env.ANSIBLE_CONFIG_FILE,
+ galaxy: this.env.ANSIBLE_GALAXY_REQUIREMENTS,
+ };
+ for (const [name, value] of Object.entries(optionalFiles)) {
+ this.core.setOutput(name, value ? this.checkoutPath(value) : "");
+ }
+ const collectionsPath = this.env.ANSIBLE_COLLECTIONS_DIRECTORY
+ ? this.checkoutPath(this.env.ANSIBLE_COLLECTIONS_DIRECTORY, true)
+ : "";
+ this.core.setOutput(
+ "collections-path",
+ [
+ collectionsPath,
+ path.join(os.homedir(), ".ansible/collections"),
+ "/usr/share/ansible/collections",
+ ]
+ .filter(Boolean)
+ .join(":"),
+ );
+ const reports = fs.mkdtempSync(
+ path.join(this.env.RUNNER_TEMP, "ansible-reports-"),
+ );
+ if (playbook) this.core.addPath(path.join(reports, "venv/bin"));
+ const constraint = path.join(reports, "constraints.txt");
+ fs.writeFileSync(constraint, `ansible-core==${pins[0][1]}\n`);
+ this.core.setOutput("constraint", constraint);
+ this.core.setOutput("ansible-core-ref", `v${pins[0][1]}`);
+ this.core.setOutput("mode", collection ? "sanity" : "playbook");
+ this.core.setOutput("collection", collectionRoot);
+ this.core.setOutput(
+ "collection-root",
+ collectionRoot ? path.relative(this.workspace, collectionRoot) : "",
+ );
+ this.core.setOutput("report-directory", reports);
+ this.core.setOutput("id", path.basename(reports));
+ }
+}
+
+module.exports = { AnsibleInputs };
diff --git a/actions/ansible/test/reports.cjs b/actions/ansible/test/reports.cjs
new file mode 100644
index 0000000..a040177
--- /dev/null
+++ b/actions/ansible/test/reports.cjs
@@ -0,0 +1,61 @@
+const fs = require("node:fs");
+const path = require("node:path");
+
+class AnsibleReports {
+ constructor(core, env) {
+ this.core = core;
+ this.env = env;
+ }
+
+ collect() {
+ const destination = fs.realpathSync(this.env.ANSIBLE_REPORT_DIRECTORY);
+ if (
+ path.dirname(destination) !== fs.realpathSync(this.env.RUNNER_TEMP) ||
+ !path.basename(destination).startsWith("ansible-reports-")
+ ) {
+ throw new Error("Invalid action report directory.");
+ }
+ const files = [];
+ if (this.env.ANSIBLE_COLLECTION_ROOT) {
+ const source = path.resolve(
+ this.env.GITHUB_WORKSPACE,
+ this.env.ANSIBLE_COLLECTION_ROOT,
+ "tests/output/junit",
+ );
+ const relative = path.relative(
+ fs.realpathSync(this.env.GITHUB_WORKSPACE),
+ source,
+ );
+ if (relative.startsWith("..") || path.isAbsolute(relative))
+ throw new Error("Collection reports must remain inside the checkout.");
+ if (fs.existsSync(source)) {
+ if (fs.realpathSync(source) !== source)
+ throw new Error("JUnit report directories cannot be symbolic links.");
+ for (const entry of fs.readdirSync(source, { withFileTypes: true })) {
+ if (!entry.isFile() || !entry.name.endsWith(".xml")) continue;
+ const target = path.join(destination, entry.name);
+ fs.copyFileSync(
+ path.join(source, entry.name),
+ target,
+ fs.constants.COPYFILE_EXCL,
+ );
+ files.push(target);
+ }
+ }
+ } else {
+ for (const entry of fs.readdirSync(destination, {
+ withFileTypes: true,
+ })) {
+ if (entry.isFile() && entry.name.endsWith(".xml"))
+ files.push(path.join(destination, entry.name));
+ }
+ }
+ if (!files.length)
+ throw new Error(
+ "The upstream Ansible action produced no reports. See its execution log.",
+ );
+ this.core.setOutput("report-files", files.join("\n"));
+ }
+}
+
+module.exports = { AnsibleReports };
diff --git a/actions/vm/create/README.md b/actions/vm/create/README.md
index 053f64c..f77cf9c 100644
--- a/actions/vm/create/README.md
+++ b/actions/vm/create/README.md
@@ -87,7 +87,7 @@ application-specific resources such as remote browser devices.
## Usage
```yaml
-- uses: hoverkraft-tech/ci-github-iac/actions/vm/create@2b6495c4fd7dad081899d21186e4f554f3ca8d9c # feat/phase-1-bootstrap
+- uses: hoverkraft-tech/ci-github-iac/actions/vm/create@70843368d5f5f7b0a2cec1e8435d81cc40beee64 # 0.1.0
with:
# Path to a Lima YAML file in the checkout. Plain mode is enforced.
# This input is required.
diff --git a/actions/vm/destroy/README.md b/actions/vm/destroy/README.md
index 6c30a40..5deb3b3 100644
--- a/actions/vm/destroy/README.md
+++ b/actions/vm/destroy/README.md
@@ -57,7 +57,7 @@ and cloud resource cleanup are outside scope.
## Usage
```yaml
-- uses: hoverkraft-tech/ci-github-iac/actions/vm/destroy@2b6495c4fd7dad081899d21186e4f554f3ca8d9c # feat/phase-1-bootstrap
+- uses: hoverkraft-tech/ci-github-iac/actions/vm/destroy@70843368d5f5f7b0a2cec1e8435d81cc40beee64 # 0.1.0
with:
# Private runtime directory returned by vm/create; repeated deletion is safe.
# This input is required.
diff --git a/actions/vm/test/README.md b/actions/vm/test/README.md
index e174ec4..c14188e 100644
--- a/actions/vm/test/README.md
+++ b/actions/vm/test/README.md
@@ -101,7 +101,7 @@ No extra reporting steps are needed in the workflow.
## Usage
```yaml
-- uses: hoverkraft-tech/ci-github-iac/actions/vm/test@2b6495c4fd7dad081899d21186e4f554f3ca8d9c # feat/phase-1-bootstrap
+- uses: hoverkraft-tech/ci-github-iac/actions/vm/test@70843368d5f5f7b0a2cec1e8435d81cc40beee64 # 0.1.0
with:
# Private runtime directory returned by vm/create.
# This input is required.
diff --git a/docs/README.md b/docs/README.md
index 6a88011..ee45859 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -1,7 +1,4 @@
# Documentation
-- [Create a VM](../actions/vm/create/README.md)
-- [Run tests and publish reports](../actions/vm/test/README.md)
-- [Destroy a VM](../actions/vm/destroy/README.md)
- [Contributing, validation and releases](../CONTRIBUTING.md)
- [ADR 0001: Focused VM actions](development/adr/0001-vm-actions.md)
diff --git a/tests/ansible.test.cjs b/tests/ansible.test.cjs
new file mode 100644
index 0000000..fb98a47
--- /dev/null
+++ b/tests/ansible.test.cjs
@@ -0,0 +1,167 @@
+const assert = require("node:assert/strict");
+const fs = require("node:fs");
+const os = require("node:os");
+const path = require("node:path");
+const test = require("node:test");
+const entrypoint = require("../actions/ansible/test/index.cjs");
+
+function fixture(t) {
+ const root = fs.mkdtempSync(
+ path.join(os.tmpdir(), "ansible-composition-test-"),
+ );
+ t.after(() => fs.rmSync(root, { recursive: true, force: true }));
+ const workspace = path.join(root, "checkout");
+ const temp = path.join(root, "temp");
+ fs.mkdirSync(workspace);
+ fs.mkdirSync(temp);
+ fs.writeFileSync(
+ path.join(workspace, "requirements.txt"),
+ "ansible-core==2.99.1\n",
+ );
+ fs.writeFileSync(path.join(workspace, "playbook.yml"), "---\n");
+ const outputs = {};
+ const failures = [];
+ const env = {
+ GITHUB_WORKSPACE: workspace,
+ RUNNER_TEMP: temp,
+ ANSIBLE_PHASE: "prepare",
+ ANSIBLE_REQUIREMENTS: "requirements.txt",
+ ANSIBLE_TEST_PLAYBOOK: "playbook.yml",
+ };
+ const core = {
+ addPath: () => {},
+ setOutput: (key, value) => {
+ outputs[key] = value;
+ },
+ setFailed: (value) => failures.push(value),
+ };
+ return { root, workspace, temp, outputs, failures, env, core };
+}
+
+test("playbook tests keep the consumer's exact Ansible pin and explicit test target", (t) => {
+ const f = fixture(t);
+ entrypoint(f);
+ assert.deepEqual(f.failures, []);
+ assert.equal(f.outputs.mode, "playbook");
+ assert.equal(f.outputs["target-python-version"], "");
+ assert.equal(f.outputs["ansible-core-ref"], "v2.99.1");
+ assert.equal(
+ fs.readFileSync(f.outputs.constraint, "utf8"),
+ "ansible-core==2.99.1\n",
+ );
+ assert.equal(f.outputs["test-playbook"], `${f.workspace}/playbook.yml`);
+ assert.equal(path.dirname(f.outputs["report-directory"]), f.temp);
+});
+
+test("collection inputs select a single checked-out source with Galaxy metadata", (t) => {
+ const f = fixture(t);
+ const collection = "ansible_collections/example/smoke";
+ fs.mkdirSync(path.join(f.workspace, collection), { recursive: true });
+ fs.writeFileSync(
+ path.join(f.workspace, collection, "galaxy.yml"),
+ "namespace: example\nname: smoke\n",
+ );
+ entrypoint({
+ ...f,
+ env: {
+ ...f.env,
+ ANSIBLE_TEST_PLAYBOOK: "",
+ ANSIBLE_COLLECTION: collection,
+ ANSIBLE_TARGET_PYTHON_VERSION: "3.99",
+ },
+ });
+ assert.deepEqual(f.failures, []);
+ assert.equal(f.outputs.mode, "sanity");
+ assert.equal(f.outputs["collection-root"], collection);
+ assert.equal(f.outputs["target-python-version"], "3.99");
+});
+
+test("ambiguous selections, missing targets, non-pinned versions and escaping paths fail", (t) => {
+ const f = fixture(t);
+ fs.symlinkSync(f.temp, path.join(f.workspace, "outside"));
+ fs.writeFileSync(
+ path.join(f.workspace, "floating.txt"),
+ "ansible-core>=2.99\n",
+ );
+ for (const inputs of [
+ { ANSIBLE_TEST_PLAYBOOK: "" },
+ { ANSIBLE_COLLECTION: "." },
+ { ANSIBLE_TEST_PLAYBOOK: "missing.yml" },
+ { ANSIBLE_TEST_PLAYBOOK: "outside" },
+ { ANSIBLE_TEST_PLAYBOOK: "../temp" },
+ { ANSIBLE_REQUIREMENTS: "floating.txt" },
+ { ANSIBLE_TARGET_PYTHON_VERSION: "3.99" },
+ {
+ ANSIBLE_TEST_PLAYBOOK: "",
+ ANSIBLE_COLLECTION: ".",
+ ANSIBLE_TARGET_PYTHON_VERSION: "3.99; false",
+ },
+ ]) {
+ f.failures.length = 0;
+ entrypoint({ ...f, env: { ...f.env, ...inputs } });
+ assert.equal(f.failures.length, 1, JSON.stringify(inputs));
+ }
+});
+
+test("native reports are retained verbatim even when the upstream action has failed", (t) => {
+ const f = fixture(t);
+ entrypoint(f);
+ const source = path.join(f.workspace, "collection/tests/output/junit");
+ fs.mkdirSync(source, { recursive: true });
+ const junit =
+ '';
+ fs.writeFileSync(path.join(source, "sanity.xml"), junit);
+ entrypoint({
+ ...f,
+ env: {
+ ...f.env,
+ ANSIBLE_PHASE: "collect",
+ ANSIBLE_COLLECTION_ROOT: "collection",
+ ANSIBLE_REPORT_DIRECTORY: f.outputs["report-directory"],
+ },
+ });
+ assert.deepEqual(f.failures, []);
+ assert.equal(fs.readFileSync(f.outputs["report-files"], "utf8"), junit);
+});
+
+test("missing reports and redirected report directories cannot be reported as success", (t) => {
+ const f = fixture(t);
+ entrypoint(f);
+ const env = {
+ ...f.env,
+ ANSIBLE_PHASE: "collect",
+ ANSIBLE_REPORT_DIRECTORY: f.outputs["report-directory"],
+ };
+ entrypoint({ ...f, env });
+ assert.match(f.failures.pop(), /no reports/);
+ fs.mkdirSync(path.join(f.workspace, "collection/tests/output"), {
+ recursive: true,
+ });
+ fs.symlinkSync(
+ f.temp,
+ path.join(f.workspace, "collection/tests/output/junit"),
+ );
+ entrypoint({ ...f, env: { ...env, ANSIBLE_COLLECTION_ROOT: "collection" } });
+ assert.match(f.failures.pop(), /symbolic links/);
+ assert.equal(f.outputs["report-files"], undefined);
+});
+
+test("test playbooks publish native JUnit without exposing the installed runtime", (t) => {
+ const f = fixture(t);
+ entrypoint(f);
+ const reports = f.outputs["report-directory"];
+ fs.mkdirSync(path.join(reports, "venv"));
+ const junit =
+ '';
+ fs.writeFileSync(path.join(reports, "playbook.xml"), junit);
+ entrypoint({
+ ...f,
+ env: {
+ ...f.env,
+ ANSIBLE_PHASE: "collect",
+ ANSIBLE_REPORT_DIRECTORY: reports,
+ },
+ });
+ assert.deepEqual(f.failures, []);
+ assert.equal(f.outputs["report-files"], path.join(reports, "playbook.xml"));
+});
diff --git a/tests/fixture/ansible/ansible_collections/example/smoke/README.md b/tests/fixture/ansible/ansible_collections/example/smoke/README.md
new file mode 100644
index 0000000..7b1a3be
--- /dev/null
+++ b/tests/fixture/ansible/ansible_collections/example/smoke/README.md
@@ -0,0 +1,3 @@
+# Synthetic collection
+
+A role used by the [Ansible action tests](../../../../../../.github/workflows/__test-actions-ansible.yml).
diff --git a/tests/fixture/ansible/ansible_collections/example/smoke/galaxy.yml b/tests/fixture/ansible/ansible_collections/example/smoke/galaxy.yml
new file mode 100644
index 0000000..2fd0ad3
--- /dev/null
+++ b/tests/fixture/ansible/ansible_collections/example/smoke/galaxy.yml
@@ -0,0 +1,10 @@
+---
+namespace: example
+name: smoke
+version: 1.0.0
+readme: README.md
+authors: [Hoverkraft]
+description: Synthetic collection for testing the shared action.
+license: [MIT]
+tags: [tools]
+repository: https://github.com/hoverkraft-tech/ci-github-iac
diff --git a/tests/fixture/ansible/ansible_collections/example/smoke/roles/smoke/tasks/main.yml b/tests/fixture/ansible/ansible_collections/example/smoke/roles/smoke/tasks/main.yml
new file mode 100644
index 0000000..f9c92aa
--- /dev/null
+++ b/tests/fixture/ansible/ansible_collections/example/smoke/roles/smoke/tasks/main.yml
@@ -0,0 +1,4 @@
+---
+- name: Provide a synthetic role for collection sanity checks
+ ansible.builtin.debug:
+ msg: Synthetic collection fixture
diff --git a/tests/fixture/ansible/requirements.txt b/tests/fixture/ansible/requirements.txt
new file mode 100644
index 0000000..a755966
--- /dev/null
+++ b/tests/fixture/ansible/requirements.txt
@@ -0,0 +1 @@
+ansible-core==2.21.5
diff --git a/tests/fixture/ansible/valid.yml b/tests/fixture/ansible/valid.yml
new file mode 100644
index 0000000..79716fe
--- /dev/null
+++ b/tests/fixture/ansible/valid.yml
@@ -0,0 +1,11 @@
+---
+- name: Exercise a synthetic Ansible test assertion
+ hosts: localhost
+ connection: local
+ gather_facts: false
+ tasks:
+ - name: Assert the selected fixture succeeds
+ ansible.builtin.assert:
+ that: fixture_result == 'success'
+ vars:
+ fixture_result: "{{ lookup('ansible.builtin.env', 'FIXTURE_RESULT') }}"