Puppet Module Compatibility Runner #84
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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}" |