diff --git a/.github/workflows/quality-links.yml b/.github/workflows/quality-links.yml new file mode 100644 index 00000000..cfe6f5a8 --- /dev/null +++ b/.github/workflows/quality-links.yml @@ -0,0 +1,130 @@ +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 + 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: | + 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 "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: | + 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: Checkout main for quality log persistence + run: | + git fetch origin main + 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 "$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" + 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/_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..2bf9452a --- /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) +puts "Appended quality log entry for #{options[:version]} with #{options[:broken_links]} broken link(s)."