From 1a873c527d9108af07a649d76fd7b803b0191c05 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 7 Sep 2026 00:29:24 +0000 Subject: [PATCH 1/5] Initial plan From 23408b05d48197948186517107462e41e0ba41a8 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 7 Sep 2026 00:33:36 +0000 Subject: [PATCH 2/5] feat: add release link-integrity quality ledger workflow Co-authored-by: ckenst <6896787+ckenst@users.noreply.github.com> --- .github/workflows/quality-links.yml | 115 ++++++++++++++++++++++++++++ _data/quality_log.yml | 1 + test/append_quality_log_test.rb | 53 +++++++++++++ tools/append_quality_log.rb | 51 ++++++++++++ 4 files changed, 220 insertions(+) create mode 100644 .github/workflows/quality-links.yml create mode 100644 _data/quality_log.yml create mode 100644 test/append_quality_log_test.rb create mode 100644 tools/append_quality_log.rb diff --git a/.github/workflows/quality-links.yml b/.github/workflows/quality-links.yml new file mode 100644 index 00000000..20867f96 --- /dev/null +++ b/.github/workflows/quality-links.yml @@ -0,0 +1,115 @@ +name: Quality Ledger - Link Integrity + +on: + push: + tags: + - 'v*' + workflow_dispatch: + inputs: + version: + description: 'Version to record (defaults to tag name or VERSION.txt)' + required: false + ref: + description: 'Git ref to scan (defaults to tag ref or main)' + required: false + +permissions: + contents: write + +jobs: + broken-link-metrics: + runs-on: ubuntu-latest + steps: + - name: Determine scan ref and version + id: meta + run: | + REF_INPUT="${{ github.event.inputs.ref }}" + VERSION_INPUT="${{ github.event.inputs.version }}" + + if [ -n "$REF_INPUT" ]; then + SCAN_REF="$REF_INPUT" + elif [ "${{ github.event_name }}" = "push" ]; then + SCAN_REF="${{ github.ref }}" + else + SCAN_REF="main" + fi + + if [ -n "$VERSION_INPUT" ]; then + VERSION="$VERSION_INPUT" + elif [[ "${{ github.ref }}" == refs/tags/v* ]]; then + VERSION="${{ github.ref_name }}" + else + VERSION="" + fi + + echo "scan_ref=$SCAN_REF" >> "$GITHUB_OUTPUT" + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + + - name: Checkout scan ref + uses: actions/checkout@v4 + with: + token: ${{ secrets.GITHUB_TOKEN }} + fetch-depth: 0 + ref: ${{ steps.meta.outputs.scan_ref }} + + - name: Resolve version from VERSION.txt when needed + id: resolved_version + run: | + VERSION="${{ steps.meta.outputs.version }}" + if [ -z "$VERSION" ]; then + VERSION="v$(cat _includes/VERSION.txt)" + fi + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + + - name: Setup Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: '3.2' + bundler-cache: true + + - name: Build site + run: bundle exec jekyll build --verbose + + - name: Run linkinator (internal links only) + id: linkinator + run: | + set +e + npx --yes linkinator ./_site --recurse --format json --verbosity error --skip '^https?://' > /tmp/linkinator-results.json + set -e + + BROKEN_COUNT=$(node -e "const fs=require('fs'); const p='/tmp/linkinator-results.json'; const raw=fs.readFileSync(p,'utf8'); const data=JSON.parse(raw); const links=Array.isArray(data.links)?data.links:[]; process.stdout.write(String(links.filter((l)=>l.state==='BROKEN').length));") + echo "broken_count=$BROKEN_COUNT" >> "$GITHUB_OUTPUT" + echo "Broken links found: $BROKEN_COUNT" + cat /tmp/linkinator-results.json + + - name: Checkout main for quality log persistence + run: | + git fetch origin main + git switch main + + - name: Append quality log entry + run: | + ruby tools/append_quality_log.rb \ + --version "${{ steps.resolved_version.outputs.version }}" \ + --broken-links "${{ steps.linkinator.outputs.broken_count }}" \ + --commit-sha "${{ github.sha }}" \ + --workflow-run-url "${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" \ + --output _data/quality_log.yml + + - name: Commit quality log update + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git add _data/quality_log.yml + if git diff --staged --quiet; then + echo "No quality log changes to commit" + else + git commit -m "chore: log link integrity for ${{ steps.resolved_version.outputs.version }}" + git push origin main + fi + + - name: Fail when broken links are present + if: steps.linkinator.outputs.broken_count != '0' + run: | + echo "::error::Broken link scan found ${{ steps.linkinator.outputs.broken_count }} broken link(s)." + exit 1 diff --git a/_data/quality_log.yml b/_data/quality_log.yml new file mode 100644 index 00000000..fe51488c --- /dev/null +++ b/_data/quality_log.yml @@ -0,0 +1 @@ +[] diff --git a/test/append_quality_log_test.rb b/test/append_quality_log_test.rb new file mode 100644 index 00000000..5082b6f9 --- /dev/null +++ b/test/append_quality_log_test.rb @@ -0,0 +1,53 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +require 'minitest/autorun' +require 'open3' +require 'tmpdir' +require 'yaml' + +ROOT = File.expand_path('..', __dir__) +APPENDER = File.join(ROOT, 'tools', 'append_quality_log.rb') + +class AppendQualityLogTest < Minitest::Test + def test_appends_new_entry_to_existing_log + Dir.mktmpdir do |dir| + output = File.join(dir, 'quality_log.yml') + File.write(output, [{ 'version' => 'v1', 'broken_links' => 0 }].to_yaml) + + _stdout, stderr, status = Open3.capture3( + 'ruby', APPENDER, + '--version', 'v2', + '--broken-links', '3', + '--commit-sha', 'abc123', + '--workflow-run-url', 'https://example.com/run/1', + '--release-date', '2026-09-07T00:00:00Z', + '--output', output + ) + + assert status.success?, "expected success, got stderr:\n#{stderr}" + + log = YAML.safe_load_file(output) + assert_equal 2, log.length + assert_equal 'v2', log.last['version'] + assert_equal 3, log.last['broken_links'] + assert_equal 'abc123', log.last['commit_sha'] + assert_equal 'https://example.com/run/1', log.last['workflow_run_url'] + assert_equal( + { 'performance' => nil, 'accessibility' => nil, 'seo' => nil }, + log.last['lighthouse'] + ) + end + end + + def test_rejects_negative_broken_link_count + _stdout, stderr, status = Open3.capture3( + 'ruby', APPENDER, + '--version', 'v2', + '--broken-links', '-1' + ) + + refute status.success? + assert_match(/--broken-links must be >= 0/, stderr) + end +end diff --git a/tools/append_quality_log.rb b/tools/append_quality_log.rb new file mode 100644 index 00000000..fc17dd93 --- /dev/null +++ b/tools/append_quality_log.rb @@ -0,0 +1,51 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +require 'optparse' +require 'yaml' +require 'time' + +options = { + output: '_data/quality_log.yml', + release_date: Time.now.utc.iso8601 +} + +OptionParser.new do |opts| + opts.banner = 'Usage: ruby tools/append_quality_log.rb --version v123 --broken-links 0 [options]' + + opts.on('--version VERSION', 'Release version (for example: v123)') { |v| options[:version] = v } + opts.on('--broken-links COUNT', Integer, 'Broken link count') { |v| options[:broken_links] = v } + opts.on('--commit-sha SHA', 'Commit SHA for this snapshot') { |v| options[:commit_sha] = v } + opts.on('--workflow-run-url URL', 'Workflow run URL') { |v| options[:workflow_run_url] = v } + opts.on('--release-date DATE', 'Release date (ISO 8601)') { |v| options[:release_date] = v } + opts.on('--output PATH', 'Output YAML path') { |v| options[:output] = v } +end.parse! + +abort('Missing required --version') unless options[:version] +abort('Missing required --broken-links') unless options.key?(:broken_links) +abort('--broken-links must be >= 0') if options[:broken_links].negative? + +log_entries = + if File.exist?(options[:output]) + data = YAML.safe_load_file(options[:output], permitted_classes: [Time], aliases: false) + data.is_a?(Array) ? data : [] + else + [] + end + +log_entries << { + 'version' => options[:version], + 'release_date' => options[:release_date], + 'commit_sha' => options[:commit_sha], + 'lighthouse' => { + 'performance' => nil, + 'accessibility' => nil, + 'seo' => nil + }, + 'broken_links' => options[:broken_links], + 'build_time_seconds' => nil, + 'workflow_run_url' => options[:workflow_run_url] +} + +File.write(options[:output], "#{log_entries.to_yaml.sub(/\A---\n/, '')}") +puts "Appended quality log entry for #{options[:version]} with #{options[:broken_links]} broken link(s)." From 86bdba63da3642c262205b7fc9146dbe414adc0d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 7 Sep 2026 00:37:53 +0000 Subject: [PATCH 3/5] fix: add link-integrity workflow quality log safeguards Co-authored-by: ckenst <6896787+ckenst@users.noreply.github.com> --- .github/workflows/quality-links.yml | 53 ++++++++++++++++++----------- tools/append_quality_log.rb | 2 +- 2 files changed, 35 insertions(+), 20 deletions(-) diff --git a/.github/workflows/quality-links.yml b/.github/workflows/quality-links.yml index 20867f96..cfe6f5a8 100644 --- a/.github/workflows/quality-links.yml +++ b/.github/workflows/quality-links.yml @@ -22,22 +22,25 @@ jobs: steps: - name: Determine scan ref and version id: meta + env: + REF_INPUT: ${{ github.event.inputs.ref || '' }} + VERSION_INPUT: ${{ github.event.inputs.version || '' }} + EVENT_NAME: ${{ github.event_name }} + GITHUB_REF_VALUE: ${{ github.ref }} + GITHUB_REF_NAME_VALUE: ${{ github.ref_name }} run: | - REF_INPUT="${{ github.event.inputs.ref }}" - VERSION_INPUT="${{ github.event.inputs.version }}" - if [ -n "$REF_INPUT" ]; then SCAN_REF="$REF_INPUT" - elif [ "${{ github.event_name }}" = "push" ]; then - SCAN_REF="${{ github.ref }}" + elif [ "$EVENT_NAME" = "push" ]; then + SCAN_REF="$GITHUB_REF_VALUE" else SCAN_REF="main" fi if [ -n "$VERSION_INPUT" ]; then VERSION="$VERSION_INPUT" - elif [[ "${{ github.ref }}" == refs/tags/v* ]]; then - VERSION="${{ github.ref_name }}" + elif [[ "$GITHUB_REF_VALUE" == refs/tags/v* ]]; then + VERSION="$GITHUB_REF_NAME_VALUE" else VERSION="" fi @@ -73,8 +76,12 @@ jobs: - name: Run linkinator (internal links only) id: linkinator run: | + python3 -m http.server 4001 --bind 127.0.0.1 --directory _site >/tmp/quality-links-server.log 2>&1 & + SERVER_PID=$! + trap "kill $SERVER_PID" EXIT + set +e - npx --yes linkinator ./_site --recurse --format json --verbosity error --skip '^https?://' > /tmp/linkinator-results.json + npx --yes linkinator http://127.0.0.1:4001 --recurse --format json --verbosity error --skip '^https?://(?!127\.0\.0\.1:4001)' --skip '^//' > /tmp/linkinator-results.json set -e BROKEN_COUNT=$(node -e "const fs=require('fs'); const p='/tmp/linkinator-results.json'; const raw=fs.readFileSync(p,'utf8'); const data=JSON.parse(raw); const links=Array.isArray(data.links)?data.links:[]; process.stdout.write(String(links.filter((l)=>l.state==='BROKEN').length));") @@ -88,15 +95,29 @@ jobs: git switch main - name: Append quality log entry + env: + VERSION: ${{ steps.resolved_version.outputs.version }} + BROKEN_COUNT: ${{ steps.linkinator.outputs.broken_count }} + COMMIT_SHA: ${{ github.sha }} + WORKFLOW_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} run: | ruby tools/append_quality_log.rb \ - --version "${{ steps.resolved_version.outputs.version }}" \ - --broken-links "${{ steps.linkinator.outputs.broken_count }}" \ - --commit-sha "${{ github.sha }}" \ - --workflow-run-url "${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" \ + --version "$VERSION" \ + --broken-links "$BROKEN_COUNT" \ + --commit-sha "$COMMIT_SHA" \ + --workflow-run-url "$WORKFLOW_RUN_URL" \ --output _data/quality_log.yml + - name: Fail when broken links are present + if: steps.linkinator.outputs.broken_count != '0' + run: | + echo "::error::Broken link scan found ${{ steps.linkinator.outputs.broken_count }} broken link(s)." + exit 1 + - name: Commit quality log update + if: always() + env: + VERSION: ${{ steps.resolved_version.outputs.version }} run: | git config user.name "github-actions[bot]" git config user.email "github-actions[bot]@users.noreply.github.com" @@ -104,12 +125,6 @@ jobs: if git diff --staged --quiet; then echo "No quality log changes to commit" else - git commit -m "chore: log link integrity for ${{ steps.resolved_version.outputs.version }}" + git commit -m "chore: log link integrity for ${VERSION}" git push origin main fi - - - name: Fail when broken links are present - if: steps.linkinator.outputs.broken_count != '0' - run: | - echo "::error::Broken link scan found ${{ steps.linkinator.outputs.broken_count }} broken link(s)." - exit 1 diff --git a/tools/append_quality_log.rb b/tools/append_quality_log.rb index fc17dd93..2bf9452a 100644 --- a/tools/append_quality_log.rb +++ b/tools/append_quality_log.rb @@ -47,5 +47,5 @@ 'workflow_run_url' => options[:workflow_run_url] } -File.write(options[:output], "#{log_entries.to_yaml.sub(/\A---\n/, '')}") +File.write(options[:output], log_entries.to_yaml) puts "Appended quality log entry for #{options[:version]} with #{options[:broken_links]} broken link(s)." From bb5dd8f5422d45c5da440c7013c0a5e71fbf8758 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 10 Sep 2026 04:44:15 +0000 Subject: [PATCH 4/5] fix: wire quality link checks into release workflow Co-authored-by: ckenst <6896787+ckenst@users.noreply.github.com> --- .github/workflows/deploy.yml | 11 ++++++ .github/workflows/quality-links.yml | 32 +++++++++++++----- test/append_quality_log_test.rb | 52 +++++++++++++++++++++++++++++ tools/append_quality_log.rb | 10 ++++-- 4 files changed, 95 insertions(+), 10 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index a1b581c6..e5803db9 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -15,6 +15,8 @@ concurrency: jobs: version-and-tag: + outputs: + version: ${{ steps.version.outputs.version }} runs-on: ubuntu-latest steps: - name: Checkout @@ -69,3 +71,12 @@ jobs: git tag -a "v${{ steps.version.outputs.version }}" -m "Release version ${{ steps.version.outputs.version }}" git push origin "v${{ steps.version.outputs.version }}" fi + + link-integrity-quality-ledger: + needs: version-and-tag + if: needs.version-and-tag.result == 'success' && needs.version-and-tag.outputs.version != '' + uses: ./.github/workflows/quality-links.yml + with: + ref: refs/tags/v${{ needs.version-and-tag.outputs.version }} + version: v${{ needs.version-and-tag.outputs.version }} + secrets: inherit diff --git a/.github/workflows/quality-links.yml b/.github/workflows/quality-links.yml index cfe6f5a8..6ccbf9b4 100644 --- a/.github/workflows/quality-links.yml +++ b/.github/workflows/quality-links.yml @@ -1,9 +1,16 @@ name: Quality Ledger - Link Integrity on: - push: - tags: - - 'v*' + workflow_call: + inputs: + ref: + required: false + type: string + default: '' + version: + required: false + type: string + default: '' workflow_dispatch: inputs: version: @@ -16,6 +23,10 @@ on: permissions: contents: write +concurrency: + group: quality-ledger + cancel-in-progress: false + jobs: broken-link-metrics: runs-on: ubuntu-latest @@ -23,8 +34,8 @@ jobs: - name: Determine scan ref and version id: meta env: - REF_INPUT: ${{ github.event.inputs.ref || '' }} - VERSION_INPUT: ${{ github.event.inputs.version || '' }} + REF_INPUT: ${{ inputs.ref || github.event.inputs.ref || '' }} + VERSION_INPUT: ${{ inputs.version || github.event.inputs.version || '' }} EVENT_NAME: ${{ github.event_name }} GITHUB_REF_VALUE: ${{ github.ref }} GITHUB_REF_NAME_VALUE: ${{ github.ref_name }} @@ -45,7 +56,7 @@ jobs: VERSION="" fi - echo "scan_ref=$SCAN_REF" >> "$GITHUB_OUTPUT" + echo "ref=$SCAN_REF" >> "$GITHUB_OUTPUT" echo "version=$VERSION" >> "$GITHUB_OUTPUT" - name: Checkout scan ref @@ -53,7 +64,7 @@ jobs: with: token: ${{ secrets.GITHUB_TOKEN }} fetch-depth: 0 - ref: ${{ steps.meta.outputs.scan_ref }} + ref: ${{ steps.meta.outputs.ref }} - name: Resolve version from VERSION.txt when needed id: resolved_version @@ -64,6 +75,11 @@ jobs: fi echo "version=$VERSION" >> "$GITHUB_OUTPUT" + - name: Capture scanned commit SHA + id: scanned_sha + run: | + echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" + - name: Setup Ruby uses: ruby/setup-ruby@v1 with: @@ -98,7 +114,7 @@ jobs: env: VERSION: ${{ steps.resolved_version.outputs.version }} BROKEN_COUNT: ${{ steps.linkinator.outputs.broken_count }} - COMMIT_SHA: ${{ github.sha }} + COMMIT_SHA: ${{ steps.scanned_sha.outputs.sha }} WORKFLOW_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} run: | ruby tools/append_quality_log.rb \ diff --git a/test/append_quality_log_test.rb b/test/append_quality_log_test.rb index 5082b6f9..99d018c8 100644 --- a/test/append_quality_log_test.rb +++ b/test/append_quality_log_test.rb @@ -50,4 +50,56 @@ def test_rejects_negative_broken_link_count refute status.success? assert_match(/--broken-links must be >= 0/, stderr) end + + def test_rejects_non_array_yaml_log + Dir.mktmpdir do |dir| + output = File.join(dir, 'quality_log.yml') + File.write(output, { 'version' => 'v1' }.to_yaml) + + _stdout, stderr, status = Open3.capture3( + 'ruby', APPENDER, + '--version', 'v2', + '--broken-links', '1', + '--output', output + ) + + refute status.success? + assert_match(/Expected .* to contain a YAML array/, stderr) + end + end + + def test_rejects_malformed_yaml_log + Dir.mktmpdir do |dir| + output = File.join(dir, 'quality_log.yml') + File.write(output, "---\n- version: v1\n broken_links: [\n") + + _stdout, stderr, status = Open3.capture3( + 'ruby', APPENDER, + '--version', 'v2', + '--broken-links', '1', + '--output', output + ) + + refute status.success? + assert_match(/Invalid YAML in .*quality_log\.yml/, stderr) + end + end + + def test_allows_duplicate_versions_by_appending_new_entry + Dir.mktmpdir do |dir| + output = File.join(dir, 'quality_log.yml') + File.write(output, [{ 'version' => 'v2', 'broken_links' => 0 }].to_yaml) + + _stdout, stderr, status = Open3.capture3( + 'ruby', APPENDER, + '--version', 'v2', + '--broken-links', '4', + '--output', output + ) + + assert status.success?, "expected success, got stderr:\n#{stderr}" + versions = YAML.safe_load_file(output).map { |entry| entry['version'] } + assert_equal ['v2', 'v2'], versions + end + end end diff --git a/tools/append_quality_log.rb b/tools/append_quality_log.rb index 2bf9452a..ce83a657 100644 --- a/tools/append_quality_log.rb +++ b/tools/append_quality_log.rb @@ -27,8 +27,14 @@ log_entries = if File.exist?(options[:output]) - data = YAML.safe_load_file(options[:output], permitted_classes: [Time], aliases: false) - data.is_a?(Array) ? data : [] + begin + data = YAML.safe_load_file(options[:output], permitted_classes: [Time], aliases: false) + rescue Psych::SyntaxError => e + abort("Invalid YAML in #{options[:output]}: #{e.message}") + end + + abort("Expected #{options[:output]} to contain a YAML array (or be empty)") unless data.nil? || data.is_a?(Array) + data || [] else [] end From 4c1799a9667301a51606853d8311f643b5ba6e01 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 13 Sep 2026 03:15:31 +0000 Subject: [PATCH 5/5] fix: harden quality-log persistence against non-fast-forward races Co-authored-by: ckenst <6896787+ckenst@users.noreply.github.com> --- .github/workflows/quality-links.yml | 27 ++------ test/persist_quality_log_test.rb | 86 +++++++++++++++++++++++++ tools/persist_quality_log.rb | 98 +++++++++++++++++++++++++++++ 3 files changed, 189 insertions(+), 22 deletions(-) create mode 100644 test/persist_quality_log_test.rb create mode 100644 tools/persist_quality_log.rb diff --git a/.github/workflows/quality-links.yml b/.github/workflows/quality-links.yml index 6ccbf9b4..3d324255 100644 --- a/.github/workflows/quality-links.yml +++ b/.github/workflows/quality-links.yml @@ -105,23 +105,21 @@ jobs: echo "Broken links found: $BROKEN_COUNT" cat /tmp/linkinator-results.json - - name: Checkout main for quality log persistence - run: | - git fetch origin main - git switch main - - - name: Append quality log entry + - name: Persist quality log entry + if: always() && steps.linkinator.conclusion != 'skipped' && steps.linkinator.outputs.broken_count != '' env: VERSION: ${{ steps.resolved_version.outputs.version }} BROKEN_COUNT: ${{ steps.linkinator.outputs.broken_count }} COMMIT_SHA: ${{ steps.scanned_sha.outputs.sha }} WORKFLOW_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} run: | - ruby tools/append_quality_log.rb \ + ruby tools/persist_quality_log.rb \ --version "$VERSION" \ --broken-links "$BROKEN_COUNT" \ --commit-sha "$COMMIT_SHA" \ --workflow-run-url "$WORKFLOW_RUN_URL" \ + --base-branch main \ + --max-attempts 3 \ --output _data/quality_log.yml - name: Fail when broken links are present @@ -129,18 +127,3 @@ jobs: run: | echo "::error::Broken link scan found ${{ steps.linkinator.outputs.broken_count }} broken link(s)." exit 1 - - - name: Commit quality log update - if: always() - env: - VERSION: ${{ steps.resolved_version.outputs.version }} - run: | - git config user.name "github-actions[bot]" - git config user.email "github-actions[bot]@users.noreply.github.com" - git add _data/quality_log.yml - if git diff --staged --quiet; then - echo "No quality log changes to commit" - else - git commit -m "chore: log link integrity for ${VERSION}" - git push origin main - fi diff --git a/test/persist_quality_log_test.rb b/test/persist_quality_log_test.rb new file mode 100644 index 00000000..0a8c184b --- /dev/null +++ b/test/persist_quality_log_test.rb @@ -0,0 +1,86 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +require 'minitest/autorun' +require 'fileutils' +require 'open3' +require 'shellwords' +require 'tmpdir' +require 'yaml' + +ROOT = File.expand_path('..', __dir__) +PERSIST = File.join(ROOT, 'tools', 'persist_quality_log.rb') + +class PersistQualityLogTest < Minitest::Test + def run_command!(dir, *args) + _stdout, stderr, status = Open3.capture3(*args, chdir: dir) + assert status.success?, "command failed: #{args.join(' ')}\n#{stderr}" + end + + def write_file(path, content) + FileUtils.mkdir_p(File.dirname(path)) + File.write(path, content) + end + + def test_retries_non_fast_forward_and_preserves_intervening_main_changes + Dir.mktmpdir do |dir| + remote = File.join(dir, 'remote.git') + seed = File.join(dir, 'seed') + worker = File.join(dir, 'worker') + competitor = File.join(dir, 'competitor') + final_clone = File.join(dir, 'final') + + run_command!(dir, 'git', 'init', '--bare', remote) + + run_command!(dir, 'git', 'clone', remote, seed) + run_command!(seed, 'git', 'config', 'user.name', 'Test User') + run_command!(seed, 'git', 'config', 'user.email', 'test@example.com') + write_file(File.join(seed, '_data', 'quality_log.yml'), "--- []\n") + write_file(File.join(seed, 'README.md'), "base\n") + run_command!(seed, 'git', 'add', '_data/quality_log.yml', 'README.md') + run_command!(seed, 'git', 'commit', '-m', 'seed main') + run_command!(seed, 'git', 'push', 'origin', 'HEAD:main') + + run_command!(dir, 'git', 'clone', '--branch', 'main', remote, worker) + run_command!(dir, 'git', 'clone', '--branch', 'main', remote, competitor) + run_command!(competitor, 'git', 'config', 'user.name', 'Competing User') + run_command!(competitor, 'git', 'config', 'user.email', 'compete@example.com') + + hook = [ + "cd #{Shellwords.escape(competitor)}", + 'git fetch origin main', + 'git checkout -B main origin/main', + "echo 'intervening change' >> README.md", + 'git add README.md', + "git commit -m 'intervening main change'", + 'git push origin HEAD:main' + ].join(' && ') + + stdout, stderr, status = Open3.capture3( + 'ruby', PERSIST, + '--version', 'v-race', + '--broken-links', '5', + '--commit-sha', 'scanned-sha-123', + '--workflow-run-url', 'https://example.com/workflow/1', + '--base-branch', 'main', + '--max-attempts', '3', + '--before-push-cmd', hook, + '--output', '_data/quality_log.yml', + chdir: worker + ) + assert status.success?, "persist script failed:\nSTDOUT:\n#{stdout}\nSTDERR:\n#{stderr}" + assert_includes stdout, 'non-fast-forward' + + run_command!(dir, 'git', 'clone', '--branch', 'main', remote, final_clone) + readme = File.read(File.join(final_clone, 'README.md')) + assert_includes readme, 'intervening change' + + log = YAML.safe_load_file(File.join(final_clone, '_data', 'quality_log.yml')) + matching = log.select { |entry| entry['version'] == 'v-race' } + assert_equal 1, matching.length + assert_equal 'scanned-sha-123', matching.first['commit_sha'] + assert_equal 5, matching.first['broken_links'] + assert_equal 'https://example.com/workflow/1', matching.first['workflow_run_url'] + end + end +end diff --git a/tools/persist_quality_log.rb b/tools/persist_quality_log.rb new file mode 100644 index 00000000..1a24e136 --- /dev/null +++ b/tools/persist_quality_log.rb @@ -0,0 +1,98 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +require 'open3' +require 'optparse' + +options = { + output: '_data/quality_log.yml', + base_branch: 'main', + max_attempts: 3, + before_push_cmd: '', + before_push_cmd_each_attempt: false +} + +OptionParser.new do |opts| + opts.banner = 'Usage: ruby tools/persist_quality_log.rb --version v123 --broken-links 0 --commit-sha abc123 --workflow-run-url https://...' + + opts.on('--version VERSION', 'Release version (for example: v123)') { |v| options[:version] = v } + opts.on('--broken-links COUNT', Integer, 'Broken link count') { |v| options[:broken_links] = v } + opts.on('--commit-sha SHA', 'Commit SHA for this snapshot') { |v| options[:commit_sha] = v } + opts.on('--workflow-run-url URL', 'Workflow run URL') { |v| options[:workflow_run_url] = v } + opts.on('--output PATH', 'Output YAML path') { |v| options[:output] = v } + opts.on('--base-branch NAME', 'Branch to persist to') { |v| options[:base_branch] = v } + opts.on('--max-attempts N', Integer, 'Max push attempts on non-fast-forward rejection') { |v| options[:max_attempts] = v } + opts.on('--before-push-cmd COMMAND', 'Optional command to run before push attempts') { |v| options[:before_push_cmd] = v } + opts.on('--before-push-cmd-each-attempt', 'Run before-push command on every attempt instead of once') { options[:before_push_cmd_each_attempt] = true } +end.parse! + +abort('Missing required --version') unless options[:version] +abort('Missing required --broken-links') unless options.key?(:broken_links) +abort('Missing required --commit-sha') unless options[:commit_sha] +abort('Missing required --workflow-run-url') unless options[:workflow_run_url] +abort('--max-attempts must be >= 1') if options[:max_attempts].to_i < 1 + +def run!(*command) + stdout, stderr, status = Open3.capture3(*command) + return [stdout, stderr] if status.success? + + abort("Command failed: #{command.join(' ')}\nSTDOUT:\n#{stdout}\nSTDERR:\n#{stderr}") +end + +def push_to_origin(branch) + Open3.capture3('git', 'push', 'origin', "HEAD:#{branch}") +end + +base_branch = options[:base_branch] +append_script = File.expand_path('append_quality_log.rb', __dir__) +before_push_cmd = options[:before_push_cmd].to_s.strip + +run!('git', 'config', 'user.name', 'github-actions[bot]') +run!('git', 'config', 'user.email', 'github-actions[bot]@users.noreply.github.com') + +(1..options[:max_attempts]).each do |attempt| + run!('git', 'fetch', 'origin', base_branch) + run!('git', 'checkout', '-B', base_branch, "origin/#{base_branch}") + + run!( + 'ruby', append_script, + '--version', options[:version], + '--broken-links', options[:broken_links].to_s, + '--commit-sha', options[:commit_sha], + '--workflow-run-url', options[:workflow_run_url], + '--output', options[:output] + ) + + run!('git', 'add', options[:output]) + + stdout, _stderr = run!('git', 'diff', '--staged', '--name-only') + if stdout.strip.empty? + puts 'No quality log changes to commit' + exit 0 + end + + run!('git', 'commit', '-m', "chore: log link integrity for #{options[:version]}") + + if !before_push_cmd.empty? && (options[:before_push_cmd_each_attempt] || attempt == 1) + hook_stdout, hook_stderr, hook_status = Open3.capture3('bash', '-c', before_push_cmd) + unless hook_status.success? + abort("Before-push command failed on attempt #{attempt}.\nSTDOUT:\n#{hook_stdout}\nSTDERR:\n#{hook_stderr}") + end + end + + push_stdout, push_stderr, push_status = push_to_origin(base_branch) + if push_status.success? + puts "Persisted quality log on attempt #{attempt}" + exit 0 + end + + combined = "#{push_stdout}\n#{push_stderr}" + non_fast_forward = combined.include?('non-fast-forward') || combined.include?('[rejected]') + unless non_fast_forward + abort("Push failed for a non-retryable reason:\n#{combined}") + end + + puts "Push rejected as non-fast-forward on attempt #{attempt}; retrying..." +end + +abort("Failed to persist quality log after #{options[:max_attempts]} attempts")