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
8 changes: 8 additions & 0 deletions data/project_types/pupmod.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,14 @@ profile::obsoletes::files:
- 'spec/fixtures/manifests/site.pp' # Not required since Puppet 3.x
- '.pmtignore' # Now using .pdkignore

# Workflow files are bootstrap-strategy: laid down in full only when
# missing, then refreshed in place by the merge_github_workflows stage,
# which preserves Renovate-managed values (uses refs, ruby versions,
# runner/container images). See simp/puppetsync#50.
# (This key activates once profile::managed_file's strategy support is
# merged; it is inert before then.)
profile::github_actions::strategy: bootstrap

profile::github_actions::absent_action_files:
- pr_glci.yml # PR-triggered GLCI actions
- pr_glci_manual.yml # --> manual trigger for external contributors
Expand Down
4 changes: 4 additions & 0 deletions data/project_types/pupmod_skeleton.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@ profile::obsoletes::files:
- 'spec/fixtures/manifests/site.pp' # Not required since Puppet 3.x
- '.pmtignore' # Now using .pdkignore

# Bootstrap + merge_github_workflows, same as project_type pupmod
# (see data/project_types/pupmod.yaml and simp/puppetsync#50)
profile::github_actions::strategy: bootstrap

profile::github_actions::absent_action_files:
- pr_glci.yml # PR-triggered GLCI actions
- pr_glci_manual.yml # --> manual trigger for external contributors
Expand Down
57 changes: 57 additions & 0 deletions dist/puppetsync/plans/init.pp
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,63 @@
}
}

$repos.puppetsync::pipeline_stage(
# --------------------------------------------------------------------------
'merge_github_workflows',
# --------------------------------------------------------------------------
$opts
) |$ok_repos, $stage_name| {
# Refresh existing workflow files from their baseline templates while
# preserving Renovate-managed values (action refs, image tags, ruby
# versions). Only files that exist in BOTH the repo and the template
# chain are merged; creating and removing workflow files remains
# profile::github_actions' job. See simp/puppetsync#50.
#
# NOTE: this stage only protects Renovate's values when
# profile::github_actions runs in bootstrap strategy (set per
# project_type in Hiera). With the profile still enforcing, the apply
# stage overwrites each workflow with pure template BEFORE this stage
# runs, so there is nothing left to preserve. The bootstrap flip for
# pupmod/pupmod_skeleton ships with this stage.
$gha_repos = $ok_repos.filter |$repo| {
$repo.facts['project_type'] in ['pupmod', 'pupmod_skeleton']
}
run_task_with('puppetsync::merge_gha_workflows',
$gha_repos,
'_catch_errors' => true,
) |$repo| {
$ptype = $repo.facts['project_type'] ? {
'pupmod_skeleton' => 'pupmod',
default => $repo.facts['project_type'],
}
$target_module_name = $repo.facts.dig('module_metadata','name').lest || {
$repo.vars['mod_data']['repo_name']
}
$wf_dir = "${repo.vars['repo_path']}/.github/workflows"
$existing_files = file::exists($wf_dir) ? {
true => dir::children($wf_dir).filter |$f| { $f =~ /\.yml$/ },
default => [],
}
$workflows = $existing_files.map |$f| {
$action = $f.regsubst(/\.yml$/, '')
$template_path = find_file(
"profile/${ptype}/_github/workflows/${action}.${target_module_name}.yml",
"profile/${ptype}/_github/workflows/${action}.yml",
"profile/_github/workflows/${action}.${target_module_name}.yml",
"profile/_github/workflows/${action}.yml",
)
$template_path ? {
undef => undef,
default => Hash({ 'path' => "${wf_dir}/${f}", 'template' => file::read($template_path) }),
}
}.filter |$wf| { $wf =~ NotUndef }
Hash({
'workflows' => $workflows,
'preserve_keys' => $opts.dig('merge_github_workflows', 'preserve_keys'),
})
}
}

