Skip to content
Draft
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
130 changes: 130 additions & 0 deletions .github/workflows/quality-links.yml
Original file line number Diff line number Diff line change
@@ -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
1 change: 1 addition & 0 deletions _data/quality_log.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
[]
53 changes: 53 additions & 0 deletions test/append_quality_log_test.rb
Original file line number Diff line number Diff line change
@@ -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
51 changes: 51 additions & 0 deletions tools/append_quality_log.rb
Original file line number Diff line number Diff line change
@@ -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)."