From 3c00a9e6ca16c52dbcd7ad9097f8e3cd328a8fe6 Mon Sep 17 00:00:00 2001 From: Steven Pritchard Date: Thu, 30 Jul 2026 17:35:32 +0000 Subject: [PATCH 1/2] Add merge_gha_workflows task and pipeline stage (#50 slice 3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Final slice of the Renovate-resilient template machinery: refresh GHA workflow files from their baseline templates while preserving every Renovate-managed value already in the repo. Approach: the template text is canonical — structure, comments, and formatting come from it byte-for-byte — and the Renovate-managed scalar values are grafted back on using Psych's node line/column coordinates. No YAML re-serialization happens at all, so comment/ format preservation holds by construction and no new gem (psych-pure) is needed. - preserve_keys (default: uses, image, container, ruby-version, runs-on; extensible per session config) selects which mapping keys' values are Renovate's - Values pair by [mapping path, key, identity], where identity is the part Renovate never changes (action before @, image before :, none for bare versions) — so distinct versions per job survive, trailing comments (pinned-digest conventions) ride along, and steps new to the template take the template's value - The merge_github_workflows stage merges only files that exist in BOTH the repo and the template chain (same per-module override resolution as profile::github_actions); creating/removing workflow files remains the profile's job, and repo-specific workflows with no template are never touched Verified with 13 specs (several driven by the real pr_tests.yml template, including byte-identity assertions) and an e2e run against a fixture with Renovate-bumped checkout@v7 / ruby-version 3.4.9 / ubuntu-24.04 plus structural drift: the merge restored template structure, preserved all bumped values (including per-job ones), left an untemplated custom workflow untouched, and a second run reported "1 unchanged". Real-world cases from pupmod-simp-pupmod dd98340 (ruby-version) and 837fb5e (runs-on) are covered by the defaults. After this and #70 merge, profile::github_actions can flip to bootstrap mode per project_type in Hiera, completing #50. Refs #50 Co-Authored-By: Claude Fable 5 --- dist/puppetsync/plans/init.pp | 50 ++++ .../puppetsync/tasks/merge_gha_workflows.json | 14 ++ dist/puppetsync/tasks/merge_gha_workflows.rb | 133 ++++++++++ spec/tasks/merge_gha_workflows_spec.rb | 227 ++++++++++++++++++ 4 files changed, 424 insertions(+) create mode 100644 dist/puppetsync/tasks/merge_gha_workflows.json create mode 100644 dist/puppetsync/tasks/merge_gha_workflows.rb create mode 100644 spec/tasks/merge_gha_workflows_spec.rb diff --git a/dist/puppetsync/plans/init.pp b/dist/puppetsync/plans/init.pp index 28c1b3f..9091e5b 100644 --- a/dist/puppetsync/plans/init.pp +++ b/dist/puppetsync/plans/init.pp @@ -216,6 +216,56 @@ } } + $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. + $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', diff --git a/dist/puppetsync/tasks/merge_gha_workflows.json b/dist/puppetsync/tasks/merge_gha_workflows.json new file mode 100644 index 0000000..fe7ed4b --- /dev/null +++ b/dist/puppetsync/tasks/merge_gha_workflows.json @@ -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: , template: }, ...]", + "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]]]" + } + } +} diff --git a/dist/puppetsync/tasks/merge_gha_workflows.rb b/dist/puppetsync/tasks/merge_gha_workflows.rb new file mode 100644 index 0000000..d46070d --- /dev/null +++ b/dist/puppetsync/tasks/merge_gha_workflows.rb @@ -0,0 +1,133 @@ +#!/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}) + value.split(':').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) +warn 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)) diff --git a/spec/tasks/merge_gha_workflows_spec.rb b/spec/tasks/merge_gha_workflows_spec.rb new file mode 100644 index 0000000..36a0363 --- /dev/null +++ b/spec/tasks/merge_gha_workflows_spec.rb @@ -0,0 +1,227 @@ +require 'spec_helper' + +describe 'task: merge_gha_workflows' do + let(:real_template) do + File.read(File.join(REPO_ROOT, 'modules', 'profile', 'files', 'pupmod', '_github', 'workflows', 'pr_tests.yml')) + end + + around(:each) do |example| + Dir.mktmpdir do |dir| + @dir = dir + @wf = File.join(dir, 'pr_tests.yml') + example.run + end + end + + def run_merge(workflows) + run_task('merge_gha_workflows.rb', 'workflows' => workflows) + end + + it 'writes the template when the target does not exist' do + stdout, stderr, status = run_merge([{ 'path' => @wf, 'template' => real_template }]) + + expect(status).to be_success, stderr + expect(JSON.parse(stdout)['files'][@wf]).to include('changed' => true, 'created' => true) + expect(File.read(@wf)).to eq(real_template) + end + + it 'is a no-op when the target matches the template' do + File.write(@wf, real_template) + + stdout, stderr, status = run_merge([{ 'path' => @wf, 'template' => real_template }]) + + expect(status).to be_success, stderr + expect(JSON.parse(stdout)).to include('changed' => false) + expect(File.read(@wf)).to eq(real_template) + end + + it 'preserves Renovate-bumped action refs when refreshing the template' do + bumped = real_template.gsub('actions/checkout@v5', 'actions/checkout@v7') + raise 'munge failed' if bumped == real_template + File.write(@wf, bumped) + + stdout, stderr, status = run_merge([{ 'path' => @wf, 'template' => real_template }]) + + expect(status).to be_success, stderr + expect(JSON.parse(stdout)['files'][@wf]['changed']).to be false + expect(File.read(@wf)).to eq(bumped) # untouched: only refs differ, and they're preserved + end + + it 'restores template structure while grafting existing refs onto it' do + # Target: structurally stale (a job removed) AND Renovate-bumped + stale = real_template + .gsub('actions/checkout@v5', 'actions/checkout@v7') + .sub(/^ puppet-syntax:.*?(?=^ \w)/m, '') + raise 'munge failed' unless stale.length < real_template.length + File.write(@wf, stale) + + stdout, stderr, status = run_merge([{ 'path' => @wf, 'template' => real_template }]) + + expect(status).to be_success, stderr + expect(JSON.parse(stdout)['files'][@wf]['changed']).to be true + merged = File.read(@wf) + # Structure comes back from the template... + expect(merged).to include('puppet-syntax:') + # ...jobs that survived keep their bumped refs; the restored job takes + # the template's ref (it has no counterpart in the existing file) + expect(merged.scan('actions/checkout@v7').count).to eq(real_template.scan('actions/checkout@v5').count - 1) + expect(merged.scan('actions/checkout@v5').count).to eq(1) + # And apart from the grafted ref lines, output is byte-identical to the template + expect(merged.gsub('actions/checkout@v7', 'actions/checkout@v5')).to eq(real_template) + end + + it 'preserves pinned-digest refs with their trailing version comment' do + template = <<~YAML + jobs: + build: + steps: + - uses: actions/checkout@v5 + - name: setup + uses: ruby/setup-ruby@v1 + YAML + pinned = <<~YAML + jobs: + build: + steps: + - uses: actions/checkout@8edcb1bdb4e267140fa742c62e395cd74f332709 # v7.0.0 + - name: setup + uses: ruby/setup-ruby@ec106b438a1ff6ff109590de34ddc62c540232e0 # v1.244.0 + YAML + File.write(@wf, pinned) + + stdout, stderr, status = run_merge([{ 'path' => @wf, 'template' => template }]) + + expect(status).to be_success, stderr + expect(JSON.parse(stdout)['files'][@wf]['changed']).to be false + expect(File.read(@wf)).to eq(pinned) + end + + it 'uses the template ref for actions new to the template' do + existing = <<~YAML + jobs: + build: + steps: + - uses: actions/checkout@v7 + YAML + template = <<~YAML + jobs: + build: + steps: + - uses: actions/checkout@v5 + - uses: actions/upload-artifact@v4 + YAML + File.write(@wf, existing) + + stdout, stderr, status = run_merge([{ 'path' => @wf, 'template' => template }]) + + expect(status).to be_success, stderr + merged = File.read(@wf) + expect(merged).to include('actions/checkout@v7') # preserved + expect(merged).to include('actions/upload-artifact@v4') # from template + end + + it 'handles multiple workflow files in one invocation' do + other = File.join(@dir, 'tag_deploy.yml') + File.write(@wf, real_template.gsub('actions/checkout@v5', 'actions/checkout@v7')) + File.write(other, "jobs:\n x:\n steps:\n - uses: actions/checkout@v7\n") + + stdout, stderr, status = run_merge([ + { 'path' => @wf, 'template' => real_template }, + { 'path' => other, 'template' => "jobs:\n x:\n steps:\n - uses: actions/checkout@v5\n" }, + ]) + + expect(status).to be_success, stderr + result = JSON.parse(stdout) + expect(result['files'].keys).to contain_exactly(@wf, other) + expect(File.read(other)).to include('actions/checkout@v7') + end + + it 'preserves other Renovate-managed values: ruby versions and container image tags' do + template = <<~YAML + jobs: + spec: + container: + image: ghcr.io/simp/build:8.0.0 + steps: + - uses: ruby/setup-ruby@v1 + with: + ruby-version: '3.2' + release: + container: ruby:3.2 + steps: + - uses: ruby/setup-ruby@v1 + with: + ruby-version: '3.2' + YAML + # Renovate bumped each managed value — differently per job for ruby-version + bumped = <<~YAML + jobs: + spec: + container: + image: ghcr.io/simp/build:9.1.0 + steps: + - uses: ruby/setup-ruby@v1 + with: + ruby-version: '3.4' + release: + container: ruby:3.3 + steps: + - uses: ruby/setup-ruby@v1 + with: + ruby-version: '4.0' + YAML + File.write(@wf, bumped) + + stdout, stderr, status = run_merge([{ 'path' => @wf, 'template' => template }]) + + expect(status).to be_success, stderr + expect(JSON.parse(stdout)['files'][@wf]['changed']).to be false + expect(File.read(@wf)).to eq(bumped) + end + + it 'grafts non-uses managed values onto refreshed template structure' do + template = <<~YAML + name: New Name + jobs: + spec: + container: + image: ghcr.io/simp/build:8.0.0 + steps: + - uses: ruby/setup-ruby@v1 + with: + ruby-version: '3.2' + YAML + stale = template + .sub('New Name', 'Old Name') + .sub('ghcr.io/simp/build:8.0.0', 'ghcr.io/simp/build:9.1.0') + .sub("ruby-version: '3.2'", "ruby-version: '3.4'") + File.write(@wf, stale) + + stdout, stderr, status = run_merge([{ 'path' => @wf, 'template' => template }]) + + expect(status).to be_success, stderr + expect(JSON.parse(stdout)['files'][@wf]['changed']).to be true + merged = File.read(@wf) + expect(merged).to include('New Name') # structure from template + expect(merged).to include('ghcr.io/simp/build:9.1.0') # preserved + expect(merged).to include("ruby-version: '3.4'") # preserved + end + + it 'is idempotent' do + stale = real_template.gsub('actions/checkout@v5', 'actions/checkout@v7').sub("name: PR Tests\n", "name: Old Name\n") + File.write(@wf, stale) + run_merge([{ 'path' => @wf, 'template' => real_template }]) + first_pass = File.read(@wf) + + stdout, stderr, status = run_merge([{ 'path' => @wf, 'template' => real_template }]) + + expect(status).to be_success, stderr + expect(JSON.parse(stdout)['changed']).to be false + expect(File.read(@wf)).to eq(first_pass) + end + + it 'fails when workflows param is missing' do + _stdout, _stderr, status = run_task('merge_gha_workflows.rb', {}) + expect(status).not_to be_success + end +end From 144716fcff37abec6240bc9443aa47b85f295022 Mon Sep 17 00:00:00 2001 From: Steven Pritchard Date: Fri, 31 Jul 2026 14:21:13 +0000 Subject: [PATCH 2/2] Address review feedback: debug output, ported registries, pair the flip - Remove the `warn stdin` debug line (it echoed every workflow template to stderr on each run) - identity() now handles ported registries (registry:5000/foo:8 pairs by everything before the tag colon), with a spec - Ship the profile::github_actions::strategy: bootstrap Hiera flip for pupmod/pupmod_skeleton WITH this stage: while the profile enforces, the apply stage overwrites workflows with pure template before this stage runs, so enabling merge_github_workflows pre-flip protects nothing. The key is inert until the managed_file strategy support merges, and the stage comment now documents the coupling. rubygem and unknown_with_ci project types stay enforce, since the merge stage does not cover them Co-Authored-By: Claude Fable 5 --- data/project_types/pupmod.yaml | 8 ++++++++ data/project_types/pupmod_skeleton.yaml | 4 ++++ dist/puppetsync/plans/init.pp | 7 +++++++ dist/puppetsync/tasks/merge_gha_workflows.rb | 7 ++++--- spec/tasks/merge_gha_workflows_spec.rb | 17 +++++++++++++++++ 5 files changed, 40 insertions(+), 3 deletions(-) diff --git a/data/project_types/pupmod.yaml b/data/project_types/pupmod.yaml index 84d0b88..7225e5f 100644 --- a/data/project_types/pupmod.yaml +++ b/data/project_types/pupmod.yaml @@ -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 diff --git a/data/project_types/pupmod_skeleton.yaml b/data/project_types/pupmod_skeleton.yaml index 0a506cb..9f5d4f1 100644 --- a/data/project_types/pupmod_skeleton.yaml +++ b/data/project_types/pupmod_skeleton.yaml @@ -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 diff --git a/dist/puppetsync/plans/init.pp b/dist/puppetsync/plans/init.pp index 9091e5b..481e029 100644 --- a/dist/puppetsync/plans/init.pp +++ b/dist/puppetsync/plans/init.pp @@ -227,6 +227,13 @@ # 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'] } diff --git a/dist/puppetsync/tasks/merge_gha_workflows.rb b/dist/puppetsync/tasks/merge_gha_workflows.rb index d46070d..9fdae18 100644 --- a/dist/puppetsync/tasks/merge_gha_workflows.rb +++ b/dist/puppetsync/tasks/merge_gha_workflows.rb @@ -50,8 +50,10 @@ def preserved_scalars(node, keys, path = [], acc = []) def identity(value) if value.include?('@') value.split('@', 2).first - elsif value.match?(%r{\A[\w./-]+:[\w.-]+\z}) - value.split(':').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 @@ -124,7 +126,6 @@ def merge_gha_workflows(workflows, keys) stdin = STDIN.read params = JSON.parse(stdin) -warn stdin workflows = params['workflows'] raise('No workflows given') unless workflows.is_a?(Array) diff --git a/spec/tasks/merge_gha_workflows_spec.rb b/spec/tasks/merge_gha_workflows_spec.rb index 36a0363..cdc048d 100644 --- a/spec/tasks/merge_gha_workflows_spec.rb +++ b/spec/tasks/merge_gha_workflows_spec.rb @@ -207,6 +207,23 @@ def run_merge(workflows) expect(merged).to include("ruby-version: '3.4'") # preserved end + it 'pairs ported-registry images by everything before the tag colon' do + template = <<~YAML + jobs: + build: + container: + image: registry.internal:5000/simp/builder:8.0.0 + YAML + bumped = template.sub(':8.0.0', ':9.2.0') + File.write(@wf, bumped) + + stdout, stderr, status = run_merge([{ 'path' => @wf, 'template' => template }]) + + expect(status).to be_success, stderr + expect(JSON.parse(stdout)['files'][@wf]['changed']).to be false + expect(File.read(@wf)).to eq(bumped) + end + it 'is idempotent' do stale = real_template.gsub('actions/checkout@v5', 'actions/checkout@v7').sub("name: PR Tests\n", "name: Old Name\n") File.write(@wf, stale)