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 new file mode 100644 index 00000000..3d324255 --- /dev/null +++ b/.github/workflows/quality-links.yml @@ -0,0 +1,129 @@ +name: Quality Ledger - Link Integrity + +on: + workflow_call: + inputs: + ref: + required: false + type: string + default: '' + version: + required: false + type: string + default: '' + 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 + +concurrency: + group: quality-ledger + cancel-in-progress: false + +jobs: + broken-link-metrics: + runs-on: ubuntu-latest + steps: + - name: Determine scan ref and version + id: meta + env: + 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 }} + run: | + if [ -n "$REF_INPUT" ]; then + SCAN_REF="$REF_INPUT" + 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_VALUE" == refs/tags/v* ]]; then + VERSION="$GITHUB_REF_NAME_VALUE" + else + VERSION="" + fi + + echo "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.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: 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: + 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: | + 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 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));") + echo "broken_count=$BROKEN_COUNT" >> "$GITHUB_OUTPUT" + echo "Broken links found: $BROKEN_COUNT" + cat /tmp/linkinator-results.json + + - 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/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 + 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..99d018c8 --- /dev/null +++ b/test/append_quality_log_test.rb @@ -0,0 +1,105 @@ +#!/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 + + 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/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/append_quality_log.rb b/tools/append_quality_log.rb new file mode 100644 index 00000000..ce83a657 --- /dev/null +++ b/tools/append_quality_log.rb @@ -0,0 +1,57 @@ +#!/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]) + 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 + +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) +puts "Appended quality log entry for #{options[:version]} with #{options[:broken_links]} broken link(s)." 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")