Skip to content

Puppet Module Compatibility Runner #84

Puppet Module Compatibility Runner

Puppet Module Compatibility Runner #84

name: Puppet Module Compatibility Runner
on:
schedule:
- cron: "0 2 * * *" # Nightly at 02:00 UTC
workflow_dispatch:
inputs:
profile:
description: "Compatibility profile name"
required: false
default: "8-latest-maintained"
metadata_mode:
description: "Metadata handling mode (warn|fail)"
required: false
default: "warn"
enable_debug:
description: "Enable verbose debug output (captures Docker logs, acceptance output)"
required: false
default: 'false'
type: choice
options:
- 'false'
- 'true'
lean:
description: "Lean run: test only changed/not-green/stale modules (false = full run of all modules)"
required: false
default: 'true'
type: choice
options:
- 'true'
- 'false'
modules_json:
description: "Optional JSON array override: [{\"repo\":\"...\",\"ref\":\"main\",\"os\":\"windows-latest\"}]"
required: false
default: ""
permissions:
contents: read
# Serialize runs on the same ref so concurrent ledger commits cannot race.
concurrency:
group: compatibility-runner-${{ github.ref }}
cancel-in-progress: false
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
jobs:
prepare:
runs-on: ubuntu-latest
outputs:
unit_matrix: ${{ steps.matrix.outputs.unit_matrix }}
acceptance_matrix: ${{ steps.matrix.outputs.acceptance_matrix }}
has_acceptance: ${{ steps.matrix.outputs.has_acceptance }}
steps:
- name: Checkout
uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version: '3.11'
- name: Validate modules config schema
shell: bash
run: |
set -euo pipefail
python -m pip install --disable-pip-version-check jsonschema
python scripts/validate_modules_config.py --config config/modules.json --schema config/modules.schema.json
- name: Detect changes (lean matrix)
id: detect
if: github.event.inputs.modules_json == ''
shell: bash
run: |
set -euo pipefail
mkdir -p .tmp
python scripts/detect_changes.py
env:
MODULES_FILE: config/modules.json
LEDGER_FILE: status/ledger.json
OUTPUT_FILE: .tmp/change-decisions.json
WINDOW_HOURS: ${{ vars.PUPPET_CHANGE_WINDOW_HOURS || '48' }}
STALE_DAYS: ${{ vars.PUPPET_STALE_DAYS || '30' }}
EVENT_NAME: ${{ github.event_name }}
LEAN: ${{ github.event.inputs.lean || 'true' }}
GITHUB_TOKEN: ${{ github.token }}
- name: Build module matrix
id: matrix
shell: bash
run: |
set -euo pipefail
OVERRIDE='${{ github.event.inputs.modules_json }}'
RUN_ALL='${{ steps.detect.outputs.run_all }}'
INCLUDE_IDS='${{ steps.detect.outputs.include_ids }}'
# No detection ran (modules_json override) -> include everything.
if [ -z "$RUN_ALL" ]; then RUN_ALL='true'; fi
RUN_ALL="$RUN_ALL" INCLUDE_IDS="$INCLUDE_IDS" ruby scripts/build_matrix.rb "$OVERRIDE" > .tmp-matrix.json
UNIT_MATRIX=$(python -c "import json; print(json.dumps(json.load(open('.tmp-matrix.json', encoding='utf-8'))['unit_matrix']))")
ACCEPTANCE_MATRIX=$(python -c "import json; print(json.dumps(json.load(open('.tmp-matrix.json', encoding='utf-8'))['acceptance_matrix']))")
HAS_ACCEPTANCE=$(python -c "import json; print(json.load(open('.tmp-matrix.json', encoding='utf-8'))['has_acceptance'])")
echo "unit_matrix=$UNIT_MATRIX" >> "$GITHUB_OUTPUT"
echo "acceptance_matrix=$ACCEPTANCE_MATRIX" >> "$GITHUB_OUTPUT"
echo "has_acceptance=$HAS_ACCEPTANCE" >> "$GITHUB_OUTPUT"
- name: Upload change decisions
if: always()
uses: actions/upload-artifact@v7
with:
name: change-decisions
path: .tmp/change-decisions.json
if-no-files-found: ignore
test_unit:
name: test / ${{ matrix.module.id }} / unit
runs-on: ${{ matrix.module.os }}
timeout-minutes: ${{ fromJSON(vars.PUPPET_JOB_TIMEOUT_MINUTES || '360') }}
needs: prepare
strategy:
fail-fast: false
matrix:
module: ${{ fromJson(needs.prepare.outputs.unit_matrix) }}
env:
PUPPET_CORE_API_KEY: ${{ secrets.PUPPET_CORE_API_KEY }}
PUPPET_CORE_SOURCE_URL: https://rubygems-puppetcore.puppet.com
PUPPET_COMPAT_METADATA_MODE: ${{ github.event.inputs.metadata_mode || 'warn' }}
PUPPET_COMPAT_BUNDLE_PATH: .b
PUPPET_ENFORCE_PRIVATE_SOURCE: "true"
PUPPET_ENFORCE_NO_OPENVOX: "false"
PUPPET_ENFORCE_EXACT_PUPPET_VERSION: "true"
PUPPET_SPLIT_SOURCES: "true"
PUPPET_STAGE_TIMEOUT_SECONDS: ${{ vars.PUPPET_STAGE_TIMEOUT_SECONDS || '1800' }}
PUPPET_ACCEPTANCE_DEBUG: ${{ github.event.inputs.enable_debug || 'false' }}
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Run module compatibility test
uses: ./.github/actions/run-module-test
with:
module-json: ${{ toJson(matrix.module) }}
module-id: ${{ matrix.module.id }}
profile: ${{ github.event.inputs.profile || '8-latest-maintained' }}
metadata-mode: ${{ github.event.inputs.metadata_mode || 'warn' }}
enable-debug: ${{ github.event.inputs.enable_debug || 'false' }}
test-mode: unit
output-dir: o/${{ matrix.module.id }}
artifact-name: compatibility-${{ matrix.module.id }}-unit
test-lane: unit
acceptance-target: unit
prereqs-json: ${{ toJson(matrix.module.prereqs) }}
- name: Upload module artifacts (unit)
if: always()
uses: actions/upload-artifact@v7
with:
name: compatibility-${{ matrix.module.id }}-unit
path: o/${{ matrix.module.id }}
if-no-files-found: warn
test_acceptance:
name: test / ${{ matrix.module.id }} / acceptance / ${{ matrix.module.target_id }}
runs-on: ${{ matrix.module.os }}
timeout-minutes: ${{ fromJSON(vars.PUPPET_JOB_TIMEOUT_MINUTES || '360') }}
needs: prepare
if: needs.prepare.outputs.has_acceptance == 'true'
strategy:
fail-fast: false
matrix:
module: ${{ fromJson(needs.prepare.outputs.acceptance_matrix) }}
env:
PUPPET_CORE_API_KEY: ${{ secrets.PUPPET_CORE_API_KEY }}
PUPPET_CORE_SOURCE_URL: https://rubygems-puppetcore.puppet.com
PUPPET_COMPAT_METADATA_MODE: ${{ github.event.inputs.metadata_mode || 'warn' }}
PUPPET_COMPAT_BUNDLE_PATH: .b
PUPPET_ENFORCE_PRIVATE_SOURCE: "true"
PUPPET_ENFORCE_NO_OPENVOX: "false"
PUPPET_ENFORCE_EXACT_PUPPET_VERSION: "true"
PUPPET_SPLIT_SOURCES: "true"
PUPPET_STAGE_TIMEOUT_SECONDS: ${{ vars.PUPPET_STAGE_TIMEOUT_SECONDS || '1800' }}
PUPPET_ACCEPTANCE_DEBUG: ${{ github.event.inputs.enable_debug || 'false' }}
steps:
- name: Checkout (for Beaker host seeding)
uses: actions/checkout@v6
- name: Seed Beaker host mappings
if: runner.os == 'Linux'
shell: bash
run: |
set -euo pipefail
mkdir -p .tmp
setfile="config/beaker/setfiles/${{ matrix.module.setfile }}.yml"
echo "Reading host entries from $setfile"
ruby -ryaml -e "
data = YAML.safe_load(File.read(ARGV[0]), permitted_classes: [Symbol])
hosts = data.fetch('HOSTS', {})
hosts.each do |name, cfg|
next unless cfg.is_a?(Hash)
ip = cfg['ip'].to_s.strip
next if ip.empty?
puts \"#{ip} #{name}\"
end
" "$setfile" > .tmp/beaker-host-mappings.txt
if [ ! -s .tmp/beaker-host-mappings.txt ]; then
echo "No explicit host mappings found in setfile"
exit 0
fi
while read -r ip host; do
[ -n "$ip" ] || continue
[ -n "$host" ] || continue
if getent hosts "$host" > /dev/null; then
echo "Host already resolvable: $host"
else
echo "Adding host mapping: $ip $host"
echo "$ip $host" | sudo tee -a /etc/hosts > /dev/null
fi
done < .tmp/beaker-host-mappings.txt
echo "---- Relevant /etc/hosts entries ----"
while read -r _ host; do
[ -n "$host" ] || continue
getent hosts "$host" || true
done < .tmp/beaker-host-mappings.txt
- name: Run module compatibility test (acceptance)
uses: ./.github/actions/run-module-test
with:
module-json: ${{ toJson(matrix.module) }}
module-id: ${{ matrix.module.id }}
profile: ${{ github.event.inputs.profile || '8-latest-maintained' }}
metadata-mode: ${{ github.event.inputs.metadata_mode || 'warn' }}
enable-debug: ${{ github.event.inputs.enable_debug || 'false' }}
test-mode: acceptance
allow-acceptance: 'true'
beaker-setfile: config/beaker/setfiles/${{ matrix.module.setfile }}.yml
output-dir: o/${{ matrix.module.id }}-${{ matrix.module.target_id }}
artifact-name: compatibility-${{ matrix.module.id }}-acceptance-${{ matrix.module.target_id }}
test-lane: acceptance
acceptance-target: ${{ matrix.module.target }}
prereqs-json: ${{ toJson(matrix.module.prereqs) }}
docker-mode: ${{ matrix.module.docker_mode || 'sshd' }}
install-puppetserver: ${{ matrix.module.install_puppetserver || 'false' }}
setup-commands: ${{ toJson(matrix.module.setup_commands) }}
pre-acceptance-commands: ${{ toJson(matrix.module.pre_acceptance_commands) }}
- name: Upload module artifacts (acceptance)
if: always()
uses: actions/upload-artifact@v7
with:
name: compatibility-${{ matrix.module.id }}-acceptance-${{ matrix.module.target_id }}
path: o/${{ matrix.module.id }}-${{ matrix.module.target_id }}
if-no-files-found: warn
- name: Upload acceptance fallback diagnostics
if: ${{ always() && (failure() || cancelled()) }}
uses: actions/upload-artifact@v7
with:
name: compatibility-${{ matrix.module.id }}-acceptance-${{ matrix.module.target_id }}-diagnostics
path: |
o/${{ matrix.module.id }}-${{ matrix.module.target_id }}
.tmp/beaker-host-mappings.txt
if-no-files-found: warn
publish:
runs-on: ubuntu-latest
needs:
- test_unit
- test_acceptance
if: always()
permissions:
contents: write
steps:
- name: Checkout
uses: actions/checkout@v6
with:
ref: ${{ github.ref_name }}
fetch-depth: 0
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version: '3.11'
- name: Download module artifacts
uses: actions/download-artifact@v8
with:
path: all-artifacts
pattern: compatibility-*
merge-multiple: false
- name: Download change decisions
continue-on-error: true
uses: actions/download-artifact@v8
with:
name: change-decisions
path: .tmp-decisions
- name: Summarize results
shell: bash
run: |
set -euo pipefail
python scripts/summarize_module_statuses.py
env:
STATUS_ROOT: all-artifacts
SKIP_MANIFEST: .tmp-decisions/change-decisions.json
# Persist to the ledger/dashboard ONLY for real runs (schedule, or a
# manual dispatch of the configured fleet). An ad-hoc modules_json
# override is a throwaway experiment and must not write committed state.
- name: Update status ledger
if: github.event.inputs.modules_json == ''
shell: bash
run: |
set -euo pipefail
python scripts/update_ledger.py
env:
STATUS_ROOT: all-artifacts
LEDGER_FILE: status/ledger.json
MODULES_FILE: config/modules.json
KNOWN_INCOMPATIBLE_FILE: KNOWN_INCOMPATIBLE.md
KNOWN_DEPRECATED_FILE: KNOWN_DEPRECATED.md
- name: Render status dashboard
if: github.event.inputs.modules_json == ''
shell: bash
run: |
set -euo pipefail
python scripts/render_status_dashboard.py
env:
LEDGER_FILE: status/ledger.json
MODULES_FILE: config/modules.json
STATUS_FILE: STATUS.md
KNOWN_COMPATIBLE_FILE: KNOWN_COMPATIBLE.md
KNOWN_INCOMPATIBLE_FILE: KNOWN_INCOMPATIBLE.md
STALE_DAYS: ${{ vars.PUPPET_STALE_DAYS || '30' }}
- name: Render acceptance audit
if: github.event.inputs.modules_json == ''
shell: bash
run: |
set -euo pipefail
python scripts/render_acceptance_audit.py
env:
MODULES_FILE: config/modules.json
AUDIT_FILE: docs/available-acceptance-tests.md
- name: Commit status ledger and dashboard
if: github.event.inputs.modules_json == ''
shell: bash
run: |
set -euo pipefail
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git add status/ledger.json STATUS.md KNOWN_COMPATIBLE.md docs/available-acceptance-tests.md
if git diff --cached --quiet; then
echo "No status changes to commit."
exit 0
fi
git commit -m "chore(status): update compatibility ledger and dashboard [skip ci]"
git push origin "HEAD:${GITHUB_REF_NAME}"