#!/usr/bin/env bash # mirror-check.sh — grade dunn.dev repos against the GitHub mirror baseline # in repository/CONTRACT.md. Online and read-only: glab and gh GETs, curl GETs. # Items 8 and 9 are asserted offline by check.sh; item 11 is review. # # mirror-check.sh ... repo names under gitlab.com/dunn.dev # # Needs glab and gh authenticated. Exit 1 if any item fails. set -uo pipefail for t in glab gh jq curl; do command -v "$t" >/dev/null || { echo "ERROR mirror/tools: missing $t" >&2; exit 2; } done [ $# -gt 0 ] || { echo "usage: mirror-check.sh ..." >&2; exit 2; } NS=dunn.dev OWNER=andrewdunndev fails=0 say() { # status id msg printf '%s mirror/%s/%s: %s\n' "$1" "$repo" "$2" "$3" [ "$1" = FAIL ] && fails=$((fails + 1)) return 0 } verdict() { # id ok msg if [ "$2" = 1 ]; then say PASS "$1" "$3"; else say FAIL "$1" "$3"; fi } glraw() { glab api "projects/$pid/repository/files/$1/raw?ref=$branch" 2>/dev/null; } for repo in "$@"; do gl_path="$NS/$repo" gl_enc="${NS}%2F${repo}" proj=$(glab api "projects/$gl_enc" 2>/dev/null | jq -c '{id, visibility, archived, description, topics, web_url, avatar_url, releases: .releases_access_level, branch: .default_branch}') if [ "$(jq -r '.id // empty' <<<"${proj:-null}")" = "" ]; then say FAIL 0-source "no GitLab project $gl_path"; continue; fi pid=$(jq -r .id <<<"$proj") branch=$(jq -r .branch <<<"$proj") gh_json=$(gh api "repos/$OWNER/$repo" 2>/dev/null | jq -c '{private, archived, description, topics, homepage, has_issues, has_wiki, has_projects, has_discussions, dependabot: .security_and_analysis.dependabot_security_updates.status}') if [ -z "$gh_json" ] || [ "$(jq -r '.has_issues' <<<"$gh_json")" = null ]; then say FAIL 0-target "no GitHub repo $OWNER/$repo"; continue; fi # 1 mirror enabled, finished, no error, no divergent refs kept m=$(glab api "projects/$pid/remote_mirrors" 2>/dev/null | jq -c '[.[] | {enabled, update_status, last_error, keep_divergent_refs}] | map(select(.enabled)) | .[0] // empty') ok=$(jq -r '. != null and .update_status == "finished" and .last_error == null and .keep_divergent_refs == false' <<<"${m:-null}") verdict 1-mirror "$([ "$ok" = true ] && echo 1 || echo 0)" "enabled mirror finished, no last_error, keep_divergent_refs false" # 2 visibility and archived state equal ok=$(jq -rn --argjson g "$proj" --argjson h "$gh_json" '($g.visibility == "public") == ($h.private | not) and $g.archived == $h.archived') verdict 2-state "$([ "$ok" = true ] && echo 1 || echo 0)" "visibility and archived state equal on both forges" # 3 description, topics, homepage ok=$(jq -rn --argjson g "$proj" --argjson h "$gh_json" --arg suf " (mirror of gitlab.com/$gl_path)" \ '($h.description == $g.description or $h.description == ($g.description + $suf)) and (($h.topics | sort) == ($g.topics | map(ascii_downcase) | sort)) and $h.homepage == $g.web_url') verdict 3-metadata "$([ "$ok" = true ] && echo 1 || echo 0)" "description, lowercased topics and homepage match GitLab" # 4 GitLab avatar slot set ok=$(jq -r '.avatar_url != null' <<<"$proj") verdict 4-avatar "$([ "$ok" = true ] && echo 1 || echo 0)" "GitLab avatar slot set" # 5 GitHub features and notifications sub=$(gh api "repos/$OWNER/$repo/subscription" 2>/dev/null | jq -r '.subscribed // false') ok=$(jq -rn --argjson h "$gh_json" --arg sub "$sub" \ '$h.has_issues == true and $h.has_wiki == false and $h.has_projects == false and $h.has_discussions == false and $h.dependabot == "disabled" and $sub == "true"') verdict 5-features "$([ "$ok" = true ] && echo 1 || echo 0)" "issues on; wiki, projects, discussions, dependabot off; subscribed" # 6 the latest GitLab release cut by a tag pipeline is on GitHub through github-release latest=none if [ "$(jq -r .releases <<<"$proj")" != disabled ]; then tags=$(glab api "projects/$pid/releases?order_by=released_at&sort=desc&per_page=100" --paginate 2>/dev/null | jq -sr 'if all(.[]; type == "array") then add[].tag_name else "!" end') if [ "$tags" = "!" ]; then latest=unreadable; else while IFS= read -r t; do [ -n "$t" ] || continue n=$(glab api "projects/$pid/pipelines?ref=$t&per_page=1" 2>/dev/null | jq 'if type == "array" then length else 0 end') if [ "${n:-0}" -gt 0 ]; then latest=$t; break; fi done <<<"$tags" fi fi ok=0 if [ "$latest" = none ]; then ok=1 elif [ "$latest" != unreadable ] && grep -q 'github-release' <<<"$(glraw .gitlab-ci.yml)" \ && [ -n "$(gh api "repos/$OWNER/$repo/releases/tags/$latest" 2>/dev/null | jq -r '.id // empty')" ]; then ok=1; fi verdict 6-release "$ok" "latest tag-backed GitLab release ($latest) is a GitHub release via github-release" # 7 README and CONTRIBUTING name both forges ok=1 for f in README.md CONTRIBUTING.md; do body=$(glraw "$f") grep -q "gitlab.com/$gl_path" <<<"$body" && grep -q "github.com/$OWNER/$repo" <<<"$body" || ok=0 done verdict 7-contact "$ok" "README and CONTRIBUTING name gitlab.com/$gl_path and github.com/$OWNER/$repo" readme=$(glraw README.md) # 10 badge row in order, absolute URLs resolving anonymously badges=$(grep -oE '!\[[^]]*\]\(https?://[^)]*\)' <<<"$readme" | head -3) order=$(sed -E 's/^!\[([^]]*)\].*/\1/' <<<"$badges" | paste -sd, -) ok=0 if [ "$order" = "pipeline,license,built with GitLab" ]; then ok=1 while IFS= read -r u; do [ "$(curl -s -L -o /dev/null -w '%{http_code}' "$u")" = 200 ] || ok=0 done < <(sed -E 's/^!\[[^]]*\]\(([^)]*)\)$/\1/' <<<"$badges") fi verdict 10-badges "$ok" "badge row pipeline, license, built with GitLab; absolute URLs return 200" # 12 nothing that breaks on GitHub ok=1 for f in README.md CONTRIBUTING.md; do grep -qE '\]\(/-/|/uploads/|\[\[_TOC_\]\]' <<<"$(glraw "$f")" && ok=0 done while IFS= read -r p; do head=$(curl -s -L -r 0-39 "https://raw.githubusercontent.com/$OWNER/$repo/$branch/$p") case "$head" in '' | *'404'* | 'version https://git-lfs'*) ok=0 ;; esac done < <(grep -oE '!\[[^]]*\]\([^)]*\)' <<<"$readme" | sed -E 's/^!\[[^]]*\]\(([^)]*)\)$/\1/' | grep -vE '^https?://') verdict 12-portable "$ok" "no GitLab-only links; README images resolve on GitHub, none an LFS pointer" # 13 social preview set og=$(curl -s -L "https://github.com/$OWNER/$repo" | grep -o '