$repos.puppetsync::pipeline_stage(
# --------------------------------------------------------------------------
'configure_renovate',
Expand Down
14 changes: 14 additions & 0 deletions dist/puppetsync/tasks/merge_gha_workflows.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
{
"description": "Refresh GitHub Actions workflow files from their baseline templates, preserving Renovate-managed values (action refs, container image tags, ruby versions, runner images, ...) already present in the repo. Template text is canonical; values are grafted via Psych node coordinates, so comments and formatting are never disturbed.",
"input_method": "stdin",
"parameters": {
"workflows": {
"description": "Workflow files to merge: [{path: <target file>, template: <baseline content>}, ...]",
"type": "Array[Hash]"
},
"preserve_keys": {
"description": "YAML mapping keys whose scalar values are Renovate-managed and must be preserved from the existing file (default: uses, image, container, ruby-version, runs-on)",
"type": "Optional[Array[String[1]]]"
}
}
}
134 changes: 134 additions & 0 deletions dist/puppetsync/tasks/merge_gha_workflows.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
#!/opt/puppetlabs/bolt/bin/ruby
#
# Refresh GitHub Actions workflow files from their baseline templates while
# preserving the Renovate-managed values already in the repo.
#
# The template text is canonical — structure, jobs, comments, and formatting
# all come from it byte-for-byte. The only things carried over from the
# existing file are the scalar values of `preserve_keys` (action refs,
# container image tags, ruby versions, ...), including any trailing
# comment — pinned-digest conventions keep the human-readable version there.
#
# Matching pairs occurrences of the same key + identity positionally, where
# the identity is the part of the value Renovate never changes:
#
# uses: actions/checkout@v5 -> identity 'actions/checkout'
# image: ghcr.io/foo/builder:8 -> identity 'ghcr.io/foo/builder'
# ruby-version: '3.2' -> no identity (paired per key)
#
# Values new in the template keep the template's value; entries that
# vanished from the template vanish from the file. Scalars are located via
# Psych's node line/column info, so this needs no YAML re-serialization:
# template comments and formatting can't be disturbed by construction.
# See simp/puppetsync#50.

require 'json'
require 'psych'

DEFAULT_PRESERVE_KEYS = %w[uses image container ruby-version runs-on].freeze

# All scalar values of the given mapping keys, in document order, as
# [mapping_path, key, value_node] triples. The path contains mapping key
# names only (sequence positions are deliberately excluded, so steps can be
# reordered within a job without losing their values).
def preserved_scalars(node, keys, path = [], acc = [])
case node
when Psych::Nodes::Mapping
node.children.each_slice(2) do |key, value|
key_name = key.is_a?(Psych::Nodes::Scalar) ? key.value : '?'
acc << [path.join('.'), key_name, value] if keys.include?(key_name) && value.is_a?(Psych::Nodes::Scalar)
preserved_scalars(value, keys, path + [key_name], acc)
end
when Psych::Nodes::Stream, Psych::Nodes::Document, Psych::Nodes::Sequence
node.children.each { |child| preserved_scalars(child, keys, path, acc) }
end
acc
end

# The part of a value Renovate never changes (nil when the whole value is
# the managed part, e.g. a bare version)
def identity(value)
if value.include?('@')
value.split('@', 2).first
elsif value.match?(%r{\A[\w./:-]+:[\w.-]+\z})
# image:tag, including ported registries (registry:5000/foo:8) — the
# identity is everything before the final (tag) colon
value.rpartition(':').first
end
end

# { [path, key, identity] => [raw rest-of-line starting at the scalar
# (value + trailing comment, exactly as written), ...] in document order }
def existing_values(text, keys)
lines = text.split("\n", -1)
preserved_scalars(Psych.parse(text), keys).each_with_object(Hash.new { |h, k| h[k] = [] }) do |(path, key, node), map|
next unless node.start_line == node.end_line

map[[path, key, identity(node.value)]] << lines[node.start_line][node.start_column..]
end
end

def merge_workflow(template, existing_text, keys)
existing = existing_values(existing_text, keys)
lines = template.split("\n", -1)
updated = []

# Pair occurrences positionally per [path, key, identity], so a value only
# carries over when the same job/step context still has it — per-occurrence
# differences (distinct versions per job, trailing comments) survive
# verbatim. Template entries with no counterpart in the existing file
# (a genuinely new or restored step) keep the template's value.
counters = Hash.new(0)
preserved_scalars(Psych.parse(template), keys).each do |path, key, node|
next unless node.start_line == node.end_line

id = [path, key, identity(node.value)]
list = existing[id]
value = list[counters[id]] || list.last
counters[id] += 1
next if value.nil?

line = lines[node.start_line]
next if line[node.start_column..] == value

lines[node.start_line] = line[0...node.start_column] + value
updated << "#{key}: #{value.split(/\s+#/).first}"
end

[lines.join("\n"), updated]
end

def merge_gha_workflows(workflows, keys)
results = {}
workflows.each do |wf|
path = wf.fetch('path')
template = wf.fetch('template')

unless File.exist?(path)
File.write(path, template)
results[path] = { 'changed' => true, 'created' => true }
next
end

existing_text = File.read(path)
merged, updated = merge_workflow(template, existing_text, keys)

if merged == existing_text
results[path] = { 'changed' => false }
else
File.write(path, merged)
results[path] = { 'changed' => true, 'preserved_values' => updated }
end
end

{ 'changed' => results.values.any? { |r| r['changed'] }, 'files' => results }
end

stdin = STDIN.read
params = JSON.parse(stdin)

workflows = params['workflows']
raise('No workflows given') unless workflows.is_a?(Array)
keys = params.fetch('preserve_keys', nil) || DEFAULT_PRESERVE_KEYS

puts JSON.generate(merge_gha_workflows(workflows, keys))
Loading