From 3aa3c20667a51655e920bf0dfe3552c388a37614 Mon Sep 17 00:00:00 2001 From: Emilien Escalle Date: Thu, 29 Oct 2026 16:48:33 +0100 Subject: [PATCH 01/10] feat(ansible): add standalone validation and reporting action --- .github/dependabot.yml | 16 +- .github/workflows/__shared-ci.yml | 7 +- .github/workflows/__test-actions-ansible.yml | 69 ++++++++ CONTRIBUTING.md | 7 + Makefile | 2 +- README.md | 15 +- actions/ansible/test/README.md | 147 +++++++++++++++++ actions/ansible/test/action.yml | 137 ++++++++++++++++ actions/ansible/test/checks.cjs | 150 ++++++++++++++++++ actions/ansible/test/index.cjs | 10 ++ actions/ansible/test/reports.cjs | 55 +++++++ docs/README.md | 2 + tests/ansible.test.cjs | 116 ++++++++++++++ tests/fixture/ansible/Dockerfile | 9 ++ .../example/smoke/roles/smoke/tasks/main.yml | 4 + tests/fixture/ansible/requirements.txt | 1 + tests/fixture/ansible/valid.yml | 8 + 17 files changed, 750 insertions(+), 5 deletions(-) create mode 100644 .github/workflows/__test-actions-ansible.yml create mode 100644 actions/ansible/test/README.md create mode 100644 actions/ansible/test/action.yml create mode 100644 actions/ansible/test/checks.cjs create mode 100644 actions/ansible/test/index.cjs create mode 100644 actions/ansible/test/reports.cjs create mode 100644 tests/ansible.test.cjs create mode 100644 tests/fixture/ansible/Dockerfile create mode 100644 tests/fixture/ansible/ansible_collections/example/smoke/roles/smoke/tasks/main.yml create mode 100644 tests/fixture/ansible/requirements.txt create mode 100644 tests/fixture/ansible/valid.yml diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 09aad73..5b2fe64 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -7,8 +7,22 @@ updates: schedule: interval: weekly - package-ecosystem: docker - directory: / + directories: + - / + - /tests/fixture/ansible + cooldown: + 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..2261cd8 --- /dev/null +++ b/.github/workflows/__test-actions-ansible.yml @@ -0,0 +1,69 @@ +--- +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: + scenario: [success, syntax-failure] + steps: + - name: Checkout fixture + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Build Ansible fixture image + run: docker build --tag ansible-fixture:local tests/fixture/ansible + - name: Prepare selected scenario without a consumer Makefile + env: + SCENARIO: ${{ matrix.scenario }} + run: | + rm Makefile + cp tests/fixture/ansible/valid.yml tests/fixture/ansible/selected.yml + if [ "$SCENARIO" = syntax-failure ]; then + printf '%s\n' '---' '- hosts: all' ' tasks:' ' - unknown_fixture_module: {}' > tests/fixture/ansible/selected.yml + fi + - name: Test playbooks + id: test + continue-on-error: true + uses: $/actions/ansible/test + with: + container-image: ansible-fixture:local + playbooks: | + tests/fixture/ansible/selected.yml + tests/fixture/ansible/valid.yml + collections: tests/fixture/ansible/ansible_collections/example/smoke + report-name: Ansible tests - ${{ matrix.scenario }} + - name: Verify check outcomes and reporting + if: ${{ always() }} + env: + SCENARIO: ${{ matrix.scenario }} + OUTCOME: ${{ steps.test.outcome }} + REPORTS: ${{ steps.test.outputs.report-files }} + PARSED: ${{ steps.test.outputs.parsed-files }} + SUMMARY: ${{ steps.test.outputs.summary }} + 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'); + const failed = process.env.SCENARIO === 'syntax-failure'; + assert.equal(process.env.OUTCOME, failed ? 'failure' : 'success'); + const files = process.env.REPORTS.split('\n'); + assert.equal(files.length, 2); + assert.deepEqual(JSON.parse(process.env.PARSED).sort(), files.sort()); + assert.ok(process.env.ARTIFACT); + assert.ok(process.env.SUMMARY); + const junit = fs.readFileSync(files.find(file => file.endsWith('.xml')), 'utf8'); + assert.match(junit, /tests="3"/); + assert.ok(junit.includes(`failures="${failed ? 1 : 0}"`)); + const sarif = JSON.parse(fs.readFileSync(files.find(file => file.endsWith('.sarif')), 'utf8')); + assert.equal(sarif.runs[0].results.length, failed ? 1 : 0); diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f833995..593865a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -58,6 +58,13 @@ 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 native syntax checks in a pinned fixture +image, including failures, continued checks and report publication. Ansible action +logic lives entirely under `actions/ansible/test/`; it owns command execution and +sanitized JUnit/SARIF reporting. Consumers supply their tooling image, playbooks +and collections, and keep their integration scenarios. Dependabot maintains the +fixture's Docker and pip dependencies. + ## Releases and dependencies Use Conventional Commits and a reviewed pull request. Publish semantic versions diff --git a/Makefile b/Makefile index ea484eb..b5afe78 100644 --- a/Makefile +++ b/Makefile @@ -31,7 +31,7 @@ ci: ## Execute local CI tasks $(MAKE) test 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 + 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 actions/ansible/test/action.yml --repository git ############################# # Helpers diff --git a/README.md b/README.md index 57e2e21..b91a9d3 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) + +Validate playbook syntax and first-party collection sanity using your tooling +image. Publish check 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 and build or pull its Ansible tooling image +on a Linux runner with Docker. Pass the image and 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..285ae0c --- /dev/null +++ b/actions/ansible/test/README.md @@ -0,0 +1,147 @@ + + +# ![Icon](data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0IiBmaWxsPSJub25lIiBzdHJva2U9ImN1cnJlbnRDb2xvciIgc3Ryb2tlLXdpZHRoPSIyIiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiIGNsYXNzPSJmZWF0aGVyIGZlYXRoZXItY2hlY2stY2lyY2xlIiBjb2xvcj0icHVycGxlIj48cGF0aCBkPSJNMjIgMTEuMDhWMTJhMTAgMTAgMCAxIDEtNS45My05LjE0Ij48L3BhdGg+PHBvbHlsaW5lIHBvaW50cz0iMjIgNCAxMiAxNC4wMSA5IDExLjAxIj48L3BvbHlsaW5lPjwvc3ZnPg==) GitHub Action: Ansible - Test + + + + +[![Marketplace](https://img.shields.io/badge/Marketplace-ansible------test-blue?logo=github-actions)](https://github.com/marketplace/actions/ansible---test) +[![Release](https://img.shields.io/github/v/release/hoverkraft-tech/ci-github-iac)](https://github.com/hoverkraft-tech/ci-github-iac/releases) +[![Stars](https://img.shields.io/github/stars/hoverkraft-tech/ci-github-iac?style=social)](https://img.shields.io/github/stars/hoverkraft-tech/ci-github-iac?style=social) + + + + +## Overview + +Validate playbook syntax and collection sanity, publish reports and annotate pull requests. + +Validate Ansible playbooks and first-party collections in a consumer tooling image. + +### Run checks + +Check out the consumer and build or pull its tooling image before this action. +The Linux runner needs Docker. The image must provide `/bin/sh`, `ansible-playbook`, +`ansible-test` and `python3`, with the consumer's pinned dependencies installed. +No Makefile or reporting script is required. The image's entrypoint is replaced. + +Supply newline-separated `playbooks`, `collections`, or both. Paths are explicit +checkout-relative paths, without globs. Inventory is optional; when omitted, +Ansible uses the image's configuration. Missing, empty and escaping paths fail. +Collection roots must follow `ansible_collections/namespace/name` layout. + +Each playbook runs with `ansible-playbook --syntax-check`. Each collection runs +with `ansible-test sanity --python` using the image's Python version. Checks +continue after a nonzero exit so all selected targets receive a result. Each +command has a 20-minute timeout. Interrupted containers are forcibly removed. +These checks do not apply playbooks or measure code coverage; integration and +deployment scenarios remain in the consumer. + +The checkout is mounted at `/workspace` and commands start there (sanity starts +in its collection directory). Containers run with the runner's UID/GID and a +temporary home. Use `environment` for Ansible configuration, for example +`ANSIBLE_COLLECTIONS_PATH=/opt/ansible/collections:/workspace/ansible/collections`. +Image environment defaults remain available. Runner secrets are not forwarded. + +`cache-directory` optionally mounts a checkout-local directory for ansible-test +virtual environments. Restore/save it with `actions/cache`, keyed by the tooling +runtime. The action does not cache source files or reports. Sanity's normal +`tests/output/` files remain in each collection and are not uploaded. + +### Publish results + +The action writes fresh JUnit status results for every executed check and SARIF +locations for syntax failures under `runner.temp`. Raw command output remains +in execution logs; report artifacts contain status and file locations only. +Hoverkraft's parser publishes summaries and annotations. PR comments use a +stable `report-name` and require `pull-requests: write`; forks and Dependabot +skip comments. Never use `pull_request_target` to execute untrusted tests. + +Completed reports are uploaded with unique artifact names and seven-day +retention, including after checks or report publication fail. Neither a parser +nor an upload can turn failed checks into success. Use a distinct report name +for each invocation. Reports describe validation results, not code coverage. + + + + +## Usage + +```yaml +- uses: hoverkraft-tech/ci-github-iac/actions/ansible/test@70843368d5f5f7b0a2cec1e8435d81cc40beee64 # 0.1.0 + with: + # Built or pulled tooling image containing Ansible and the consumer's dependencies. + # This input is required. + container-image: "" + + # Newline-separated nonempty playbook files relative to the checkout. Supply playbooks or collections. + playbooks: "" + + # Optional inventory file relative to the checkout for syntax validation. + inventory: "" + + # Newline-separated first-party collection directories relative to the checkout for sanity validation. + collections: "" + + # Newline-separated ANSIBLE_* assignments using container paths. ANSIBLE_HOME and ANSIBLE_NOCOLOR are managed by the action. + environment: "" + + # Optional checkout-relative directory for ansible-test virtual environments; manage persistence with actions/cache. + cache-directory: "" + + # Stable summary and PR comment title, distinct for each test step. + # Default: `Ansible test results` + report-name: Ansible test results + + # Token for PR comments; requires pull-requests write. Fork and Dependabot comments are skipped. + # Default: `${{ github.token }}` + github-token: ${{ github.token }} +``` + + + + +## Inputs + +| **Input** | **Description** | **Required** | **Default** | +| --------------------- | -------------------------------------------------------------------------------------------------------------------------- | ------------ | ---------------------- | +| **`container-image`** | Built or pulled tooling image containing Ansible and the consumer's dependencies. | **true** | - | +| **`playbooks`** | Newline-separated nonempty playbook files relative to the checkout. Supply playbooks or collections. | **false** | - | +| **`inventory`** | Optional inventory file relative to the checkout for syntax validation. | **false** | - | +| **`collections`** | Newline-separated first-party collection directories relative to the checkout for sanity validation. | **false** | - | +| **`environment`** | Newline-separated ANSIBLE_* assignments using container paths. ANSIBLE_HOME and ANSIBLE_NOCOLOR are managed by the action. | **false** | - | +| **`cache-directory`** | Optional checkout-relative directory for ansible-test virtual environments; manage persistence with actions/cache. | **false** | - | +| **`report-name`** | Stable summary and PR comment title, distinct for each test step. | **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 JUnit and SARIF reports, also 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..581c646 --- /dev/null +++ b/actions/ansible/test/action.yml @@ -0,0 +1,137 @@ +# Validate Ansible playbooks and first-party collections in a consumer tooling image. +# +# ### Run checks +# +# Check out the consumer and build or pull its tooling image before this action. +# The Linux runner needs Docker. The image must provide `/bin/sh`, `ansible-playbook`, +# `ansible-test` and `python3`, with the consumer's pinned dependencies installed. +# No Makefile or reporting script is required. The image's entrypoint is replaced. +# +# Supply newline-separated `playbooks`, `collections`, or both. Paths are explicit +# checkout-relative paths, without globs. Inventory is optional; when omitted, +# Ansible uses the image's configuration. Missing, empty and escaping paths fail. +# Collection roots must follow `ansible_collections/namespace/name` layout. +# +# Each playbook runs with `ansible-playbook --syntax-check`. Each collection runs +# with `ansible-test sanity --python` using the image's Python version. Checks +# continue after a nonzero exit so all selected targets receive a result. Each +# command has a 20-minute timeout. Interrupted containers are forcibly removed. +# These checks do not apply playbooks or measure code coverage; integration and +# deployment scenarios remain in the consumer. +# +# The checkout is mounted at `/workspace` and commands start there (sanity starts +# in its collection directory). Containers run with the runner's UID/GID and a +# temporary home. Use `environment` for Ansible configuration, for example +# `ANSIBLE_COLLECTIONS_PATH=/opt/ansible/collections:/workspace/ansible/collections`. +# Image environment defaults remain available. Runner secrets are not forwarded. +# +# `cache-directory` optionally mounts a checkout-local directory for ansible-test +# virtual environments. Restore/save it with `actions/cache`, keyed by the tooling +# runtime. The action does not cache source files or reports. Sanity's normal +# `tests/output/` files remain in each collection and are not uploaded. +# +# ### Publish results +# +# The action writes fresh JUnit status results for every executed check and SARIF +# locations for syntax failures under `runner.temp`. Raw command output remains +# in execution logs; report artifacts contain status and file locations only. +# Hoverkraft's parser publishes summaries and annotations. PR comments use a +# stable `report-name` and require `pull-requests: write`; forks and Dependabot +# skip comments. Never use `pull_request_target` to execute untrusted tests. +# +# Completed reports are uploaded with unique artifact names and seven-day +# retention, including after checks or report publication fail. Neither a parser +# nor an upload can turn failed checks into success. Use a distinct report name +# for each invocation. Reports describe validation results, not code coverage. +--- +name: Ansible - Test +description: Validate playbook syntax and collection sanity, publish reports and annotate pull requests. +author: Hoverkraft +branding: + icon: check-circle + color: purple +inputs: + container-image: + description: Built or pulled tooling image containing Ansible and the consumer's dependencies. + required: true + playbooks: + description: Newline-separated nonempty playbook files relative to the checkout. Supply playbooks or collections. + default: "" + inventory: + description: Optional inventory file relative to the checkout for syntax validation. + default: "" + collections: + description: Newline-separated first-party collection directories relative to the checkout for sanity validation. + default: "" + environment: + description: Newline-separated ANSIBLE_* assignments using container paths. ANSIBLE_HOME and ANSIBLE_NOCOLOR are managed by the action. + default: "" + cache-directory: + description: Optional checkout-relative directory for ansible-test virtual environments; manage persistence with actions/cache. + default: "" + report-name: + description: Stable summary and PR comment title, distinct for each test step. + 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 JUnit and SARIF reports, also after check failure. + value: ${{ steps.test.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: Run Ansible checks + id: test + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + ANSIBLE_ACTION_PATH: ${{ github.action_path }} + ANSIBLE_TEST_IMAGE: ${{ inputs.container-image }} + ANSIBLE_TEST_PLAYBOOKS: ${{ inputs.playbooks }} + ANSIBLE_TEST_INVENTORY: ${{ inputs.inventory }} + ANSIBLE_TEST_COLLECTIONS: ${{ inputs.collections }} + ANSIBLE_TEST_ENVIRONMENT: ${{ inputs.environment }} + ANSIBLE_TEST_CACHE: ${{ inputs.cache-directory }} + with: + script: await require(`${process.env.ANSIBLE_ACTION_PATH}/index.cjs`)({ core }); + + - name: Parse Ansible reports + if: ${{ always() && steps.test.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.test.outputs.report-files }} + 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.test.outputs.report-files != '' }} + id: upload + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: ${{ steps.test.outputs.id }} + path: ${{ steps.test.outputs.report-files }} + if-no-files-found: error + retention-days: 7 diff --git a/actions/ansible/test/checks.cjs b/actions/ansible/test/checks.cjs new file mode 100644 index 0000000..f92d1ab --- /dev/null +++ b/actions/ansible/test/checks.cjs @@ -0,0 +1,150 @@ +const fs = require("node:fs"); +const path = require("node:path"); +const { randomUUID } = require("node:crypto"); +const { spawnSync } = require("node:child_process"); +const { CheckReports } = require("./reports.cjs"); + +class AnsibleChecks { + constructor({ core, env, run = spawnSync }) { + this.core = core; + this.env = env; + this.run = run; + this.workspace = fs.realpathSync(env.GITHUB_WORKSPACE); + this.image = env.ANSIBLE_TEST_IMAGE?.trim(); + if (!this.image || !/^[a-zA-Z0-9][a-zA-Z0-9._/:@-]*$/.test(this.image)) { + throw new Error("container-image must be a Docker image reference."); + } + this.playbooks = this.paths(env.ANSIBLE_TEST_PLAYBOOKS, false); + this.collections = this.paths(env.ANSIBLE_TEST_COLLECTIONS, true); + if (!this.playbooks.length && !this.collections.length) { + throw new Error("Provide at least one playbook or collection to test."); + } + for (const collection of this.collections) { + if (!/\/ansible_collections\/\w+\/\w+$/.test(`/${collection}`)) { + throw new Error("Collections must use ansible_collections/namespace/name directories."); + } + } + this.inventory = env.ANSIBLE_TEST_INVENTORY?.trim(); + if (this.inventory) this.inventory = this.relativePath(this.inventory, false); + this.environment = this.lines(env.ANSIBLE_TEST_ENVIRONMENT); + for (const setting of this.environment) { + if (!/^ANSIBLE_[A-Z_]+=.*$/.test(setting) || /^ANSIBLE_(HOME|NOCOLOR)=/.test(setting)) { + throw new Error("environment accepts ANSIBLE_* assignments except ANSIBLE_HOME and ANSIBLE_NOCOLOR."); + } + } + this.cache = env.ANSIBLE_TEST_CACHE?.trim(); + if (this.cache) { + const target = path.resolve(this.workspace, this.cache); + this.contained(target); + // Validate existing ancestors before creating directories through possible links. + let ancestor = target; + while (!fs.existsSync(ancestor)) ancestor = path.dirname(ancestor); + this.contained(fs.realpathSync(ancestor)); + fs.mkdirSync(target, { recursive: true }); + this.cache = fs.realpathSync(target); + this.contained(this.cache); + } + } + + lines(value = "") { + return [...new Set(value.split(/\r?\n/).map((line) => line.trim()).filter(Boolean))]; + } + + contained(target) { + const relative = path.relative(this.workspace, target); + if (relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative) || /[,\r\n]/.test(target)) { + throw new Error("Paths must stay inside the checkout and cannot contain commas or newlines."); + } + } + + relativePath(value, directory) { + const target = fs.realpathSync(path.resolve(this.workspace, value)); + this.contained(target); + const stat = fs.statSync(target); + if (directory ? !stat.isDirectory() : !stat.isFile() || stat.size === 0) { + throw new Error(`${value} must be a ${directory ? "directory" : "nonempty file"}.`); + } + return path.relative(this.workspace, target); + } + + paths(value, directory) { + return this.lines(value).map((entry) => this.relativePath(entry, directory)); + } + + container(command, args = [], directory = "") { + const name = `ansible-test-${randomUUID()}`; + const options = [ + "run", "--rm", "--name", name, + "--user", `${process.getuid()}:${process.getgid()}`, + "--mount", `type=bind,src=${this.workspace},dst=/workspace`, + "--mount", `type=bind,src=${this.accounts}/passwd,dst=/etc/passwd,readonly`, + "--mount", `type=bind,src=${this.accounts}/group,dst=/etc/group,readonly`, + "--workdir", path.posix.join("/workspace", directory), + "--env", "HOME=/tmp", "--env", "XDG_CACHE_HOME=/tmp/.cache", + "--env", "ANSIBLE_HOME=/tmp/.ansible", "--env", "ANSIBLE_NOCOLOR=1", + ]; + for (const setting of this.environment) options.push("--env", setting); + if (this.cache) options.push("--mount", `type=bind,src=${this.cache},dst=/ansible-test-cache`); + options.push("--entrypoint", "/bin/sh", this.image, "-ec", + 'mkdir -p "$ANSIBLE_HOME/test"; if [ -d /ansible-test-cache ]; then ln -s /ansible-test-cache "$ANSIBLE_HOME/test/venv"; fi; exec "$@"', + "ansible-test", command, ...args); + let result; + try { + result = this.run("docker", options, { encoding: "utf8", timeout: 20 * 60 * 1000, maxBuffer: 8 * 1024 * 1024 }); + const output = `${result.stdout || ""}${result.stderr || ""}`; + this.core.info(output); + return { code: result.error || result.signal ? 1 : result.status, output }; + } finally { + // A killed Docker client can leave its container running after a timeout. + if (!result || result.error || result.signal) { + const cleanup = this.run("docker", ["rm", "--force", name], { encoding: "utf8", timeout: 30000 }); + if (cleanup.status !== 0 && !cleanup.stderr?.includes("No such container")) { + throw new Error("Failed to remove the interrupted Ansible test container."); + } + } + } + } + + async execute() { + this.contained(this.workspace); + const directory = fs.mkdtempSync(path.join(this.env.RUNNER_TEMP, "ansible-reports-")); + const reports = new CheckReports(directory); + this.accounts = fs.mkdtempSync(path.join(this.env.RUNNER_TEMP, "ansible-accounts-")); + fs.chmodSync(this.accounts, 0o755); + const uid = process.getuid(); + const gid = process.getgid(); + fs.writeFileSync(path.join(this.accounts, "passwd"), `ansible-ci:x:${uid}:${gid}::/tmp:/bin/sh\n`, { mode: 0o644 }); + fs.writeFileSync(path.join(this.accounts, "group"), `ansible-ci:x:${gid}:\n`, { mode: 0o644 }); + try { + for (const playbook of this.playbooks) { + this.core.info(`Checking playbook ${playbook}`); + const args = ["--syntax-check"]; + if (this.inventory) args.push("--inventory", `/workspace/${this.inventory}`); + args.push(`/workspace/${playbook}`); + const result = this.container("ansible-playbook", args); + reports.add("syntax", playbook, result); + } + if (this.collections.length) { + const python = this.container("python3", ["--version"]); + const version = python.output.match(/^Python (\d+\.\d+)\./m)?.[1]; + if (python.code !== 0 || !version) throw new Error("Cannot identify the tooling image's Python version."); + for (const collection of this.collections) { + this.core.info(`Checking collection ${collection}`); + const result = this.container("ansible-test", ["sanity", "--python", version], collection); + reports.add("sanity", collection, result); + } + } + } catch (error) { + reports.add("runtime", "Ansible test execution", { code: 1, output: "" }); + throw error; + } finally { + fs.rmSync(this.accounts, { recursive: true, force: true }); + const files = reports.write(); + this.core.setOutput("report-files", files.join("\n")); + this.core.setOutput("id", path.basename(directory)); + } + if (reports.failed) throw new Error("Ansible checks failed. See the check reports and execution log."); + } +} + +module.exports = { AnsibleChecks }; diff --git a/actions/ansible/test/index.cjs b/actions/ansible/test/index.cjs new file mode 100644 index 0000000..2c706b4 --- /dev/null +++ b/actions/ansible/test/index.cjs @@ -0,0 +1,10 @@ +const { AnsibleChecks } = require("./checks.cjs"); + +module.exports = async ({ core, env = process.env, run }) => { + try { + const checks = new AnsibleChecks({ core, env, run }); + await checks.execute(); + } catch (error) { + core.setFailed(error.message); + } +}; diff --git a/actions/ansible/test/reports.cjs b/actions/ansible/test/reports.cjs new file mode 100644 index 0000000..02a3d79 --- /dev/null +++ b/actions/ansible/test/reports.cjs @@ -0,0 +1,55 @@ +const fs = require("node:fs"); +const path = require("node:path"); + +class CheckReports { + constructor(directory) { + this.directory = directory; + this.checks = []; + this.issues = []; + } + + get failed() { + return this.checks.some((check) => check.code !== 0); + } + + add(kind, target, { code, output }) { + this.checks.push({ kind, target, code }); + if (kind !== "syntax" || code === 0) return; + const origin = output.match(/^Origin: \/workspace\/(.+):(\d+):(\d+)\s*$/m); + const filename = origin?.[1] || target; + const relative = path.posix.normalize(filename); + const safe = !relative.startsWith("../") && !path.posix.isAbsolute(relative); + this.issues.push({ + ruleId: "ansible-syntax", + level: "error", + // Keep raw diagnostics in the execution log, not in public report artifacts. + message: { text: `Syntax check failed for ${target}. See the execution log.` }, + locations: [{ physicalLocation: { + artifactLocation: { uri: encodeURI(safe ? relative : target), uriBaseId: "%SRCROOT%" }, + region: { startLine: safe && origin ? Math.max(1, Number(origin[2])) : 1, startColumn: safe && origin ? Math.max(1, Number(origin[3])) : 1 }, + } }], + }); + } + + escape(value) { + return value.replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f]/g, "").replace(/[&<>"']/g, (character) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[character]); + } + + write() { + const cases = this.checks.map(({ kind, target, code }) => { + const failure = code === 0 ? "" : ''; + return `${failure}`; + }); + const junit = path.join(this.directory, "checks.junit.xml"); + fs.writeFileSync(junit, `\n${cases.join("")}\n`); + const sarif = path.join(this.directory, "syntax.sarif"); + fs.writeFileSync(sarif, `${JSON.stringify({ + $schema: "https://json.schemastore.org/sarif-2.1.0.json", + version: "2.1.0", + runs: [{ tool: { driver: { name: "ansible-playbook", rules: [{ id: "ansible-syntax", shortDescription: { text: "Ansible playbook syntax validation" } }] } }, results: this.issues }], + }, null, 2)}\n`); + return [junit, sarif]; + } +} + +module.exports = { CheckReports }; diff --git a/docs/README.md b/docs/README.md index 6a88011..ff54ae0 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,5 +1,7 @@ # Documentation +- [Validate Ansible playbooks and collections](../actions/ansible/test/README.md) + - [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) diff --git a/tests/ansible.test.cjs b/tests/ansible.test.cjs new file mode 100644 index 0000000..3fd1865 --- /dev/null +++ b/tests/ansible.test.cjs @@ -0,0 +1,116 @@ +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-check-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); + for (const filename of ["first.yml", "second.yml", "inventory.yml"]) fs.writeFileSync(path.join(workspace, filename), "---\n"); + const collection = "collections/ansible_collections/example/smoke"; + fs.mkdirSync(path.join(workspace, collection), { recursive: true }); + const outputs = {}; + const failures = []; + const calls = []; + const env = { GITHUB_WORKSPACE: workspace, RUNNER_TEMP: temp, ANSIBLE_TEST_IMAGE: "fixture:local", ANSIBLE_TEST_PLAYBOOKS: "first.yml\nsecond.yml", ANSIBLE_TEST_INVENTORY: "inventory.yml" }; + const core = { info() {}, setOutput: (key, value) => { outputs[key] = value; }, setFailed: (value) => failures.push(value) }; + const success = { status: 0, stdout: "", stderr: "" }; + const run = (command, args) => { calls.push({ command, args }); return args.includes("--version") ? { ...success, stdout: "Python 3.14.1\n" } : success; }; + return { root, workspace, temp, collection, outputs, failures, calls, env, core, run, success }; +} + +test("syntax and sanity checks use native commands and the image's Python with no consumer Makefile", async (t) => { + const f = fixture(t); + f.env.ANSIBLE_TEST_COLLECTIONS = f.collection; + f.env.ANSIBLE_TEST_CACHE = ".cache/sanity"; + f.env.ANSIBLE_TEST_ENVIRONMENT = "ANSIBLE_COLLECTIONS_PATH=/opt/collections:/workspace/collections"; + await entrypoint(f); + assert.deepEqual(f.failures, []); + assert.equal(f.calls.length, 4); + assert.ok(f.calls.every(({ command, args }) => command === "docker" && args.includes("--user") && !args.includes("make"))); + assert.ok(f.calls[0].args.includes("/workspace/inventory.yml")); + assert.deepEqual(f.calls[3].args.slice(-4), ["ansible-test", "sanity", "--python", "3.14"]); + assert.ok(f.calls[3].args.includes(`/workspace/${f.collection}`)); + assert.ok(f.calls[3].args.includes(`type=bind,src=${f.workspace}/.cache/sanity,dst=/ansible-test-cache`)); + assert.match(fs.readFileSync(f.outputs["report-files"].split("\n")[0], "utf8"), /tests="3" failures="0"/); + assert.equal(fs.readdirSync(f.temp).some((name) => name.startsWith("ansible-accounts-")), false); +}); + +test("failures do not suppress subsequent checks and reports exclude raw output", async (t) => { + const f = fixture(t); + f.env.ANSIBLE_TEST_COLLECTIONS = f.collection; + const run = (command, args) => { + const result = f.run(command, args); + return args.includes("/workspace/first.yml") || args.includes("sanity") ? { status: 2, stdout: "[ERROR]: synthetic-private-output\nOrigin: /workspace/roles/task.yml:7:3\n", stderr: "" } : result; + }; + await entrypoint({ ...f, run }); + assert.equal(f.calls.length, 4); + assert.equal(f.failures.length, 1); + const [junit, sarif] = f.outputs["report-files"].split("\n").map((filename) => fs.readFileSync(filename, "utf8")); + assert.match(junit, /tests="3" failures="2"/); + assert.equal(`${junit}${sarif}`.includes("synthetic-private-output"), false); + const location = JSON.parse(sarif).runs[0].results[0].locations[0].physicalLocation; + assert.equal(location.artifactLocation.uri, "roles/task.yml"); + assert.deepEqual(location.region, { startLine: 7, startColumn: 3 }); +}); + +test("timeouts remove the named container and still publish failure results", async (t) => { + const f = fixture(t); + f.env.ANSIBLE_TEST_PLAYBOOKS = "first.yml"; + await entrypoint({ ...f, run: (command, args) => { + f.run(command, args); + return args[0] === "run" ? { status: null, error: new Error("timeout"), signal: "SIGTERM" } : f.success; + } }); + assert.equal(f.failures.length, 1); + const name = f.calls[0].args[f.calls[0].args.indexOf("--name") + 1]; + assert.deepEqual(f.calls[1].args, ["rm", "--force", name]); + assert.match(fs.readFileSync(f.outputs["report-files"].split("\n")[0], "utf8"), /failures="1"/); +}); + +test("container cleanup failure is reported and cannot pass", async (t) => { + const f = fixture(t); + await entrypoint({ ...f, run: () => ({ status: 1, error: new Error("failed"), stderr: "daemon unavailable" }) }); + assert.match(f.failures[0], /remove the interrupted/); + assert.ok(f.outputs["report-files"]); +}); + +test("missing selections, files, directories, invalid settings and path escapes fail before Docker", async (t) => { + const f = fixture(t); + fs.symlinkSync(f.temp, path.join(f.workspace, "outside")); + fs.writeFileSync(path.join(f.workspace, "empty.yml"), ""); + const invalid = [ + { ANSIBLE_TEST_PLAYBOOKS: "" }, + { ANSIBLE_TEST_PLAYBOOKS: "missing.yml" }, + { ANSIBLE_TEST_PLAYBOOKS: "empty.yml" }, + { ANSIBLE_TEST_PLAYBOOKS: "../temp" }, + { ANSIBLE_TEST_COLLECTIONS: "." }, + { ANSIBLE_TEST_CACHE: "outside/new/cache" }, + { ANSIBLE_TEST_IMAGE: "--privileged" }, + { ANSIBLE_TEST_ENVIRONMENT: "HOME=/root" }, + { ANSIBLE_TEST_ENVIRONMENT: "ANSIBLE_HOME=/elsewhere" }, + ]; + for (const inputs of invalid) { + f.failures.length = 0; + await entrypoint({ ...f, env: { ...f.env, ...inputs } }); + assert.equal(f.failures.length, 1, JSON.stringify(inputs)); + } + assert.equal(f.calls.length, 0); + assert.equal(fs.existsSync(path.join(f.temp, "new")), false); +}); + +test("reruns preserve earlier reports and escape XML filenames", async (t) => { + const f = fixture(t); + fs.writeFileSync(path.join(f.workspace, 'a&"b.yml'), "---\n"); + f.env.ANSIBLE_TEST_PLAYBOOKS = 'a&"b.yml'; + await entrypoint(f); + const first = f.outputs["report-files"]; + await entrypoint(f); + assert.notEqual(first, f.outputs["report-files"]); + assert.match(fs.readFileSync(first.split("\n")[0], "utf8"), /a&"b.yml/); +}); diff --git a/tests/fixture/ansible/Dockerfile b/tests/fixture/ansible/Dockerfile new file mode 100644 index 0000000..59c8514 --- /dev/null +++ b/tests/fixture/ansible/Dockerfile @@ -0,0 +1,9 @@ +FROM debian:trixie-slim@sha256:f6b08b09a05725f9431502d3b5714f5c4576e82c1a84fa1debe0c24a7e0c3ec4 + +# hadolint ignore=DL3008 +RUN apt-get update && apt-get install -y --no-install-recommends python3 python3-venv git && \ + rm -rf /var/lib/apt/lists/* +COPY requirements.txt /opt/requirements.txt +RUN python3 -m venv /opt/ansible && /opt/ansible/bin/pip install --no-cache-dir -r /opt/requirements.txt +ENV PATH="/opt/ansible/bin:${PATH}" +HEALTHCHECK CMD ["ansible-playbook", "--version"] 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..ee1bf46 --- /dev/null +++ b/tests/fixture/ansible/valid.yml @@ -0,0 +1,8 @@ +--- +- name: Validate syntax without applying a playbook + hosts: all + gather_facts: false + tasks: + - name: Prevent accidental execution of the syntax fixture + ansible.builtin.fail: + msg: This playbook must only be syntax checked. From 7bb6c22d7e9a9eded70627a001ea880a033e100d Mon Sep 17 00:00:00 2001 From: Emilien Escalle Date: Thu, 29 Oct 2026 16:52:41 +0100 Subject: [PATCH 02/10] fix(ansible): validate fixture and generated documentation conventions --- .gitignore | 1 + Makefile | 4 +- actions/ansible/test/checks.cjs | 155 ++++++++++++++++++++++++------- actions/ansible/test/reports.cjs | 85 ++++++++++++++--- actions/vm/create/README.md | 2 +- actions/vm/destroy/README.md | 2 +- actions/vm/test/README.md | 2 +- tests/ansible.test.cjs | 123 +++++++++++++++++++----- tests/fixture/ansible/Dockerfile | 1 + 9 files changed, 303 insertions(+), 72 deletions(-) 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/Makefile b/Makefile index b5afe78..abcbbce 100644 --- a/Makefile +++ b/Makefile @@ -31,7 +31,9 @@ ci: ## Execute local CI tasks $(MAKE) test 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 actions/ansible/test/action.yml --repository git + 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 + # Ansible is not in the latest release; its metadata supplies a reviewed commit example. + 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/actions/ansible/test/checks.cjs b/actions/ansible/test/checks.cjs index f92d1ab..0acbafa 100644 --- a/actions/ansible/test/checks.cjs +++ b/actions/ansible/test/checks.cjs @@ -21,15 +21,23 @@ class AnsibleChecks { } for (const collection of this.collections) { if (!/\/ansible_collections\/\w+\/\w+$/.test(`/${collection}`)) { - throw new Error("Collections must use ansible_collections/namespace/name directories."); + throw new Error( + "Collections must use ansible_collections/namespace/name directories.", + ); } } this.inventory = env.ANSIBLE_TEST_INVENTORY?.trim(); - if (this.inventory) this.inventory = this.relativePath(this.inventory, false); + if (this.inventory) + this.inventory = this.relativePath(this.inventory, false); this.environment = this.lines(env.ANSIBLE_TEST_ENVIRONMENT); for (const setting of this.environment) { - if (!/^ANSIBLE_[A-Z_]+=.*$/.test(setting) || /^ANSIBLE_(HOME|NOCOLOR)=/.test(setting)) { - throw new Error("environment accepts ANSIBLE_* assignments except ANSIBLE_HOME and ANSIBLE_NOCOLOR."); + if ( + !/^ANSIBLE_[A-Z_]+=.*$/.test(setting) || + /^ANSIBLE_(HOME|NOCOLOR)=/.test(setting) + ) { + throw new Error( + "environment accepts ANSIBLE_* assignments except ANSIBLE_HOME and ANSIBLE_NOCOLOR.", + ); } } this.cache = env.ANSIBLE_TEST_CACHE?.trim(); @@ -47,13 +55,27 @@ class AnsibleChecks { } lines(value = "") { - return [...new Set(value.split(/\r?\n/).map((line) => line.trim()).filter(Boolean))]; + return [ + ...new Set( + value + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean), + ), + ]; } contained(target) { const relative = path.relative(this.workspace, target); - if (relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative) || /[,\r\n]/.test(target)) { - throw new Error("Paths must stay inside the checkout and cannot contain commas or newlines."); + if ( + relative === ".." || + relative.startsWith(`..${path.sep}`) || + path.isAbsolute(relative) || + /[,\r\n]/.test(target) + ) { + throw new Error( + "Paths must stay inside the checkout and cannot contain commas or newlines.", + ); } } @@ -62,44 +84,88 @@ class AnsibleChecks { this.contained(target); const stat = fs.statSync(target); if (directory ? !stat.isDirectory() : !stat.isFile() || stat.size === 0) { - throw new Error(`${value} must be a ${directory ? "directory" : "nonempty file"}.`); + throw new Error( + `${value} must be a ${directory ? "directory" : "nonempty file"}.`, + ); } return path.relative(this.workspace, target); } paths(value, directory) { - return this.lines(value).map((entry) => this.relativePath(entry, directory)); + return this.lines(value).map((entry) => + this.relativePath(entry, directory), + ); } container(command, args = [], directory = "") { const name = `ansible-test-${randomUUID()}`; const options = [ - "run", "--rm", "--name", name, - "--user", `${process.getuid()}:${process.getgid()}`, - "--mount", `type=bind,src=${this.workspace},dst=/workspace`, - "--mount", `type=bind,src=${this.accounts}/passwd,dst=/etc/passwd,readonly`, - "--mount", `type=bind,src=${this.accounts}/group,dst=/etc/group,readonly`, - "--workdir", path.posix.join("/workspace", directory), - "--env", "HOME=/tmp", "--env", "XDG_CACHE_HOME=/tmp/.cache", - "--env", "ANSIBLE_HOME=/tmp/.ansible", "--env", "ANSIBLE_NOCOLOR=1", + "run", + "--rm", + "--name", + name, + "--user", + `${process.getuid()}:${process.getgid()}`, + "--mount", + `type=bind,src=${this.workspace},dst=/workspace`, + "--mount", + `type=bind,src=${this.accounts}/passwd,dst=/etc/passwd,readonly`, + "--mount", + `type=bind,src=${this.accounts}/group,dst=/etc/group,readonly`, + "--workdir", + path.posix.join("/workspace", directory), + "--env", + "HOME=/tmp", + "--env", + "XDG_CACHE_HOME=/tmp/.cache", + "--env", + "ANSIBLE_HOME=/tmp/.ansible", + "--env", + "ANSIBLE_NOCOLOR=1", ]; for (const setting of this.environment) options.push("--env", setting); - if (this.cache) options.push("--mount", `type=bind,src=${this.cache},dst=/ansible-test-cache`); - options.push("--entrypoint", "/bin/sh", this.image, "-ec", + if (this.cache) + options.push( + "--mount", + `type=bind,src=${this.cache},dst=/ansible-test-cache`, + ); + options.push( + "--entrypoint", + "/bin/sh", + this.image, + "-ec", 'mkdir -p "$ANSIBLE_HOME/test"; if [ -d /ansible-test-cache ]; then ln -s /ansible-test-cache "$ANSIBLE_HOME/test/venv"; fi; exec "$@"', - "ansible-test", command, ...args); + "ansible-test", + command, + ...args, + ); let result; try { - result = this.run("docker", options, { encoding: "utf8", timeout: 20 * 60 * 1000, maxBuffer: 8 * 1024 * 1024 }); + result = this.run("docker", options, { + encoding: "utf8", + timeout: 20 * 60 * 1000, + maxBuffer: 8 * 1024 * 1024, + }); const output = `${result.stdout || ""}${result.stderr || ""}`; this.core.info(output); - return { code: result.error || result.signal ? 1 : result.status, output }; + return { + code: result.error || result.signal ? 1 : result.status, + output, + }; } finally { // A killed Docker client can leave its container running after a timeout. if (!result || result.error || result.signal) { - const cleanup = this.run("docker", ["rm", "--force", name], { encoding: "utf8", timeout: 30000 }); - if (cleanup.status !== 0 && !cleanup.stderr?.includes("No such container")) { - throw new Error("Failed to remove the interrupted Ansible test container."); + const cleanup = this.run("docker", ["rm", "--force", name], { + encoding: "utf8", + timeout: 30000, + }); + if ( + cleanup.status !== 0 && + !cleanup.stderr?.includes("No such container") + ) { + throw new Error( + "Failed to remove the interrupted Ansible test container.", + ); } } } @@ -107,19 +173,32 @@ class AnsibleChecks { async execute() { this.contained(this.workspace); - const directory = fs.mkdtempSync(path.join(this.env.RUNNER_TEMP, "ansible-reports-")); + const directory = fs.mkdtempSync( + path.join(this.env.RUNNER_TEMP, "ansible-reports-"), + ); const reports = new CheckReports(directory); - this.accounts = fs.mkdtempSync(path.join(this.env.RUNNER_TEMP, "ansible-accounts-")); + this.accounts = fs.mkdtempSync( + path.join(this.env.RUNNER_TEMP, "ansible-accounts-"), + ); fs.chmodSync(this.accounts, 0o755); const uid = process.getuid(); const gid = process.getgid(); - fs.writeFileSync(path.join(this.accounts, "passwd"), `ansible-ci:x:${uid}:${gid}::/tmp:/bin/sh\n`, { mode: 0o644 }); - fs.writeFileSync(path.join(this.accounts, "group"), `ansible-ci:x:${gid}:\n`, { mode: 0o644 }); + fs.writeFileSync( + path.join(this.accounts, "passwd"), + `ansible-ci:x:${uid}:${gid}::/tmp:/bin/sh\n`, + { mode: 0o644 }, + ); + fs.writeFileSync( + path.join(this.accounts, "group"), + `ansible-ci:x:${gid}:\n`, + { mode: 0o644 }, + ); try { for (const playbook of this.playbooks) { this.core.info(`Checking playbook ${playbook}`); const args = ["--syntax-check"]; - if (this.inventory) args.push("--inventory", `/workspace/${this.inventory}`); + if (this.inventory) + args.push("--inventory", `/workspace/${this.inventory}`); args.push(`/workspace/${playbook}`); const result = this.container("ansible-playbook", args); reports.add("syntax", playbook, result); @@ -127,10 +206,17 @@ class AnsibleChecks { if (this.collections.length) { const python = this.container("python3", ["--version"]); const version = python.output.match(/^Python (\d+\.\d+)\./m)?.[1]; - if (python.code !== 0 || !version) throw new Error("Cannot identify the tooling image's Python version."); + if (python.code !== 0 || !version) + throw new Error( + "Cannot identify the tooling image's Python version.", + ); for (const collection of this.collections) { this.core.info(`Checking collection ${collection}`); - const result = this.container("ansible-test", ["sanity", "--python", version], collection); + const result = this.container( + "ansible-test", + ["sanity", "--python", version], + collection, + ); reports.add("sanity", collection, result); } } @@ -143,7 +229,10 @@ class AnsibleChecks { this.core.setOutput("report-files", files.join("\n")); this.core.setOutput("id", path.basename(directory)); } - if (reports.failed) throw new Error("Ansible checks failed. See the check reports and execution log."); + if (reports.failed) + throw new Error( + "Ansible checks failed. See the check reports and execution log.", + ); } } diff --git a/actions/ansible/test/reports.cjs b/actions/ansible/test/reports.cjs index 02a3d79..e8edf3e 100644 --- a/actions/ansible/test/reports.cjs +++ b/actions/ansible/test/reports.cjs @@ -18,36 +18,93 @@ class CheckReports { const origin = output.match(/^Origin: \/workspace\/(.+):(\d+):(\d+)\s*$/m); const filename = origin?.[1] || target; const relative = path.posix.normalize(filename); - const safe = !relative.startsWith("../") && !path.posix.isAbsolute(relative); + const safe = + !relative.startsWith("../") && !path.posix.isAbsolute(relative); this.issues.push({ ruleId: "ansible-syntax", level: "error", // Keep raw diagnostics in the execution log, not in public report artifacts. - message: { text: `Syntax check failed for ${target}. See the execution log.` }, - locations: [{ physicalLocation: { - artifactLocation: { uri: encodeURI(safe ? relative : target), uriBaseId: "%SRCROOT%" }, - region: { startLine: safe && origin ? Math.max(1, Number(origin[2])) : 1, startColumn: safe && origin ? Math.max(1, Number(origin[3])) : 1 }, - } }], + message: { + text: `Syntax check failed for ${target}. See the execution log.`, + }, + locations: [ + { + physicalLocation: { + artifactLocation: { + uri: encodeURI(safe ? relative : target), + uriBaseId: "%SRCROOT%", + }, + region: { + startLine: safe && origin ? Math.max(1, Number(origin[2])) : 1, + startColumn: safe && origin ? Math.max(1, Number(origin[3])) : 1, + }, + }, + }, + ], }); } escape(value) { - return value.replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f]/g, "").replace(/[&<>"']/g, (character) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[character]); + // biome-ignore lint/suspicious/noControlCharactersInRegex: XML 1.0 forbids these control characters in filenames. + const forbiddenControls = /[\u0000-\u0008\u000b\u000c\u000e-\u001f]/g; + return value + .replace(forbiddenControls, "") + .replace( + /[&<>"']/g, + (character) => + ({ + "&": "&", + "<": "<", + ">": ">", + '"': """, + "'": "'", + })[character], + ); } write() { const cases = this.checks.map(({ kind, target, code }) => { - const failure = code === 0 ? "" : ''; + const failure = + code === 0 + ? "" + : ''; return `${failure}`; }); const junit = path.join(this.directory, "checks.junit.xml"); - fs.writeFileSync(junit, `\n${cases.join("")}\n`); + fs.writeFileSync( + junit, + `\n${cases.join("")}\n`, + ); const sarif = path.join(this.directory, "syntax.sarif"); - fs.writeFileSync(sarif, `${JSON.stringify({ - $schema: "https://json.schemastore.org/sarif-2.1.0.json", - version: "2.1.0", - runs: [{ tool: { driver: { name: "ansible-playbook", rules: [{ id: "ansible-syntax", shortDescription: { text: "Ansible playbook syntax validation" } }] } }, results: this.issues }], - }, null, 2)}\n`); + fs.writeFileSync( + sarif, + `${JSON.stringify( + { + $schema: "https://json.schemastore.org/sarif-2.1.0.json", + version: "2.1.0", + runs: [ + { + tool: { + driver: { + name: "ansible-playbook", + rules: [ + { + id: "ansible-syntax", + shortDescription: { + text: "Ansible playbook syntax validation", + }, + }, + ], + }, + }, + results: this.issues, + }, + ], + }, + null, + 2, + )}\n`, + ); return [junit, sarif]; } } 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/tests/ansible.test.cjs b/tests/ansible.test.cjs index 3fd1865..0a48634 100644 --- a/tests/ansible.test.cjs +++ b/tests/ansible.test.cjs @@ -12,34 +12,87 @@ function fixture(t) { const temp = path.join(root, "temp"); fs.mkdirSync(workspace); fs.mkdirSync(temp); - for (const filename of ["first.yml", "second.yml", "inventory.yml"]) fs.writeFileSync(path.join(workspace, filename), "---\n"); + for (const filename of ["first.yml", "second.yml", "inventory.yml"]) + fs.writeFileSync(path.join(workspace, filename), "---\n"); const collection = "collections/ansible_collections/example/smoke"; fs.mkdirSync(path.join(workspace, collection), { recursive: true }); const outputs = {}; const failures = []; const calls = []; - const env = { GITHUB_WORKSPACE: workspace, RUNNER_TEMP: temp, ANSIBLE_TEST_IMAGE: "fixture:local", ANSIBLE_TEST_PLAYBOOKS: "first.yml\nsecond.yml", ANSIBLE_TEST_INVENTORY: "inventory.yml" }; - const core = { info() {}, setOutput: (key, value) => { outputs[key] = value; }, setFailed: (value) => failures.push(value) }; + const env = { + GITHUB_WORKSPACE: workspace, + RUNNER_TEMP: temp, + ANSIBLE_TEST_IMAGE: "fixture:local", + ANSIBLE_TEST_PLAYBOOKS: "first.yml\nsecond.yml", + ANSIBLE_TEST_INVENTORY: "inventory.yml", + }; + const core = { + info() {}, + setOutput: (key, value) => { + outputs[key] = value; + }, + setFailed: (value) => failures.push(value), + }; const success = { status: 0, stdout: "", stderr: "" }; - const run = (command, args) => { calls.push({ command, args }); return args.includes("--version") ? { ...success, stdout: "Python 3.14.1\n" } : success; }; - return { root, workspace, temp, collection, outputs, failures, calls, env, core, run, success }; + const run = (command, args) => { + calls.push({ command, args }); + return args.includes("--version") + ? { ...success, stdout: "Python 3.14.1\n" } + : success; + }; + return { + root, + workspace, + temp, + collection, + outputs, + failures, + calls, + env, + core, + run, + success, + }; } test("syntax and sanity checks use native commands and the image's Python with no consumer Makefile", async (t) => { const f = fixture(t); f.env.ANSIBLE_TEST_COLLECTIONS = f.collection; f.env.ANSIBLE_TEST_CACHE = ".cache/sanity"; - f.env.ANSIBLE_TEST_ENVIRONMENT = "ANSIBLE_COLLECTIONS_PATH=/opt/collections:/workspace/collections"; + f.env.ANSIBLE_TEST_ENVIRONMENT = + "ANSIBLE_COLLECTIONS_PATH=/opt/collections:/workspace/collections"; await entrypoint(f); assert.deepEqual(f.failures, []); assert.equal(f.calls.length, 4); - assert.ok(f.calls.every(({ command, args }) => command === "docker" && args.includes("--user") && !args.includes("make"))); + assert.ok( + f.calls.every( + ({ command, args }) => + command === "docker" && + args.includes("--user") && + !args.includes("make"), + ), + ); assert.ok(f.calls[0].args.includes("/workspace/inventory.yml")); - assert.deepEqual(f.calls[3].args.slice(-4), ["ansible-test", "sanity", "--python", "3.14"]); + assert.deepEqual(f.calls[3].args.slice(-4), [ + "ansible-test", + "sanity", + "--python", + "3.14", + ]); assert.ok(f.calls[3].args.includes(`/workspace/${f.collection}`)); - assert.ok(f.calls[3].args.includes(`type=bind,src=${f.workspace}/.cache/sanity,dst=/ansible-test-cache`)); - assert.match(fs.readFileSync(f.outputs["report-files"].split("\n")[0], "utf8"), /tests="3" failures="0"/); - assert.equal(fs.readdirSync(f.temp).some((name) => name.startsWith("ansible-accounts-")), false); + assert.ok( + f.calls[3].args.includes( + `type=bind,src=${f.workspace}/.cache/sanity,dst=/ansible-test-cache`, + ), + ); + assert.match( + fs.readFileSync(f.outputs["report-files"].split("\n")[0], "utf8"), + /tests="3" failures="0"/, + ); + assert.equal( + fs.readdirSync(f.temp).some((name) => name.startsWith("ansible-accounts-")), + false, + ); }); test("failures do not suppress subsequent checks and reports exclude raw output", async (t) => { @@ -47,15 +100,25 @@ test("failures do not suppress subsequent checks and reports exclude raw output" f.env.ANSIBLE_TEST_COLLECTIONS = f.collection; const run = (command, args) => { const result = f.run(command, args); - return args.includes("/workspace/first.yml") || args.includes("sanity") ? { status: 2, stdout: "[ERROR]: synthetic-private-output\nOrigin: /workspace/roles/task.yml:7:3\n", stderr: "" } : result; + return args.includes("/workspace/first.yml") || args.includes("sanity") + ? { + status: 2, + stdout: + "[ERROR]: synthetic-private-output\nOrigin: /workspace/roles/task.yml:7:3\n", + stderr: "", + } + : result; }; await entrypoint({ ...f, run }); assert.equal(f.calls.length, 4); assert.equal(f.failures.length, 1); - const [junit, sarif] = f.outputs["report-files"].split("\n").map((filename) => fs.readFileSync(filename, "utf8")); + const [junit, sarif] = f.outputs["report-files"] + .split("\n") + .map((filename) => fs.readFileSync(filename, "utf8")); assert.match(junit, /tests="3" failures="2"/); assert.equal(`${junit}${sarif}`.includes("synthetic-private-output"), false); - const location = JSON.parse(sarif).runs[0].results[0].locations[0].physicalLocation; + const location = + JSON.parse(sarif).runs[0].results[0].locations[0].physicalLocation; assert.equal(location.artifactLocation.uri, "roles/task.yml"); assert.deepEqual(location.region, { startLine: 7, startColumn: 3 }); }); @@ -63,19 +126,34 @@ test("failures do not suppress subsequent checks and reports exclude raw output" test("timeouts remove the named container and still publish failure results", async (t) => { const f = fixture(t); f.env.ANSIBLE_TEST_PLAYBOOKS = "first.yml"; - await entrypoint({ ...f, run: (command, args) => { - f.run(command, args); - return args[0] === "run" ? { status: null, error: new Error("timeout"), signal: "SIGTERM" } : f.success; - } }); + await entrypoint({ + ...f, + run: (command, args) => { + f.run(command, args); + return args[0] === "run" + ? { status: null, error: new Error("timeout"), signal: "SIGTERM" } + : f.success; + }, + }); assert.equal(f.failures.length, 1); const name = f.calls[0].args[f.calls[0].args.indexOf("--name") + 1]; assert.deepEqual(f.calls[1].args, ["rm", "--force", name]); - assert.match(fs.readFileSync(f.outputs["report-files"].split("\n")[0], "utf8"), /failures="1"/); + assert.match( + fs.readFileSync(f.outputs["report-files"].split("\n")[0], "utf8"), + /failures="1"/, + ); }); test("container cleanup failure is reported and cannot pass", async (t) => { const f = fixture(t); - await entrypoint({ ...f, run: () => ({ status: 1, error: new Error("failed"), stderr: "daemon unavailable" }) }); + await entrypoint({ + ...f, + run: () => ({ + status: 1, + error: new Error("failed"), + stderr: "daemon unavailable", + }), + }); assert.match(f.failures[0], /remove the interrupted/); assert.ok(f.outputs["report-files"]); }); @@ -112,5 +190,8 @@ test("reruns preserve earlier reports and escape XML filenames", async (t) => { const first = f.outputs["report-files"]; await entrypoint(f); assert.notEqual(first, f.outputs["report-files"]); - assert.match(fs.readFileSync(first.split("\n")[0], "utf8"), /a&"b.yml/); + assert.match( + fs.readFileSync(first.split("\n")[0], "utf8"), + /a&"b.yml/, + ); }); diff --git a/tests/fixture/ansible/Dockerfile b/tests/fixture/ansible/Dockerfile index 59c8514..b98ad5f 100644 --- a/tests/fixture/ansible/Dockerfile +++ b/tests/fixture/ansible/Dockerfile @@ -6,4 +6,5 @@ RUN apt-get update && apt-get install -y --no-install-recommends python3 python3 COPY requirements.txt /opt/requirements.txt RUN python3 -m venv /opt/ansible && /opt/ansible/bin/pip install --no-cache-dir -r /opt/requirements.txt ENV PATH="/opt/ansible/bin:${PATH}" +USER 1000:1000 HEALTHCHECK CMD ["ansible-playbook", "--version"] From d2673efa2d13247d9a7ccf9fc2b6876fbf2d3435 Mon Sep 17 00:00:00 2001 From: Emilien Escalle Date: Thu, 29 Oct 2026 16:55:56 +0100 Subject: [PATCH 03/10] fix(ansible): preserve failure reporting during interrupted checks --- Makefile | 2 +- actions/ansible/test/README.md | 45 +++++++------------------------- actions/ansible/test/action.yml | 9 +++++++ actions/ansible/test/checks.cjs | 40 ++++++++++++++-------------- actions/ansible/test/reports.cjs | 24 ++++++++--------- 5 files changed, 49 insertions(+), 71 deletions(-) diff --git a/Makefile b/Makefile index abcbbce..6c2df1a 100644 --- a/Makefile +++ b/Makefile @@ -32,7 +32,7 @@ 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 - # Ansible is not in the latest release; its metadata supplies a reviewed commit example. + # Ansible is not in the latest release; its metadata supplies a commit-pinned example. npm exec --yes --package=@ci-dokumentor/cli@0.5.0 -- ci-dokumentor generate --source actions/ansible/test/action.yml --repository git --exclude-sections usage ############################# diff --git a/actions/ansible/test/README.md b/actions/ansible/test/README.md index 285ae0c..33cfc07 100644 --- a/actions/ansible/test/README.md +++ b/actions/ansible/test/README.md @@ -25,6 +25,15 @@ The Linux runner needs Docker. The image must provide `/bin/sh`, `ansible-playbo `ansible-test` and `python3`, with the consumer's pinned dependencies installed. No Makefile or reporting script is required. The image's entrypoint is replaced. +```yaml +- name: Test Ansible + uses: hoverkraft-tech/ci-github-iac/actions/ansible/test@7bb6c22d7e9a9eded70627a001ea880a033e100d + with: + container-image: my-project-tooling:local + playbooks: ansible/site.yml + inventory: ansible/inventory.example.yml +``` + Supply newline-separated `playbooks`, `collections`, or both. Paths are explicit checkout-relative paths, without globs. Inventory is optional; when omitted, Ansible uses the image's configuration. Missing, empty and escaping paths fail. @@ -63,42 +72,6 @@ nor an upload can turn failed checks into success. Use a distinct report name for each invocation. Reports describe validation results, not code coverage. - - -## Usage - -```yaml -- uses: hoverkraft-tech/ci-github-iac/actions/ansible/test@70843368d5f5f7b0a2cec1e8435d81cc40beee64 # 0.1.0 - with: - # Built or pulled tooling image containing Ansible and the consumer's dependencies. - # This input is required. - container-image: "" - - # Newline-separated nonempty playbook files relative to the checkout. Supply playbooks or collections. - playbooks: "" - - # Optional inventory file relative to the checkout for syntax validation. - inventory: "" - - # Newline-separated first-party collection directories relative to the checkout for sanity validation. - collections: "" - - # Newline-separated ANSIBLE_* assignments using container paths. ANSIBLE_HOME and ANSIBLE_NOCOLOR are managed by the action. - environment: "" - - # Optional checkout-relative directory for ansible-test virtual environments; manage persistence with actions/cache. - cache-directory: "" - - # Stable summary and PR comment title, distinct for each test step. - # Default: `Ansible test results` - report-name: Ansible test results - - # Token for PR comments; requires pull-requests write. Fork and Dependabot comments are skipped. - # Default: `${{ github.token }}` - github-token: ${{ github.token }} -``` - - ## Inputs diff --git a/actions/ansible/test/action.yml b/actions/ansible/test/action.yml index 581c646..62a7f21 100644 --- a/actions/ansible/test/action.yml +++ b/actions/ansible/test/action.yml @@ -7,6 +7,15 @@ # `ansible-test` and `python3`, with the consumer's pinned dependencies installed. # No Makefile or reporting script is required. The image's entrypoint is replaced. # +# ```yaml +# - name: Test Ansible +# uses: hoverkraft-tech/ci-github-iac/actions/ansible/test@7bb6c22d7e9a9eded70627a001ea880a033e100d +# with: +# container-image: my-project-tooling:local +# playbooks: ansible/site.yml +# inventory: ansible/inventory.example.yml +# ``` +# # Supply newline-separated `playbooks`, `collections`, or both. Paths are explicit # checkout-relative paths, without globs. Inventory is optional; when omitted, # Ansible uses the image's configuration. Missing, empty and escaping paths fail. diff --git a/actions/ansible/test/checks.cjs b/actions/ansible/test/checks.cjs index 0acbafa..79753ae 100644 --- a/actions/ansible/test/checks.cjs +++ b/actions/ansible/test/checks.cjs @@ -146,29 +146,27 @@ class AnsibleChecks { timeout: 20 * 60 * 1000, maxBuffer: 8 * 1024 * 1024, }); - const output = `${result.stdout || ""}${result.stderr || ""}`; - this.core.info(output); - return { - code: result.error || result.signal ? 1 : result.status, - output, - }; - } finally { - // A killed Docker client can leave its container running after a timeout. - if (!result || result.error || result.signal) { - const cleanup = this.run("docker", ["rm", "--force", name], { - encoding: "utf8", - timeout: 30000, - }); - if ( - cleanup.status !== 0 && - !cleanup.stderr?.includes("No such container") - ) { - throw new Error( - "Failed to remove the interrupted Ansible test container.", - ); - } + } catch (error) { + result = { error }; + } + // A killed Docker client can leave its container running after a timeout. + if (result.error || result.signal) { + const cleanup = this.run("docker", ["rm", "--force", name], { + encoding: "utf8", + timeout: 30000, + }); + if ( + cleanup.status !== 0 && + !cleanup.stderr?.includes("No such container") + ) { + throw new Error( + "Failed to remove the interrupted Ansible test container.", + ); } } + const output = `${result.stdout || ""}${result.stderr || ""}`; + this.core.info(output); + return { code: result.error || result.signal ? 1 : result.status, output }; } async execute() { diff --git a/actions/ansible/test/reports.cjs b/actions/ansible/test/reports.cjs index e8edf3e..2151eed 100644 --- a/actions/ansible/test/reports.cjs +++ b/actions/ansible/test/reports.cjs @@ -47,19 +47,17 @@ class CheckReports { escape(value) { // biome-ignore lint/suspicious/noControlCharactersInRegex: XML 1.0 forbids these control characters in filenames. const forbiddenControls = /[\u0000-\u0008\u000b\u000c\u000e-\u001f]/g; - return value - .replace(forbiddenControls, "") - .replace( - /[&<>"']/g, - (character) => - ({ - "&": "&", - "<": "<", - ">": ">", - '"': """, - "'": "'", - })[character], - ); + return value.replace(forbiddenControls, "").replace( + /[&<>"']/g, + (character) => + ({ + "&": "&", + "<": "<", + ">": ">", + '"': """, + "'": "'", + })[character], + ); } write() { From 8e827b4251b95f5e2503dc1e10ab82d45ab88fe3 Mon Sep 17 00:00:00 2001 From: Emilien Escalle Date: Thu, 29 Oct 2026 16:56:38 +0100 Subject: [PATCH 04/10] docs(ansible): pin usage to validated action implementation --- actions/ansible/test/README.md | 2 +- actions/ansible/test/action.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/actions/ansible/test/README.md b/actions/ansible/test/README.md index 33cfc07..67bc9a0 100644 --- a/actions/ansible/test/README.md +++ b/actions/ansible/test/README.md @@ -27,7 +27,7 @@ No Makefile or reporting script is required. The image's entrypoint is replaced. ```yaml - name: Test Ansible - uses: hoverkraft-tech/ci-github-iac/actions/ansible/test@7bb6c22d7e9a9eded70627a001ea880a033e100d + uses: hoverkraft-tech/ci-github-iac/actions/ansible/test@d2673efa2d13247d9a7ccf9fc2b6876fbf2d3435 with: container-image: my-project-tooling:local playbooks: ansible/site.yml diff --git a/actions/ansible/test/action.yml b/actions/ansible/test/action.yml index 62a7f21..f1176ab 100644 --- a/actions/ansible/test/action.yml +++ b/actions/ansible/test/action.yml @@ -9,7 +9,7 @@ # # ```yaml # - name: Test Ansible -# uses: hoverkraft-tech/ci-github-iac/actions/ansible/test@7bb6c22d7e9a9eded70627a001ea880a033e100d +# uses: hoverkraft-tech/ci-github-iac/actions/ansible/test@d2673efa2d13247d9a7ccf9fc2b6876fbf2d3435 # with: # container-image: my-project-tooling:local # playbooks: ansible/site.yml From 316e7d124c1783020c275481a72d9fe9b24061c5 Mon Sep 17 00:00:00 2001 From: Emilien Escalle Date: Thu, 29 Oct 2026 17:22:23 +0100 Subject: [PATCH 05/10] refactor(ansible): compose maintained upstream test actions --- .github/dependabot.yml | 4 +- .github/workflows/__test-actions-ansible.yml | 57 +++-- CONTRIBUTING.md | 14 +- Makefile | 2 +- README.md | 8 +- actions/ansible/test/action.yml | 180 +++++++------ actions/ansible/test/checks.cjs | 237 ------------------ actions/ansible/test/index.cjs | 11 +- actions/ansible/test/inputs.cjs | 117 +++++++++ actions/ansible/test/reports.cjs | 152 ++++------- tests/ansible.test.cjs | 233 +++++++---------- tests/fixture/ansible/Dockerfile | 10 - .../example/smoke/README.md | 3 + .../example/smoke/galaxy.yml | 10 + 14 files changed, 425 insertions(+), 613 deletions(-) delete mode 100644 actions/ansible/test/checks.cjs create mode 100644 actions/ansible/test/inputs.cjs delete mode 100644 tests/fixture/ansible/Dockerfile create mode 100644 tests/fixture/ansible/ansible_collections/example/smoke/README.md create mode 100644 tests/fixture/ansible/ansible_collections/example/smoke/galaxy.yml diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 5b2fe64..65f1de4 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -7,9 +7,7 @@ updates: schedule: interval: weekly - package-ecosystem: docker - directories: - - / - - /tests/fixture/ansible + directory: / cooldown: default-days: 7 schedule: diff --git a/.github/workflows/__test-actions-ansible.yml b/.github/workflows/__test-actions-ansible.yml index 2261cd8..8b0e85f 100644 --- a/.github/workflows/__test-actions-ansible.yml +++ b/.github/workflows/__test-actions-ansible.yml @@ -13,57 +13,62 @@ jobs: strategy: fail-fast: false matrix: - scenario: [success, syntax-failure] + mode: [syntax, sanity] + result: [success, failure] steps: - name: Checkout fixture uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - - name: Build Ansible fixture image - run: docker build --tag ansible-fixture:local tests/fixture/ansible - name: Prepare selected scenario without a consumer Makefile env: - SCENARIO: ${{ matrix.scenario }} + MODE: ${{ matrix.mode }} + RESULT: ${{ matrix.result }} run: | rm Makefile cp tests/fixture/ansible/valid.yml tests/fixture/ansible/selected.yml - if [ "$SCENARIO" = syntax-failure ]; then - printf '%s\n' '---' '- hosts: all' ' tasks:' ' - unknown_fixture_module: {}' > tests/fixture/ansible/selected.yml + if [ "$RESULT" = failure ]; then + if [ "$MODE" = syntax ]; then + printf '%s\n' '---' '- hosts: all' ' tasks:' ' - unknown_fixture_module: {}' > tests/fixture/ansible/selected.yml + else + printf '%s\n' '---' 'invalid: [' > tests/fixture/ansible/ansible_collections/example/smoke/roles/smoke/tasks/main.yml + fi fi - - name: Test playbooks + - name: Test Ansible with upstream actions id: test continue-on-error: true uses: $/actions/ansible/test with: - container-image: ansible-fixture:local - playbooks: | - tests/fixture/ansible/selected.yml - tests/fixture/ansible/valid.yml - collections: tests/fixture/ansible/ansible_collections/example/smoke - report-name: Ansible tests - ${{ matrix.scenario }} - - name: Verify check outcomes and reporting + ansible-requirements: tests/fixture/ansible/requirements.txt + playbooks: ${{ matrix.mode == 'syntax' && 'tests/fixture/ansible/selected.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: - SCENARIO: ${{ matrix.scenario }} + MODE: ${{ matrix.mode }} + EXPECTED: ${{ matrix.result }} OUTCOME: ${{ steps.test.outcome }} REPORTS: ${{ steps.test.outputs.report-files }} PARSED: ${{ steps.test.outputs.parsed-files }} - SUMMARY: ${{ steps.test.outputs.summary }} 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'); - const failed = process.env.SCENARIO === 'syntax-failure'; - assert.equal(process.env.OUTCOME, failed ? 'failure' : 'success'); - const files = process.env.REPORTS.split('\n'); - assert.equal(files.length, 2); + 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(process.env.SUMMARY); - const junit = fs.readFileSync(files.find(file => file.endsWith('.xml')), 'utf8'); - assert.match(junit, /tests="3"/); - assert.ok(junit.includes(`failures="${failed ? 1 : 0}"`)); - const sarif = JSON.parse(fs.readFileSync(files.find(file => file.endsWith('.sarif')), 'utf8')); - assert.equal(sarif.runs[0].results.length, failed ? 1 : 0); + if (process.env.MODE === 'syntax') { + assert.equal(files.length, 1); + const sarif = JSON.parse(fs.readFileSync(files[0], 'utf8')); + const failures = sarif.runs.flatMap(run => run.results).filter(result => result.level === 'error'); + assert.equal(failures.length > 0, process.env.EXPECTED === 'failure'); + } else { + assert.ok(files.every(file => file.endsWith('.xml'))); + const reports = files.map(file => fs.readFileSync(file, 'utf8')).join('\n'); + assert.equal(/ line.trim()) - .filter(Boolean), - ), - ]; - } - - contained(target) { - const relative = path.relative(this.workspace, target); - if ( - relative === ".." || - relative.startsWith(`..${path.sep}`) || - path.isAbsolute(relative) || - /[,\r\n]/.test(target) - ) { - throw new Error( - "Paths must stay inside the checkout and cannot contain commas or newlines.", - ); - } - } - - relativePath(value, directory) { - const target = fs.realpathSync(path.resolve(this.workspace, value)); - this.contained(target); - const stat = fs.statSync(target); - if (directory ? !stat.isDirectory() : !stat.isFile() || stat.size === 0) { - throw new Error( - `${value} must be a ${directory ? "directory" : "nonempty file"}.`, - ); - } - return path.relative(this.workspace, target); - } - - paths(value, directory) { - return this.lines(value).map((entry) => - this.relativePath(entry, directory), - ); - } - - container(command, args = [], directory = "") { - const name = `ansible-test-${randomUUID()}`; - const options = [ - "run", - "--rm", - "--name", - name, - "--user", - `${process.getuid()}:${process.getgid()}`, - "--mount", - `type=bind,src=${this.workspace},dst=/workspace`, - "--mount", - `type=bind,src=${this.accounts}/passwd,dst=/etc/passwd,readonly`, - "--mount", - `type=bind,src=${this.accounts}/group,dst=/etc/group,readonly`, - "--workdir", - path.posix.join("/workspace", directory), - "--env", - "HOME=/tmp", - "--env", - "XDG_CACHE_HOME=/tmp/.cache", - "--env", - "ANSIBLE_HOME=/tmp/.ansible", - "--env", - "ANSIBLE_NOCOLOR=1", - ]; - for (const setting of this.environment) options.push("--env", setting); - if (this.cache) - options.push( - "--mount", - `type=bind,src=${this.cache},dst=/ansible-test-cache`, - ); - options.push( - "--entrypoint", - "/bin/sh", - this.image, - "-ec", - 'mkdir -p "$ANSIBLE_HOME/test"; if [ -d /ansible-test-cache ]; then ln -s /ansible-test-cache "$ANSIBLE_HOME/test/venv"; fi; exec "$@"', - "ansible-test", - command, - ...args, - ); - let result; - try { - result = this.run("docker", options, { - encoding: "utf8", - timeout: 20 * 60 * 1000, - maxBuffer: 8 * 1024 * 1024, - }); - } catch (error) { - result = { error }; - } - // A killed Docker client can leave its container running after a timeout. - if (result.error || result.signal) { - const cleanup = this.run("docker", ["rm", "--force", name], { - encoding: "utf8", - timeout: 30000, - }); - if ( - cleanup.status !== 0 && - !cleanup.stderr?.includes("No such container") - ) { - throw new Error( - "Failed to remove the interrupted Ansible test container.", - ); - } - } - const output = `${result.stdout || ""}${result.stderr || ""}`; - this.core.info(output); - return { code: result.error || result.signal ? 1 : result.status, output }; - } - - async execute() { - this.contained(this.workspace); - const directory = fs.mkdtempSync( - path.join(this.env.RUNNER_TEMP, "ansible-reports-"), - ); - const reports = new CheckReports(directory); - this.accounts = fs.mkdtempSync( - path.join(this.env.RUNNER_TEMP, "ansible-accounts-"), - ); - fs.chmodSync(this.accounts, 0o755); - const uid = process.getuid(); - const gid = process.getgid(); - fs.writeFileSync( - path.join(this.accounts, "passwd"), - `ansible-ci:x:${uid}:${gid}::/tmp:/bin/sh\n`, - { mode: 0o644 }, - ); - fs.writeFileSync( - path.join(this.accounts, "group"), - `ansible-ci:x:${gid}:\n`, - { mode: 0o644 }, - ); - try { - for (const playbook of this.playbooks) { - this.core.info(`Checking playbook ${playbook}`); - const args = ["--syntax-check"]; - if (this.inventory) - args.push("--inventory", `/workspace/${this.inventory}`); - args.push(`/workspace/${playbook}`); - const result = this.container("ansible-playbook", args); - reports.add("syntax", playbook, result); - } - if (this.collections.length) { - const python = this.container("python3", ["--version"]); - const version = python.output.match(/^Python (\d+\.\d+)\./m)?.[1]; - if (python.code !== 0 || !version) - throw new Error( - "Cannot identify the tooling image's Python version.", - ); - for (const collection of this.collections) { - this.core.info(`Checking collection ${collection}`); - const result = this.container( - "ansible-test", - ["sanity", "--python", version], - collection, - ); - reports.add("sanity", collection, result); - } - } - } catch (error) { - reports.add("runtime", "Ansible test execution", { code: 1, output: "" }); - throw error; - } finally { - fs.rmSync(this.accounts, { recursive: true, force: true }); - const files = reports.write(); - this.core.setOutput("report-files", files.join("\n")); - this.core.setOutput("id", path.basename(directory)); - } - if (reports.failed) - throw new Error( - "Ansible checks failed. See the check reports and execution log.", - ); - } -} - -module.exports = { AnsibleChecks }; diff --git a/actions/ansible/test/index.cjs b/actions/ansible/test/index.cjs index 2c706b4..0d85b05 100644 --- a/actions/ansible/test/index.cjs +++ b/actions/ansible/test/index.cjs @@ -1,9 +1,12 @@ -const { AnsibleChecks } = require("./checks.cjs"); +const { AnsibleInputs } = require("./inputs.cjs"); +const { AnsibleReports } = require("./reports.cjs"); -module.exports = async ({ core, env = process.env, run }) => { +module.exports = ({ core, env = process.env }) => { try { - const checks = new AnsibleChecks({ core, env, run }); - await checks.execute(); + 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..4d69db5 --- /dev/null +++ b/actions/ansible/test/inputs.cjs @@ -0,0 +1,117 @@ +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 playbooks = [ + ...new Set( + (this.env.ANSIBLE_PLAYBOOKS || "") + .split(/\r?\n/) + .map((value) => value.trim()) + .filter(Boolean), + ), + ]; + const collection = this.env.ANSIBLE_COLLECTION?.trim(); + if (Boolean(playbooks.length) === Boolean(collection)) { + throw new Error( + "Provide playbooks 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.", + ); + const files = playbooks.map((value) => this.checkoutPath(value)); + 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-"), + ); + 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" : "syntax"); + this.core.setOutput("collection", collectionRoot); + this.core.setOutput( + "collection-root", + collectionRoot ? path.relative(this.workspace, collectionRoot) : "", + ); + const lintRef = fs + .readFileSync(path.join(__dirname, "action.yml"), "utf8") + .match(/uses: ansible\/ansible-lint@([a-f0-9]{40})\b/)?.[1]; + if (!lintRef) + throw new Error("The Ansible Lint action must have an immutable pin."); + this.core.setOutput("ansible-lint-ref", lintRef); + this.core.setOutput("report-directory", reports); + this.core.setOutput("id", path.basename(reports)); + this.core.setOutput( + "lint-args", + [ + "--profile min", + `--sarif-file '${path.join(reports, "syntax.sarif")}'`, + ...files.map((file) => `'${file}'`), + ].join(" "), + ); + } +} + +module.exports = { AnsibleInputs }; diff --git a/actions/ansible/test/reports.cjs b/actions/ansible/test/reports.cjs index 2151eed..6cf5f4f 100644 --- a/actions/ansible/test/reports.cjs +++ b/actions/ansible/test/reports.cjs @@ -1,110 +1,58 @@ const fs = require("node:fs"); const path = require("node:path"); -class CheckReports { - constructor(directory) { - this.directory = directory; - this.checks = []; - this.issues = []; +class AnsibleReports { + constructor(core, env) { + this.core = core; + this.env = env; } - get failed() { - return this.checks.some((check) => check.code !== 0); - } - - add(kind, target, { code, output }) { - this.checks.push({ kind, target, code }); - if (kind !== "syntax" || code === 0) return; - const origin = output.match(/^Origin: \/workspace\/(.+):(\d+):(\d+)\s*$/m); - const filename = origin?.[1] || target; - const relative = path.posix.normalize(filename); - const safe = - !relative.startsWith("../") && !path.posix.isAbsolute(relative); - this.issues.push({ - ruleId: "ansible-syntax", - level: "error", - // Keep raw diagnostics in the execution log, not in public report artifacts. - message: { - text: `Syntax check failed for ${target}. See the execution log.`, - }, - locations: [ - { - physicalLocation: { - artifactLocation: { - uri: encodeURI(safe ? relative : target), - uriBaseId: "%SRCROOT%", - }, - region: { - startLine: safe && origin ? Math.max(1, Number(origin[2])) : 1, - startColumn: safe && origin ? Math.max(1, Number(origin[3])) : 1, - }, - }, - }, - ], - }); - } - - escape(value) { - // biome-ignore lint/suspicious/noControlCharactersInRegex: XML 1.0 forbids these control characters in filenames. - const forbiddenControls = /[\u0000-\u0008\u000b\u000c\u000e-\u001f]/g; - return value.replace(forbiddenControls, "").replace( - /[&<>"']/g, - (character) => - ({ - "&": "&", - "<": "<", - ">": ">", - '"': """, - "'": "'", - })[character], - ); - } - - write() { - const cases = this.checks.map(({ kind, target, code }) => { - const failure = - code === 0 - ? "" - : ''; - return `${failure}`; - }); - const junit = path.join(this.directory, "checks.junit.xml"); - fs.writeFileSync( - junit, - `\n${cases.join("")}\n`, - ); - const sarif = path.join(this.directory, "syntax.sarif"); - fs.writeFileSync( - sarif, - `${JSON.stringify( - { - $schema: "https://json.schemastore.org/sarif-2.1.0.json", - version: "2.1.0", - runs: [ - { - tool: { - driver: { - name: "ansible-playbook", - rules: [ - { - id: "ansible-syntax", - shortDescription: { - text: "Ansible playbook syntax validation", - }, - }, - ], - }, - }, - results: this.issues, - }, - ], - }, - null, - 2, - )}\n`, - ); - return [junit, sarif]; + 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 { + const sarif = path.join(destination, "syntax.sarif"); + if (fs.existsSync(sarif) && fs.lstatSync(sarif).isFile()) + files.push(sarif); + } + 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 = { CheckReports }; +module.exports = { AnsibleReports }; diff --git a/tests/ansible.test.cjs b/tests/ansible.test.cjs index 0a48634..fa853ca 100644 --- a/tests/ansible.test.cjs +++ b/tests/ansible.test.cjs @@ -6,192 +6,129 @@ 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-check-test-")); + 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); - for (const filename of ["first.yml", "second.yml", "inventory.yml"]) - fs.writeFileSync(path.join(workspace, filename), "---\n"); - const collection = "collections/ansible_collections/example/smoke"; - fs.mkdirSync(path.join(workspace, collection), { recursive: true }); + 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 calls = []; const env = { GITHUB_WORKSPACE: workspace, RUNNER_TEMP: temp, - ANSIBLE_TEST_IMAGE: "fixture:local", - ANSIBLE_TEST_PLAYBOOKS: "first.yml\nsecond.yml", - ANSIBLE_TEST_INVENTORY: "inventory.yml", + ANSIBLE_PHASE: "prepare", + ANSIBLE_REQUIREMENTS: "requirements.txt", + ANSIBLE_PLAYBOOKS: "playbook.yml", }; const core = { - info() {}, setOutput: (key, value) => { outputs[key] = value; }, setFailed: (value) => failures.push(value), }; - const success = { status: 0, stdout: "", stderr: "" }; - const run = (command, args) => { - calls.push({ command, args }); - return args.includes("--version") - ? { ...success, stdout: "Python 3.14.1\n" } - : success; - }; - return { - root, - workspace, - temp, - collection, - outputs, - failures, - calls, - env, - core, - run, - success, - }; + return { root, workspace, temp, outputs, failures, env, core }; } -test("syntax and sanity checks use native commands and the image's Python with no consumer Makefile", async (t) => { +test("syntax inputs keep the consumer's exact Ansible pin and native SARIF location", (t) => { const f = fixture(t); - f.env.ANSIBLE_TEST_COLLECTIONS = f.collection; - f.env.ANSIBLE_TEST_CACHE = ".cache/sanity"; - f.env.ANSIBLE_TEST_ENVIRONMENT = - "ANSIBLE_COLLECTIONS_PATH=/opt/collections:/workspace/collections"; - await entrypoint(f); + entrypoint(f); assert.deepEqual(f.failures, []); - assert.equal(f.calls.length, 4); - assert.ok( - f.calls.every( - ({ command, args }) => - command === "docker" && - args.includes("--user") && - !args.includes("make"), - ), - ); - assert.ok(f.calls[0].args.includes("/workspace/inventory.yml")); - assert.deepEqual(f.calls[3].args.slice(-4), [ - "ansible-test", - "sanity", - "--python", - "3.14", - ]); - assert.ok(f.calls[3].args.includes(`/workspace/${f.collection}`)); - assert.ok( - f.calls[3].args.includes( - `type=bind,src=${f.workspace}/.cache/sanity,dst=/ansible-test-cache`, - ), - ); - assert.match( - fs.readFileSync(f.outputs["report-files"].split("\n")[0], "utf8"), - /tests="3" failures="0"/, - ); + assert.equal(f.outputs.mode, "syntax"); + assert.equal(f.outputs["ansible-core-ref"], "v2.99.1"); assert.equal( - fs.readdirSync(f.temp).some((name) => name.startsWith("ansible-accounts-")), - false, + fs.readFileSync(f.outputs.constraint, "utf8"), + "ansible-core==2.99.1\n", ); + assert.ok(f.outputs["lint-args"].includes("--profile min --sarif-file")); + assert.ok(f.outputs["lint-args"].endsWith(`'${f.workspace}/playbook.yml'`)); + assert.equal(path.dirname(f.outputs["report-directory"]), f.temp); }); -test("failures do not suppress subsequent checks and reports exclude raw output", async (t) => { +test("collection inputs select a single checked-out source with Galaxy metadata", (t) => { const f = fixture(t); - f.env.ANSIBLE_TEST_COLLECTIONS = f.collection; - const run = (command, args) => { - const result = f.run(command, args); - return args.includes("/workspace/first.yml") || args.includes("sanity") - ? { - status: 2, - stdout: - "[ERROR]: synthetic-private-output\nOrigin: /workspace/roles/task.yml:7:3\n", - stderr: "", - } - : result; - }; - await entrypoint({ ...f, run }); - assert.equal(f.calls.length, 4); - assert.equal(f.failures.length, 1); - const [junit, sarif] = f.outputs["report-files"] - .split("\n") - .map((filename) => fs.readFileSync(filename, "utf8")); - assert.match(junit, /tests="3" failures="2"/); - assert.equal(`${junit}${sarif}`.includes("synthetic-private-output"), false); - const location = - JSON.parse(sarif).runs[0].results[0].locations[0].physicalLocation; - assert.equal(location.artifactLocation.uri, "roles/task.yml"); - assert.deepEqual(location.region, { startLine: 7, startColumn: 3 }); -}); - -test("timeouts remove the named container and still publish failure results", async (t) => { - const f = fixture(t); - f.env.ANSIBLE_TEST_PLAYBOOKS = "first.yml"; - await entrypoint({ - ...f, - run: (command, args) => { - f.run(command, args); - return args[0] === "run" - ? { status: null, error: new Error("timeout"), signal: "SIGTERM" } - : f.success; - }, - }); - assert.equal(f.failures.length, 1); - const name = f.calls[0].args[f.calls[0].args.indexOf("--name") + 1]; - assert.deepEqual(f.calls[1].args, ["rm", "--force", name]); - assert.match( - fs.readFileSync(f.outputs["report-files"].split("\n")[0], "utf8"), - /failures="1"/, + 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", ); -}); - -test("container cleanup failure is reported and cannot pass", async (t) => { - const f = fixture(t); - await entrypoint({ + entrypoint({ ...f, - run: () => ({ - status: 1, - error: new Error("failed"), - stderr: "daemon unavailable", - }), + env: { ...f.env, ANSIBLE_PLAYBOOKS: "", ANSIBLE_COLLECTION: collection }, }); - assert.match(f.failures[0], /remove the interrupted/); - assert.ok(f.outputs["report-files"]); + assert.deepEqual(f.failures, []); + assert.equal(f.outputs.mode, "sanity"); + assert.equal(f.outputs["collection-root"], collection); }); -test("missing selections, files, directories, invalid settings and path escapes fail before Docker", async (t) => { +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, "empty.yml"), ""); - const invalid = [ - { ANSIBLE_TEST_PLAYBOOKS: "" }, - { ANSIBLE_TEST_PLAYBOOKS: "missing.yml" }, - { ANSIBLE_TEST_PLAYBOOKS: "empty.yml" }, - { ANSIBLE_TEST_PLAYBOOKS: "../temp" }, - { ANSIBLE_TEST_COLLECTIONS: "." }, - { ANSIBLE_TEST_CACHE: "outside/new/cache" }, - { ANSIBLE_TEST_IMAGE: "--privileged" }, - { ANSIBLE_TEST_ENVIRONMENT: "HOME=/root" }, - { ANSIBLE_TEST_ENVIRONMENT: "ANSIBLE_HOME=/elsewhere" }, - ]; - for (const inputs of invalid) { + fs.writeFileSync( + path.join(f.workspace, "floating.txt"), + "ansible-core>=2.99\n", + ); + for (const inputs of [ + { ANSIBLE_PLAYBOOKS: "" }, + { ANSIBLE_COLLECTION: "." }, + { ANSIBLE_PLAYBOOKS: "missing.yml" }, + { ANSIBLE_PLAYBOOKS: "outside" }, + { ANSIBLE_PLAYBOOKS: "../temp" }, + { ANSIBLE_REQUIREMENTS: "floating.txt" }, + ]) { f.failures.length = 0; - await entrypoint({ ...f, env: { ...f.env, ...inputs } }); + entrypoint({ ...f, env: { ...f.env, ...inputs } }); assert.equal(f.failures.length, 1, JSON.stringify(inputs)); } - assert.equal(f.calls.length, 0); - assert.equal(fs.existsSync(path.join(f.temp, "new")), false); }); -test("reruns preserve earlier reports and escape XML filenames", async (t) => { +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); - fs.writeFileSync(path.join(f.workspace, 'a&"b.yml'), "---\n"); - f.env.ANSIBLE_TEST_PLAYBOOKS = 'a&"b.yml'; - await entrypoint(f); - const first = f.outputs["report-files"]; - await entrypoint(f); - assert.notEqual(first, f.outputs["report-files"]); - assert.match( - fs.readFileSync(first.split("\n")[0], "utf8"), - /a&"b.yml/, + 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); }); diff --git a/tests/fixture/ansible/Dockerfile b/tests/fixture/ansible/Dockerfile deleted file mode 100644 index b98ad5f..0000000 --- a/tests/fixture/ansible/Dockerfile +++ /dev/null @@ -1,10 +0,0 @@ -FROM debian:trixie-slim@sha256:f6b08b09a05725f9431502d3b5714f5c4576e82c1a84fa1debe0c24a7e0c3ec4 - -# hadolint ignore=DL3008 -RUN apt-get update && apt-get install -y --no-install-recommends python3 python3-venv git && \ - rm -rf /var/lib/apt/lists/* -COPY requirements.txt /opt/requirements.txt -RUN python3 -m venv /opt/ansible && /opt/ansible/bin/pip install --no-cache-dir -r /opt/requirements.txt -ENV PATH="/opt/ansible/bin:${PATH}" -USER 1000:1000 -HEALTHCHECK CMD ["ansible-playbook", "--version"] 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 From 8c0133170818c92808c59e856dc4e6a17217d19e Mon Sep 17 00:00:00 2001 From: Emilien Escalle Date: Thu, 29 Oct 2026 17:25:09 +0100 Subject: [PATCH 06/10] docs(ansible): describe upstream action composition --- actions/ansible/test/README.md | 123 ++++++++++++++++----------------- 1 file changed, 58 insertions(+), 65 deletions(-) diff --git a/actions/ansible/test/README.md b/actions/ansible/test/README.md index 67bc9a0..94de621 100644 --- a/actions/ansible/test/README.md +++ b/actions/ansible/test/README.md @@ -14,78 +14,71 @@ ## Overview -Validate playbook syntax and collection sanity, publish reports and annotate pull requests. +Compose official Ansible syntax and sanity actions with Hoverkraft reports, PR comments and artifacts. -Validate Ansible playbooks and first-party collections in a consumer tooling image. +Validate Ansible by composing maintained upstream actions and Hoverkraft reporting. ### Run checks -Check out the consumer and build or pull its tooling image before this action. -The Linux runner needs Docker. The image must provide `/bin/sh`, `ansible-playbook`, -`ansible-test` and `python3`, with the consumer's pinned dependencies installed. -No Makefile or reporting script is required. The image's entrypoint is replaced. - -```yaml -- name: Test Ansible - uses: hoverkraft-tech/ci-github-iac/actions/ansible/test@d2673efa2d13247d9a7ccf9fc2b6876fbf2d3435 - with: - container-image: my-project-tooling:local - playbooks: ansible/site.yml - inventory: ansible/inventory.example.yml -``` - -Supply newline-separated `playbooks`, `collections`, or both. Paths are explicit -checkout-relative paths, without globs. Inventory is optional; when omitted, -Ansible uses the image's configuration. Missing, empty and escaping paths fail. -Collection roots must follow `ansible_collections/namespace/name` layout. - -Each playbook runs with `ansible-playbook --syntax-check`. Each collection runs -with `ansible-test sanity --python` using the image's Python version. Checks -continue after a nonzero exit so all selected targets receive a result. Each -command has a 20-minute timeout. Interrupted containers are forcibly removed. -These checks do not apply playbooks or measure code coverage; integration and -deployment scenarios remain in the consumer. - -The checkout is mounted at `/workspace` and commands start there (sanity starts -in its collection directory). Containers run with the runner's UID/GID and a -temporary home. Use `environment` for Ansible configuration, for example -`ANSIBLE_COLLECTIONS_PATH=/opt/ansible/collections:/workspace/ansible/collections`. -Image environment defaults remain available. Runner secrets are not forwarded. - -`cache-directory` optionally mounts a checkout-local directory for ansible-test -virtual environments. Restore/save it with `actions/cache`, keyed by the tooling -runtime. The action does not cache source files or reports. Sanity's normal -`tests/output/` files remain in each collection and are not uploaded. +Check out the consumer on a fresh Linux GitHub-hosted runner. Provide the existing +requirements file containing an exact `ansible-core==X.Y.Z` pin and either +newline-separated playbook files or one collection directory. Use a workflow +matrix for multiple collections. No consumer Make target or tooling image is needed. + +Playbook validation uses [Ansible Lint](https://github.com/ansible/ansible-lint) +with its `min` profile: content loading, parsing and native syntax checks. It +produces SARIF directly. A uv constraint keeps Ansible Core at the consumer's +pinned version. `galaxy-requirements` installs the consumer's role/collection +dependencies; `collections-path` adds first-party collections to the search path. + +Collection validation uses the maintained Ansible community +[ansible-test action](https://github.com/ansible-community/ansible-test-gh-action). +It owns Python setup, the test container and native sanity/JUnit generation. +Its Ansible Core Git tag is derived from the same requirements pin. Coverage is +disabled for sanity checks. Source stays in the consumer's checked-out collection. +The upstream actions select their supported Python runtime; these checks do not +reuse the consumer's deployment image or apply playbooks. Keep integration and +deployment scenarios in the consumer. + +Paths must stay inside the checkout and use letters, digits, dots, underscores, +hyphens and slashes. Explicit files are required; expand playbook globs in the +workflow if needed. 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 -The action writes fresh JUnit status results for every executed check and SARIF -locations for syntax failures under `runner.temp`. Raw command output remains -in execution logs; report artifacts contain status and file locations only. -Hoverkraft's parser publishes summaries and annotations. PR comments use a -stable `report-name` and require `pull-requests: write`; forks and Dependabot -skip comments. Never use `pull_request_target` to execute untrusted tests. +Native SARIF and JUnit files are parsed with Hoverkraft's `parse-ci-reports`, +published as summaries and annotations, and attached to a stable PR comment. +Native reports preserve upstream diagnostics; use synthetic data suitable for +the repository's visibility. The collection action also publishes its own summary. -Completed reports are uploaded with unique artifact names and seven-day -retention, including after checks or report publication fail. Neither a parser -nor an upload can turn failed checks into success. Use a distinct report name -for each invocation. Reports describe validation results, not code coverage. +Report collection runs after upstream failures, including when the upstream +action does not export its report output. Available files are copied to a fresh +directory under `runner.temp` and uploaded with a unique artifact name and +seven-day retention, including after parsing or commenting errors. Missing +reports fail the action. Failed checks remain failures. No custom test runner, +Docker lifecycle or JUnit/SARIF generator is maintained here. + +Grant `pull-requests: write` for comments. Fork and Dependabot comments are skipped. +Never run untrusted checks through `pull_request_target`. Use distinct report +names for matrix entries. Syntax and sanity results are not code coverage. ## Inputs -| **Input** | **Description** | **Required** | **Default** | -| --------------------- | -------------------------------------------------------------------------------------------------------------------------- | ------------ | ---------------------- | -| **`container-image`** | Built or pulled tooling image containing Ansible and the consumer's dependencies. | **true** | - | -| **`playbooks`** | Newline-separated nonempty playbook files relative to the checkout. Supply playbooks or collections. | **false** | - | -| **`inventory`** | Optional inventory file relative to the checkout for syntax validation. | **false** | - | -| **`collections`** | Newline-separated first-party collection directories relative to the checkout for sanity validation. | **false** | - | -| **`environment`** | Newline-separated ANSIBLE_* assignments using container paths. ANSIBLE_HOME and ANSIBLE_NOCOLOR are managed by the action. | **false** | - | -| **`cache-directory`** | Optional checkout-relative directory for ansible-test virtual environments; manage persistence with actions/cache. | **false** | - | -| **`report-name`** | Stable summary and PR comment title, distinct for each test step. | **false** | `Ansible test results` | -| **`github-token`** | Token for PR comments; requires pull-requests write. Fork and Dependabot comments are skipped. | **false** | `${{ github.token }}` | +| **Input** | **Description** | **Required** | **Default** | +| -------------------------- | ------------------------------------------------------------------------------------------------------- | ------------ | ---------------------- | +| **`ansible-requirements`** | Checkout-relative requirements file containing exactly one stable ansible-core==X.Y.Z pin. | **true** | - | +| **`playbooks`** | Newline-separated playbook files relative to the checkout. Supply playbooks or collection, exclusively. | **false** | - | +| **`collection`** | One checked-out collection directory containing galaxy.yml. Use a matrix for multiple collections. | **false** | - | +| **`inventory`** | Optional checkout-relative inventory file for syntax validation. | **false** | - | +| **`ansible-config`** | Optional checkout-relative ansible.cfg file for syntax validation. | **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 }}` | @@ -94,13 +87,13 @@ for each invocation. Reports describe validation results, not code coverage. ## Outputs -| **Output** | **Description** | -| ------------------ | ------------------------------------------------------------------------------------------------ | -| **`report-files`** | Newline-separated absolute paths of completed JUnit and SARIF reports, also 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. | +| **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. | From 6020d664c7714af9bb76b3702d865d45ad5bcea1 Mon Sep 17 00:00:00 2001 From: Emilien Escalle Date: Thu, 29 Oct 2026 17:34:11 +0100 Subject: [PATCH 07/10] fix(ansible): use upstream revision with pinned nested actions --- actions/ansible/test/README.md | 2 ++ actions/ansible/test/action.yml | 4 +++- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/actions/ansible/test/README.md b/actions/ansible/test/README.md index 94de621..5692887 100644 --- a/actions/ansible/test/README.md +++ b/actions/ansible/test/README.md @@ -34,6 +34,8 @@ dependencies; `collections-path` adds first-party collections to the search path Collection validation uses the maintained Ansible community [ansible-test action](https://github.com/ansible-community/ansible-test-gh-action). It owns Python setup, the test container and native sanity/JUnit generation. +The selected upstream revision pins its nested actions to full commit SHAs, +matching consumers that enforce transitive action pinning. Its Ansible Core Git tag is derived from the same requirements pin. Coverage is disabled for sanity checks. Source stays in the consumer's checked-out collection. The upstream actions select their supported Python runtime; these checks do not diff --git a/actions/ansible/test/action.yml b/actions/ansible/test/action.yml index 090663a..36641f3 100644 --- a/actions/ansible/test/action.yml +++ b/actions/ansible/test/action.yml @@ -16,6 +16,8 @@ # Collection validation uses the maintained Ansible community # [ansible-test action](https://github.com/ansible-community/ansible-test-gh-action). # It owns Python setup, the test container and native sanity/JUnit generation. +# The selected upstream revision pins its nested actions to full commit SHAs, +# matching consumers that enforce transitive action pinning. # Its Ansible Core Git tag is derived from the same requirements pin. Coverage is # disabled for sanity checks. Source stays in the consumer's checked-out collection. # The upstream actions select their supported Python runtime; these checks do not @@ -131,7 +133,7 @@ runs: - name: Validate collection sanity with ansible-test id: sanity if: steps.inputs.outputs.mode == 'sanity' - uses: ansible-community/ansible-test-gh-action@6ded727edb598e670ac056a8e680ca4050bac0a3 # v1.18.0 + uses: ansible-community/ansible-test-gh-action@dc5c5bfa02fa4b6633e6175e65e9f8f53088180a # unstable/v1 with: testing-type: sanity ansible-core-version: ${{ steps.inputs.outputs.ansible-core-ref }} From 8ed3c99f0b2dba75f9854c2646a7c2b3447c7472 Mon Sep 17 00:00:00 2001 From: Emilien Escalle Date: Thu, 29 Oct 2026 17:46:31 +0100 Subject: [PATCH 08/10] fix(ansible): support consumer Python runtime constraints --- actions/ansible/test/README.md | 27 +++++++++++++++------------ actions/ansible/test/action.yml | 9 ++++++++- actions/ansible/test/inputs.cjs | 7 +++++++ tests/ansible.test.cjs | 15 ++++++++++++++- 4 files changed, 44 insertions(+), 14 deletions(-) diff --git a/actions/ansible/test/README.md b/actions/ansible/test/README.md index 5692887..71badb9 100644 --- a/actions/ansible/test/README.md +++ b/actions/ansible/test/README.md @@ -38,7 +38,9 @@ The selected upstream revision pins its nested actions to full commit SHAs, matching consumers that enforce transitive action pinning. Its Ansible Core Git tag is derived from the same requirements pin. Coverage is disabled for sanity checks. Source stays in the consumer's checked-out collection. -The upstream actions select their supported Python runtime; these checks do not +Sanity checks cover all supported Python versions by default. Set +`target-python-version` when the consumer supports a specific Python runtime. +The upstream actions select their execution runtime; these checks do not reuse the consumer's deployment image or apply playbooks. Keep integration and deployment scenarios in the consumer. @@ -70,17 +72,18 @@ names for matrix entries. Syntax and sanity results are not code coverage. ## Inputs -| **Input** | **Description** | **Required** | **Default** | -| -------------------------- | ------------------------------------------------------------------------------------------------------- | ------------ | ---------------------- | -| **`ansible-requirements`** | Checkout-relative requirements file containing exactly one stable ansible-core==X.Y.Z pin. | **true** | - | -| **`playbooks`** | Newline-separated playbook files relative to the checkout. Supply playbooks or collection, exclusively. | **false** | - | -| **`collection`** | One checked-out collection directory containing galaxy.yml. Use a matrix for multiple collections. | **false** | - | -| **`inventory`** | Optional checkout-relative inventory file for syntax validation. | **false** | - | -| **`ansible-config`** | Optional checkout-relative ansible.cfg file for syntax validation. | **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 }}` | +| **Input** | **Description** | **Required** | **Default** | +| --------------------------- | ------------------------------------------------------------------------------------------------------------- | ------------ | ---------------------- | +| **`ansible-requirements`** | Checkout-relative requirements file containing exactly one stable ansible-core==X.Y.Z pin. | **true** | - | +| **`playbooks`** | Newline-separated playbook files relative to the checkout. Supply playbooks 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 syntax validation. | **false** | - | +| **`ansible-config`** | Optional checkout-relative ansible.cfg file for syntax validation. | **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 }}` | diff --git a/actions/ansible/test/action.yml b/actions/ansible/test/action.yml index 36641f3..315c556 100644 --- a/actions/ansible/test/action.yml +++ b/actions/ansible/test/action.yml @@ -20,7 +20,9 @@ # matching consumers that enforce transitive action pinning. # Its Ansible Core Git tag is derived from the same requirements pin. Coverage is # disabled for sanity checks. Source stays in the consumer's checked-out collection. -# The upstream actions select their supported Python runtime; these checks do not +# Sanity checks cover all supported Python versions by default. Set +# `target-python-version` when the consumer supports a specific Python runtime. +# The upstream actions select their execution runtime; these checks do not # reuse the consumer's deployment image or apply playbooks. Keep integration and # deployment scenarios in the consumer. # @@ -63,6 +65,9 @@ inputs: 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 syntax validation. default: "" @@ -109,6 +114,7 @@ runs: ANSIBLE_REQUIREMENTS: ${{ inputs.ansible-requirements }} ANSIBLE_PLAYBOOKS: ${{ inputs.playbooks }} 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 }} @@ -139,6 +145,7 @@ runs: 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 diff --git a/actions/ansible/test/inputs.cjs b/actions/ansible/test/inputs.cjs index 4d69db5..5d4f817 100644 --- a/actions/ansible/test/inputs.cjs +++ b/actions/ansible/test/inputs.cjs @@ -40,6 +40,13 @@ class AnsibleInputs { ), ]; 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(playbooks.length) === Boolean(collection)) { throw new Error( "Provide playbooks or one collection, exclusively. Use a matrix for multiple collections.", diff --git a/tests/ansible.test.cjs b/tests/ansible.test.cjs index fa853ca..001aeb2 100644 --- a/tests/ansible.test.cjs +++ b/tests/ansible.test.cjs @@ -42,6 +42,7 @@ test("syntax inputs keep the consumer's exact Ansible pin and native SARIF locat entrypoint(f); assert.deepEqual(f.failures, []); assert.equal(f.outputs.mode, "syntax"); + 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"), @@ -62,11 +63,17 @@ test("collection inputs select a single checked-out source with Galaxy metadata" ); entrypoint({ ...f, - env: { ...f.env, ANSIBLE_PLAYBOOKS: "", ANSIBLE_COLLECTION: collection }, + env: { + ...f.env, + ANSIBLE_PLAYBOOKS: "", + 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) => { @@ -83,6 +90,12 @@ test("ambiguous selections, missing targets, non-pinned versions and escaping pa { ANSIBLE_PLAYBOOKS: "outside" }, { ANSIBLE_PLAYBOOKS: "../temp" }, { ANSIBLE_REQUIREMENTS: "floating.txt" }, + { ANSIBLE_TARGET_PYTHON_VERSION: "3.99" }, + { + ANSIBLE_PLAYBOOKS: "", + ANSIBLE_COLLECTION: ".", + ANSIBLE_TARGET_PYTHON_VERSION: "3.99; false", + }, ]) { f.failures.length = 0; entrypoint({ ...f, env: { ...f.env, ...inputs } }); From 00d7ffa3c60b79a58995625223580e4b25a0e3f8 Mon Sep 17 00:00:00 2001 From: Emilien Escalle Date: Thu, 29 Oct 2026 17:56:02 +0100 Subject: [PATCH 09/10] refactor(ansible): separate test execution from linting --- .github/workflows/__test-actions-ansible.yml | 28 ++--- CONTRIBUTING.md | 10 +- README.md | 6 +- actions/ansible/test/README.md | 105 +++++++++--------- actions/ansible/test/action.yml | 108 ++++++++++--------- actions/ansible/test/inputs.cjs | 35 ++---- actions/ansible/test/reports.cjs | 9 +- tests/ansible.test.cjs | 42 ++++++-- tests/fixture/ansible/valid.yml | 13 ++- 9 files changed, 180 insertions(+), 176 deletions(-) diff --git a/.github/workflows/__test-actions-ansible.yml b/.github/workflows/__test-actions-ansible.yml index 8b0e85f..4798836 100644 --- a/.github/workflows/__test-actions-ansible.yml +++ b/.github/workflows/__test-actions-ansible.yml @@ -13,7 +13,7 @@ jobs: strategy: fail-fast: false matrix: - mode: [syntax, sanity] + mode: [playbook, sanity] result: [success, failure] steps: - name: Checkout fixture @@ -26,21 +26,18 @@ jobs: RESULT: ${{ matrix.result }} run: | rm Makefile - cp tests/fixture/ansible/valid.yml tests/fixture/ansible/selected.yml - if [ "$RESULT" = failure ]; then - if [ "$MODE" = syntax ]; then - printf '%s\n' '---' '- hosts: all' ' tasks:' ' - unknown_fixture_module: {}' > tests/fixture/ansible/selected.yml - else - printf '%s\n' '---' 'invalid: [' > tests/fixture/ansible/ansible_collections/example/smoke/roles/smoke/tasks/main.yml - fi + 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 - playbooks: ${{ matrix.mode == 'syntax' && 'tests/fixture/ansible/selected.yml' || '' }} + 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 @@ -62,13 +59,6 @@ jobs: assert.ok(files.length > 0); assert.deepEqual(JSON.parse(process.env.PARSED).sort(), files.sort()); assert.ok(process.env.ARTIFACT); - if (process.env.MODE === 'syntax') { - assert.equal(files.length, 1); - const sarif = JSON.parse(fs.readFileSync(files[0], 'utf8')); - const failures = sarif.runs.flatMap(run => run.results).filter(result => result.level === 'error'); - assert.equal(failures.length > 0, process.env.EXPECTED === 'failure'); - } else { - assert.ok(files.every(file => file.endsWith('.xml'))); - const reports = files.map(file => fs.readFileSync(file, 'utf8')).join('\n'); - assert.equal(/ 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/CONTRIBUTING.md b/CONTRIBUTING.md index 38801a2..72a5e23 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -58,14 +58,14 @@ 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 syntax and collection sanity success/failure -scenarios through the official Ansible Lint and Ansible community testing actions. +`__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, -Python setup and containers. Consumers supply their existing Ansible Core pin, +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. The lint installation ref is derived -from its `uses` pin so nested action setup stays on the same revision. +pins and the fixture's requirements file. Linting remains in the separate linter workflow. ## Releases and dependencies diff --git a/README.md b/README.md index fd97b09..8d6b781 100644 --- a/README.md +++ b/README.md @@ -28,8 +28,8 @@ can run several test steps against the same guest. #### - [Test Ansible](actions/ansible/test/README.md) -Validate playbook syntax and first-party collection sanity through the official -Ansible actions. Publish check summaries, annotations, PR comments and report artifacts. +Execute test playbooks and first-party collection sanity through maintained +Ansible actions. Publish test summaries, annotations, PR comments and report artifacts. ### Virtual machines @@ -52,7 +52,7 @@ cleanup remains visible and preserves the runtime for a retry. ## Getting started For Ansible, check out your repository on a Linux runner. Pass its pinned -Ansible requirements file and playbook or collection paths to +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 diff --git a/actions/ansible/test/README.md b/actions/ansible/test/README.md index 71badb9..80185a8 100644 --- a/actions/ansible/test/README.md +++ b/actions/ansible/test/README.md @@ -14,76 +14,73 @@ ## Overview -Compose official Ansible syntax and sanity actions with Hoverkraft reports, PR comments and artifacts. - -Validate Ansible by composing maintained upstream actions and Hoverkraft reporting. - -### Run checks - -Check out the consumer on a fresh Linux GitHub-hosted runner. Provide the existing -requirements file containing an exact `ansible-core==X.Y.Z` pin and either -newline-separated playbook files or one collection directory. Use a workflow -matrix for multiple collections. No consumer Make target or tooling image is needed. - -Playbook validation uses [Ansible Lint](https://github.com/ansible/ansible-lint) -with its `min` profile: content loading, parsing and native syntax checks. It -produces SARIF directly. A uv constraint keeps Ansible Core at the consumer's -pinned version. `galaxy-requirements` installs the consumer's role/collection -dependencies; `collections-path` adds first-party collections to the search path. - -Collection validation uses the maintained Ansible community -[ansible-test action](https://github.com/ansible-community/ansible-test-gh-action). -It owns Python setup, the test container and native sanity/JUnit generation. -The selected upstream revision pins its nested actions to full commit SHAs, -matching consumers that enforce transitive action pinning. -Its Ansible Core Git tag is derived from the same requirements pin. Coverage is -disabled for sanity checks. Source stays in the consumer's checked-out collection. -Sanity checks cover all supported Python versions by default. Set -`target-python-version` when the consumer supports a specific Python runtime. -The upstream actions select their execution runtime; these checks do not -reuse the consumer's deployment image or apply playbooks. Keep integration and -deployment scenarios in the consumer. +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. Explicit files are required; expand playbook globs in the -workflow if needed. Each collection needs `galaxy.yml`. Use a fresh checkout per +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 -Native SARIF and JUnit files are parsed with Hoverkraft's `parse-ci-reports`, -published as summaries and annotations, and attached to a stable PR comment. -Native reports preserve upstream diagnostics; use synthetic data suitable for -the repository's visibility. The collection action also publishes its own summary. +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. Available files are copied to a fresh -directory under `runner.temp` and uploaded with a unique artifact name and -seven-day retention, including after parsing or commenting errors. Missing -reports fail the action. Failed checks remain failures. No custom test runner, -Docker lifecycle or JUnit/SARIF generator is maintained here. +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 checks through `pull_request_target`. Use distinct report -names for matrix entries. Syntax and sanity results are not code coverage. +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** | - | -| **`playbooks`** | Newline-separated playbook files relative to the checkout. Supply playbooks 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 syntax validation. | **false** | - | -| **`ansible-config`** | Optional checkout-relative ansible.cfg file for syntax validation. | **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 }}` | +| **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 }}` | diff --git a/actions/ansible/test/action.yml b/actions/ansible/test/action.yml index 315c556..71ba775 100644 --- a/actions/ansible/test/action.yml +++ b/actions/ansible/test/action.yml @@ -1,56 +1,53 @@ -# Validate Ansible by composing maintained upstream actions and Hoverkraft reporting. +# Run Ansible tests with maintained upstream actions and native JUnit reports. # -# ### Run checks +# ### Run tests # -# Check out the consumer on a fresh Linux GitHub-hosted runner. Provide the existing -# requirements file containing an exact `ansible-core==X.Y.Z` pin and either -# newline-separated playbook files or one collection directory. Use a workflow -# matrix for multiple collections. No consumer Make target or tooling image is needed. +# 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. # -# Playbook validation uses [Ansible Lint](https://github.com/ansible/ansible-lint) -# with its `min` profile: content loading, parsing and native syntax checks. It -# produces SARIF directly. A uv constraint keeps Ansible Core at the consumer's -# pinned version. `galaxy-requirements` installs the consumer's role/collection -# dependencies; `collections-path` adds first-party collections to the search path. +# 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 validation uses the maintained Ansible community -# [ansible-test action](https://github.com/ansible-community/ansible-test-gh-action). -# It owns Python setup, the test container and native sanity/JUnit generation. -# The selected upstream revision pins its nested actions to full commit SHAs, -# matching consumers that enforce transitive action pinning. -# Its Ansible Core Git tag is derived from the same requirements pin. Coverage is -# disabled for sanity checks. Source stays in the consumer's checked-out collection. -# Sanity checks cover all supported Python versions by default. Set -# `target-python-version` when the consumer supports a specific Python runtime. -# The upstream actions select their execution runtime; these checks do not -# reuse the consumer's deployment image or apply playbooks. Keep integration and -# deployment scenarios in the consumer. +# 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. Explicit files are required; expand playbook globs in the -# workflow if needed. Each collection needs `galaxy.yml`. Use a fresh checkout per +# 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 # -# Native SARIF and JUnit files are parsed with Hoverkraft's `parse-ci-reports`, -# published as summaries and annotations, and attached to a stable PR comment. -# Native reports preserve upstream diagnostics; use synthetic data suitable for -# the repository's visibility. The collection action also publishes its own summary. +# 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. Available files are copied to a fresh -# directory under `runner.temp` and uploaded with a unique artifact name and -# seven-day retention, including after parsing or commenting errors. Missing -# reports fail the action. Failed checks remain failures. No custom test runner, -# Docker lifecycle or JUnit/SARIF generator is maintained here. +# 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 checks through `pull_request_target`. Use distinct report -# names for matrix entries. Syntax and sanity results are not code coverage. +# Never run untrusted tests through `pull_request_target`. Use distinct report names +# for matrix entries. --- name: Ansible - Test -description: Compose official Ansible syntax and sanity actions with Hoverkraft reports, PR comments and artifacts. +description: Run Ansible test playbooks and collection sanity with native reports, PR comments and artifacts. author: Hoverkraft branding: icon: check-circle @@ -59,8 +56,8 @@ inputs: ansible-requirements: description: Checkout-relative requirements file containing exactly one stable ansible-core==X.Y.Z pin. required: true - playbooks: - description: Newline-separated playbook files relative to the checkout. Supply playbooks or collection, exclusively. + 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. @@ -69,10 +66,10 @@ inputs: 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 syntax validation. + description: Optional checkout-relative inventory file for test playbook execution. default: "" ansible-config: - description: Optional checkout-relative ansible.cfg file for syntax validation. + 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. @@ -112,7 +109,7 @@ runs: ANSIBLE_ACTION_PATH: ${{ github.action_path }} ANSIBLE_PHASE: prepare ANSIBLE_REQUIREMENTS: ${{ inputs.ansible-requirements }} - ANSIBLE_PLAYBOOKS: ${{ inputs.playbooks }} + ANSIBLE_TEST_PLAYBOOK: ${{ inputs.test-playbook }} ANSIBLE_COLLECTION: ${{ inputs.collection }} ANSIBLE_TARGET_PYTHON_VERSION: ${{ inputs.target-python-version }} ANSIBLE_INVENTORY_FILE: ${{ inputs.inventory }} @@ -122,19 +119,30 @@ runs: with: script: require(`${process.env.ANSIBLE_ACTION_PATH}/index.cjs`)({ core }); - - name: Validate playbook syntax with Ansible Lint - if: steps.inputs.outputs.mode == 'syntax' - uses: ansible/ansible-lint@9bce2bfb8f4a984e6b13767bc0d6bf0a49492164 # v26.10.1 + - 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: - UV_CONSTRAINT: ${{ steps.inputs.outputs.constraint }} 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: - # Required by upstream when nested in a composite action. - override_version: ${{ steps.inputs.outputs.ansible-lint-ref }} - args: ${{ steps.inputs.outputs.lint-args }} - requirements_file: ${{ steps.inputs.outputs.galaxy }} + playbook: ${{ steps.inputs.outputs.test-playbook }} + requirements: ${{ steps.inputs.outputs.galaxy }} - name: Validate collection sanity with ansible-test id: sanity diff --git a/actions/ansible/test/inputs.cjs b/actions/ansible/test/inputs.cjs index 5d4f817..42bae39 100644 --- a/actions/ansible/test/inputs.cjs +++ b/actions/ansible/test/inputs.cjs @@ -31,14 +31,7 @@ class AnsibleInputs { } prepare() { - const playbooks = [ - ...new Set( - (this.env.ANSIBLE_PLAYBOOKS || "") - .split(/\r?\n/) - .map((value) => value.trim()) - .filter(Boolean), - ), - ]; + 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))) { @@ -47,9 +40,9 @@ class AnsibleInputs { ); } this.core.setOutput("target-python-version", targetPython); - if (Boolean(playbooks.length) === Boolean(collection)) { + if (Boolean(playbook) === Boolean(collection)) { throw new Error( - "Provide playbooks or one collection, exclusively. Use a matrix for multiple collections.", + "Provide test-playbook or one collection, exclusively. Use a matrix for multiple collections.", ); } const requirements = this.checkoutPath(this.env.ANSIBLE_REQUIREMENTS); @@ -62,7 +55,10 @@ class AnsibleInputs { throw new Error( "ansible-requirements must contain one exact stable ansible-core==X.Y.Z pin.", ); - const files = playbooks.map((value) => this.checkoutPath(value)); + this.core.setOutput( + "test-playbook", + playbook ? this.checkoutPath(playbook) : "", + ); const collectionRoot = collection ? this.checkoutPath(collection, true) : ""; @@ -92,32 +88,19 @@ class AnsibleInputs { 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" : "syntax"); + this.core.setOutput("mode", collection ? "sanity" : "playbook"); this.core.setOutput("collection", collectionRoot); this.core.setOutput( "collection-root", collectionRoot ? path.relative(this.workspace, collectionRoot) : "", ); - const lintRef = fs - .readFileSync(path.join(__dirname, "action.yml"), "utf8") - .match(/uses: ansible\/ansible-lint@([a-f0-9]{40})\b/)?.[1]; - if (!lintRef) - throw new Error("The Ansible Lint action must have an immutable pin."); - this.core.setOutput("ansible-lint-ref", lintRef); this.core.setOutput("report-directory", reports); this.core.setOutput("id", path.basename(reports)); - this.core.setOutput( - "lint-args", - [ - "--profile min", - `--sarif-file '${path.join(reports, "syntax.sarif")}'`, - ...files.map((file) => `'${file}'`), - ].join(" "), - ); } } diff --git a/actions/ansible/test/reports.cjs b/actions/ansible/test/reports.cjs index 6cf5f4f..a040177 100644 --- a/actions/ansible/test/reports.cjs +++ b/actions/ansible/test/reports.cjs @@ -43,9 +43,12 @@ class AnsibleReports { } } } else { - const sarif = path.join(destination, "syntax.sarif"); - if (fs.existsSync(sarif) && fs.lstatSync(sarif).isFile()) - files.push(sarif); + 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( diff --git a/tests/ansible.test.cjs b/tests/ansible.test.cjs index 001aeb2..fb98a47 100644 --- a/tests/ansible.test.cjs +++ b/tests/ansible.test.cjs @@ -26,9 +26,10 @@ function fixture(t) { RUNNER_TEMP: temp, ANSIBLE_PHASE: "prepare", ANSIBLE_REQUIREMENTS: "requirements.txt", - ANSIBLE_PLAYBOOKS: "playbook.yml", + ANSIBLE_TEST_PLAYBOOK: "playbook.yml", }; const core = { + addPath: () => {}, setOutput: (key, value) => { outputs[key] = value; }, @@ -37,19 +38,18 @@ function fixture(t) { return { root, workspace, temp, outputs, failures, env, core }; } -test("syntax inputs keep the consumer's exact Ansible pin and native SARIF location", (t) => { +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, "syntax"); + 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.ok(f.outputs["lint-args"].includes("--profile min --sarif-file")); - assert.ok(f.outputs["lint-args"].endsWith(`'${f.workspace}/playbook.yml'`)); + assert.equal(f.outputs["test-playbook"], `${f.workspace}/playbook.yml`); assert.equal(path.dirname(f.outputs["report-directory"]), f.temp); }); @@ -65,7 +65,7 @@ test("collection inputs select a single checked-out source with Galaxy metadata" ...f, env: { ...f.env, - ANSIBLE_PLAYBOOKS: "", + ANSIBLE_TEST_PLAYBOOK: "", ANSIBLE_COLLECTION: collection, ANSIBLE_TARGET_PYTHON_VERSION: "3.99", }, @@ -84,15 +84,15 @@ test("ambiguous selections, missing targets, non-pinned versions and escaping pa "ansible-core>=2.99\n", ); for (const inputs of [ - { ANSIBLE_PLAYBOOKS: "" }, + { ANSIBLE_TEST_PLAYBOOK: "" }, { ANSIBLE_COLLECTION: "." }, - { ANSIBLE_PLAYBOOKS: "missing.yml" }, - { ANSIBLE_PLAYBOOKS: "outside" }, - { ANSIBLE_PLAYBOOKS: "../temp" }, + { ANSIBLE_TEST_PLAYBOOK: "missing.yml" }, + { ANSIBLE_TEST_PLAYBOOK: "outside" }, + { ANSIBLE_TEST_PLAYBOOK: "../temp" }, { ANSIBLE_REQUIREMENTS: "floating.txt" }, { ANSIBLE_TARGET_PYTHON_VERSION: "3.99" }, { - ANSIBLE_PLAYBOOKS: "", + ANSIBLE_TEST_PLAYBOOK: "", ANSIBLE_COLLECTION: ".", ANSIBLE_TARGET_PYTHON_VERSION: "3.99; false", }, @@ -145,3 +145,23 @@ test("missing reports and redirected report directories cannot be reported as su 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/valid.yml b/tests/fixture/ansible/valid.yml index ee1bf46..79716fe 100644 --- a/tests/fixture/ansible/valid.yml +++ b/tests/fixture/ansible/valid.yml @@ -1,8 +1,11 @@ --- -- name: Validate syntax without applying a playbook - hosts: all +- name: Exercise a synthetic Ansible test assertion + hosts: localhost + connection: local gather_facts: false tasks: - - name: Prevent accidental execution of the syntax fixture - ansible.builtin.fail: - msg: This playbook must only be syntax checked. + - name: Assert the selected fixture succeeds + ansible.builtin.assert: + that: fixture_result == 'success' + vars: + fixture_result: "{{ lookup('ansible.builtin.env', 'FIXTURE_RESULT') }}" From c3a4d4b48514dc17dbe00873e3e32ede05159d39 Mon Sep 17 00:00:00 2001 From: Emilien Escalle Date: Thu, 29 Oct 2026 18:06:45 +0100 Subject: [PATCH 10/10] docs: remove duplicate action links from documentation index --- docs/README.md | 5 ----- 1 file changed, 5 deletions(-) diff --git a/docs/README.md b/docs/README.md index ff54ae0..ee45859 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,9 +1,4 @@ # Documentation -- [Validate Ansible playbooks and collections](../actions/ansible/test/README.md) - -- [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)