diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..8330814 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,69 @@ +# CI for puppetsync itself (internal — NOT a puppetsync-managed baseline file) +# +# * spec: unit tests for the Bolt tasks + sanity checks over Hiera data and +# task metadata (plain rspec; the tasks are standalone Ruby scripts) +# * bolt: install openbolt, install project dependencies the documented way, +# validate Puppet syntax, and smoke-test that the plans load +--- +name: CI + +'on': + pull_request: + types: [opened, reopened, synchronize] + push: + branches: [main] + workflow_dispatch: {} + +env: + BOLT_DISABLE_ANALYTICS: 'true' + +jobs: + spec: + name: 'Unit tests (tasks & project files)' + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v5 + - uses: ruby/setup-ruby@v1 + with: + # Match the Ruby bundled with openbolt, which runs the tasks in production + ruby-version: '3.2' + - run: gem install rspec --no-document + - run: rspec + + bolt: + name: 'Bolt plan smoke test' + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v5 + + - name: Install openbolt (OpenVox Bolt) + run: | + curl -sSLo /tmp/openvox8-release.deb https://apt.voxpupuli.org/openvox8-release-ubuntu24.04.deb + sudo dpkg -i /tmp/openvox8-release.deb + sudo apt-get update -qq + sudo apt-get install -y openbolt + + - name: Install project dependencies (gems + Puppet modules) + run: ./Rakefile install + + - name: Validate Puppet syntax + run: | + /opt/puppetlabs/bolt/bin/puppet parser validate --tasks \ + $(find dist/puppetsync/plans -name '*.pp') + /opt/puppetlabs/bolt/bin/puppet parser validate \ + $(find dist/puppetsync/functions modules manifests -name '*.pp') + + - name: Verify the puppetsync plans load + run: /opt/puppetlabs/bolt/bin/bolt plan show puppetsync + + - name: List pipeline stages (plan dry run) + env: + # The plan requires these to be set, but nothing calls the APIs in + # list_pipeline_stages mode + GITHUB_API_TOKEN: dummy + GITLAB_API_TOKEN: dummy + JIRA_USER: dummy + JIRA_API_TOKEN: dummy + run: >- + /opt/puppetlabs/bolt/bin/bolt plan run puppetsync + options='{"list_pipeline_stages": true}' diff --git a/CHANGELOG.md b/CHANGELOG.md index 4621eee..711ab80 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ### Added +- CI for puppetsync itself (`.github/workflows/ci.yml`, #54): + - rspec unit tests for Bolt tasks (run as standalone scripts) plus sanity + checks over Hiera data, task metadata, and the `latest.yaml` symlinks + - Puppet syntax validation and a Bolt plan smoke test using openbolt + - New GHA workflow, `add_new_issue_to_triage_project.yml` - New task, `generate_reference_md` - Generates up-to-date `REFERENCE.md` diff --git a/spec/project_files_spec.rb b/spec/project_files_spec.rb new file mode 100644 index 0000000..a76b020 --- /dev/null +++ b/spec/project_files_spec.rb @@ -0,0 +1,40 @@ +require 'spec_helper' + +# Cheap, broad sanity checks over the files the plans depend on at runtime. +describe 'project file sanity' do + describe 'task implementations' do + Dir.glob(File.join(TASKS_DIR, '*.rb')).sort.each do |script| + it "#{File.basename(script)} passes ruby -c" do + _stdout, stderr, status = Open3.capture3(RbConfig.ruby, '-c', script) + expect(status).to be_success, stderr + end + end + end + + describe 'task metadata' do + Dir.glob(File.join(TASKS_DIR, '*.json')).sort.each do |metadata| + it "#{File.basename(metadata)} is valid JSON" do + expect { JSON.parse(File.read(metadata)) }.not_to raise_error + end + end + end + + describe 'Hiera data and project config' do + yaml_files = Dir.glob(File.join(REPO_ROOT, 'data', '**', '*.yaml')) + + %w[bolt-project.yaml hiera.yaml inventory.yaml].map { |f| File.join(REPO_ROOT, f) } + + yaml_files.sort.each do |path| + it "#{path.delete_prefix("#{REPO_ROOT}/")} parses as YAML" do + expect { YAML.load_file(path, aliases: true) }.not_to raise_error + end + end + end + + describe 'latest symlinks' do + %w[data/sync/configs/latest.yaml data/sync/repolists/latest.yaml].each do |link| + it "#{link} resolves to an existing file" do + expect(File).to exist(File.join(REPO_ROOT, link)) + end + end + end +end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb new file mode 100644 index 0000000..f61ef10 --- /dev/null +++ b/spec/spec_helper.rb @@ -0,0 +1,17 @@ +# Helpers for exercising puppetsync's Bolt tasks as standalone scripts. +# +# The tasks are plain Ruby scripts that read their parameters as JSON on +# stdin, so they can be tested without Bolt by running them as subprocesses. +require 'json' +require 'open3' +require 'tmpdir' +require 'yaml' + +REPO_ROOT = File.expand_path('..', __dir__) +TASKS_DIR = File.join(REPO_ROOT, 'dist', 'puppetsync', 'tasks') + +# Run a task script the way Bolt does: parameters as JSON on stdin. +# @return [Array(String, String, Process::Status)] stdout, stderr, status +def run_task(task_file, params) + Open3.capture3(RbConfig.ruby, File.join(TASKS_DIR, task_file), stdin_data: JSON.generate(params)) +end diff --git a/spec/tasks/configure_renovate_spec.rb b/spec/tasks/configure_renovate_spec.rb new file mode 100644 index 0000000..532b87c --- /dev/null +++ b/spec/tasks/configure_renovate_spec.rb @@ -0,0 +1,89 @@ +require 'spec_helper' + +describe 'task: configure_renovate' do + let(:shared_presets) do + [ + 'config:recommended', + 'github>simp/renovate-config', + 'github>simp/renovate-config:ruby.json', + ] + end + + around(:each) do |example| + Dir.mktmpdir do |dir| + @dir = dir + example.run + end + end + + def renovate_config + JSON.parse(File.read(File.join(@dir, 'renovate.json'))) + end + + it 'exits cleanly without creating a config when none exists' do + _stdout, stderr, status = run_task('configure_renovate.rb', 'path' => @dir) + + expect(status).to be_success, stderr + expect(File).not_to exist(File.join(@dir, 'renovate.json')) + end + + it 'fails on a JSON5 config' do + File.write(File.join(@dir, 'renovate.json5'), "{}\n") + _stdout, _stderr, status = run_task('configure_renovate.rb', 'path' => @dir) + expect(status).not_to be_success + end + + it 'adds the shared presets to extends' do + File.write(File.join(@dir, 'renovate.json'), "{}\n") + + _stdout, stderr, status = run_task('configure_renovate.rb', 'path' => @dir) + + expect(status).to be_success, stderr + expect(renovate_config['extends']).to include(*shared_presets) + end + + it 'preserves existing extends entries' do + File.write(File.join(@dir, 'renovate.json'), JSON.generate('extends' => ['local>custom/preset'])) + + _stdout, stderr, status = run_task('configure_renovate.rb', 'path' => @dir) + + expect(status).to be_success, stderr + expect(renovate_config['extends']).to include('local>custom/preset', *shared_presets) + end + + context 'with a Gemfile' do + let(:gemfile_path) { File.join(@dir, 'Gemfile') } + + before(:each) do + File.write(File.join(@dir, 'renovate.json'), "{}\n") + File.write(gemfile_path, <<~GEMFILE) + source 'https://rubygems.org' + + gem 'simp-beaker-helpers', '~> 1.28' + gem 'rake' + GEMFILE + end + + it 'rewrites pinned gems to ENV-overridable versions with a renovate manager comment' do + _stdout, stderr, status = run_task('configure_renovate.rb', 'path' => @dir) + + expect(status).to be_success, stderr + lines = File.read(gemfile_path).lines(chomp: true) + gem_line = lines.index { |l| l.include?('simp-beaker-helpers') } + expect(lines[gem_line]).to include("ENV.fetch('SIMP_BEAKER_HELPERS_VERSION'") + expect(lines[gem_line - 1]).to eq('# renovate: datasource=rubygems versioning=ruby') + expect(lines).to include("gem 'rake'") # untouched + end + + it 'is idempotent' do + run_task('configure_renovate.rb', 'path' => @dir) + first_pass = [File.read(gemfile_path), File.read(File.join(@dir, 'renovate.json'))] + + _stdout, stderr, status = run_task('configure_renovate.rb', 'path' => @dir) + + expect(status).to be_success, stderr + second_pass = [File.read(gemfile_path), File.read(File.join(@dir, 'renovate.json'))] + expect(second_pass).to eq(first_pass) + end + end +end diff --git a/spec/tasks/git_commit_spec.rb b/spec/tasks/git_commit_spec.rb new file mode 100644 index 0000000..e54e797 --- /dev/null +++ b/spec/tasks/git_commit_spec.rb @@ -0,0 +1,62 @@ +require 'spec_helper' + +describe 'task: git_commit' do + def git(*args) + out, status = Open3.capture2e('git', '-C', @repo, *args) + raise "git #{args.join(' ')} failed:\n#{out}" unless status.success? + out + end + + around(:each) do |example| + Dir.mktmpdir do |dir| + @repo = dir + git('init', '-b', 'main') + git('config', 'user.email', 'ci@example.com') + git('config', 'user.name', 'CI') + File.write(File.join(@repo, 'README.md'), "hello\n") + git('add', '-A') + git('commit', '-m', 'initial') + example.run + end + end + + it 'commits pending changes with the given message' do + File.write(File.join(@repo, 'new_file'), "content\n") + + _stdout, stderr, status = run_task('git_commit.rb', 'repo_path' => @repo, 'commit_message' => 'add new file') + + expect(status).to be_success, stderr + expect(git('log', '-1', '--pretty=%s').strip).to eq('add new file') + expect(git('status', '--porcelain')).to be_empty + end + + it 'amends the HEAD commit when the message matches' do + # Production commit messages are multi-line templates ending in a newline; + # the task's amend detection compares against `git log -1 --pretty=%B`. + message = "(SIMP-1234) update baseline\n\ndetails\n" + + File.write(File.join(@repo, 'first'), "a\n") + run_task('git_commit.rb', 'repo_path' => @repo, 'commit_message' => message) + + File.write(File.join(@repo, 'second'), "b\n") + _stdout, stderr, status = run_task('git_commit.rb', 'repo_path' => @repo, 'commit_message' => message) + + expect(status).to be_success, stderr + expect(git('rev-list', '--count', 'HEAD').strip).to eq('2') # initial + one amended commit + expect(git('ls-tree', '--name-only', 'HEAD').split).to include('first', 'second') + end + + it 'exits cleanly and leaves HEAD alone when there is nothing to commit' do + head_before = git('rev-parse', 'HEAD') + + _stdout, stderr, status = run_task('git_commit.rb', 'repo_path' => @repo, 'commit_message' => 'no-op run') + + expect(status).to be_success, stderr + expect(git('rev-parse', 'HEAD')).to eq(head_before) + end + + it 'fails when repo_path is missing' do + _stdout, _stderr, status = run_task('git_commit.rb', 'commit_message' => 'x') + expect(status).not_to be_success + end +end