Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 69 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -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}'
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
40 changes: 40 additions & 0 deletions spec/project_files_spec.rb
Original file line number Diff line number Diff line change
@@ -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
17 changes: 17 additions & 0 deletions spec/spec_helper.rb
Original file line number Diff line number Diff line change
@@ -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
89 changes: 89 additions & 0 deletions spec/tasks/configure_renovate_spec.rb
Original file line number Diff line number Diff line change
@@ -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
62 changes: 62 additions & 0 deletions spec/tasks/git_commit_spec.rb
Original file line number Diff line number Diff line change
@@ -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