From e8fae1a7eb0b98f2c24f489f7fb26cd319c259a2 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 5 Oct 2026 19:56:21 +0000 Subject: [PATCH 01/29] feat(deps): add dependency PR verifier scripts verify.sh runs a deterministic, non-interactive baseline for a Dependabot PR (tidy, codegen drift, UI dist drift, binary and dev-server smoke, actions pinning/coverage, docker base image, and more), compares failures against the base branch to separate pre-existing issues, runs agent-generated per-PR checks on both versions, and writes result.json and comment.md. post-comment.sh is the only script that writes to GitHub and only upserts one comment. Co-authored-by: Ramon Niebla --- .gitignore | 1 + scripts/dependency-pr/checks/actionlint.sh | 22 ++ .../dependency-pr/checks/actions-coverage.sh | 46 +++ .../dependency-pr/checks/actions-pinning.sh | 30 ++ scripts/dependency-pr/checks/binary-smoke.sh | 59 +++ scripts/dependency-pr/checks/ci-status.sh | 31 ++ scripts/dependency-pr/checks/cli-help-diff.sh | 27 ++ scripts/dependency-pr/checks/docker-image.sh | 51 +++ scripts/dependency-pr/checks/downgrades.sh | 10 + scripts/dependency-pr/checks/go-build-vet.sh | 21 + scripts/dependency-pr/checks/go-directive.sh | 18 + .../dependency-pr/checks/go-generate-drift.sh | 47 +++ scripts/dependency-pr/checks/go-mod-tidy.sh | 21 + scripts/dependency-pr/checks/go-test.sh | 16 + scripts/dependency-pr/checks/golangci-lint.sh | 15 + scripts/dependency-pr/checks/govulncheck.sh | 33 ++ .../checks/npm-wrapper-install.sh | 36 ++ scripts/dependency-pr/checks/pr-state.sh | 34 ++ scripts/dependency-pr/checks/registry.json | 38 ++ .../dependency-pr/checks/release-snapshot.sh | 34 ++ .../dependency-pr/checks/ui-build-drift.sh | 27 ++ scripts/dependency-pr/checks/ui-dep-usage.sh | 64 ++++ scripts/dependency-pr/checks/ui-lint.sh | 13 + scripts/dependency-pr/checks/ui-npm-audit.sh | 36 ++ scripts/dependency-pr/checks/ui-npm-ci.sh | 21 + scripts/dependency-pr/checks/ui-npm-ls.sh | 14 + scripts/dependency-pr/checks/ui-prettier.sh | 14 + scripts/dependency-pr/checks/ui-test.sh | 15 + scripts/dependency-pr/lib/actions_analysis.py | 136 +++++++ scripts/dependency-pr/lib/check.sh | 148 +++++++ scripts/dependency-pr/lib/classify.sh | 189 +++++++++ scripts/dependency-pr/lib/common.sh | 61 +++ scripts/dependency-pr/lib/semver.jq | 48 +++ scripts/dependency-pr/lib/verdict.jq | 105 +++++ scripts/dependency-pr/post-comment.sh | 61 +++ scripts/dependency-pr/render-comment.sh | 128 +++++++ scripts/dependency-pr/risk-map.json | 25 ++ scripts/dependency-pr/test/run.sh | 120 ++++++ scripts/dependency-pr/verify.sh | 362 ++++++++++++++++++ 39 files changed, 2177 insertions(+) create mode 100755 scripts/dependency-pr/checks/actionlint.sh create mode 100755 scripts/dependency-pr/checks/actions-coverage.sh create mode 100755 scripts/dependency-pr/checks/actions-pinning.sh create mode 100755 scripts/dependency-pr/checks/binary-smoke.sh create mode 100755 scripts/dependency-pr/checks/ci-status.sh create mode 100755 scripts/dependency-pr/checks/cli-help-diff.sh create mode 100755 scripts/dependency-pr/checks/docker-image.sh create mode 100755 scripts/dependency-pr/checks/downgrades.sh create mode 100755 scripts/dependency-pr/checks/go-build-vet.sh create mode 100755 scripts/dependency-pr/checks/go-directive.sh create mode 100755 scripts/dependency-pr/checks/go-generate-drift.sh create mode 100755 scripts/dependency-pr/checks/go-mod-tidy.sh create mode 100755 scripts/dependency-pr/checks/go-test.sh create mode 100755 scripts/dependency-pr/checks/golangci-lint.sh create mode 100755 scripts/dependency-pr/checks/govulncheck.sh create mode 100755 scripts/dependency-pr/checks/npm-wrapper-install.sh create mode 100755 scripts/dependency-pr/checks/pr-state.sh create mode 100644 scripts/dependency-pr/checks/registry.json create mode 100755 scripts/dependency-pr/checks/release-snapshot.sh create mode 100755 scripts/dependency-pr/checks/ui-build-drift.sh create mode 100755 scripts/dependency-pr/checks/ui-dep-usage.sh create mode 100755 scripts/dependency-pr/checks/ui-lint.sh create mode 100755 scripts/dependency-pr/checks/ui-npm-audit.sh create mode 100755 scripts/dependency-pr/checks/ui-npm-ci.sh create mode 100755 scripts/dependency-pr/checks/ui-npm-ls.sh create mode 100755 scripts/dependency-pr/checks/ui-prettier.sh create mode 100755 scripts/dependency-pr/checks/ui-test.sh create mode 100755 scripts/dependency-pr/lib/actions_analysis.py create mode 100644 scripts/dependency-pr/lib/check.sh create mode 100755 scripts/dependency-pr/lib/classify.sh create mode 100644 scripts/dependency-pr/lib/common.sh create mode 100644 scripts/dependency-pr/lib/semver.jq create mode 100644 scripts/dependency-pr/lib/verdict.jq create mode 100755 scripts/dependency-pr/post-comment.sh create mode 100755 scripts/dependency-pr/render-comment.sh create mode 100644 scripts/dependency-pr/risk-map.json create mode 100755 scripts/dependency-pr/test/run.sh create mode 100755 scripts/dependency-pr/verify.sh diff --git a/.gitignore b/.gitignore index 4dea585d6..91734d5ca 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ dist/ node_modules/ devserver.db ldcli +.verify-out/ diff --git a/scripts/dependency-pr/checks/actionlint.sh b/scripts/dependency-pr/checks/actionlint.sh new file mode 100755 index 000000000..d54780f05 --- /dev/null +++ b/scripts/dependency-pr/checks/actionlint.sh @@ -0,0 +1,22 @@ +#!/usr/bin/env bash +source "$VERIFY_ROOT/lib/check.sh" +cd "$WT" || exit 1 + +ACTIONLINT_VERSION="v1.7.7" +if command -v actionlint >/dev/null 2>&1; then + cmd=(actionlint) +else + cmd=(go run "github.com/rhysd/actionlint/cmd/actionlint@$ACTIONLINT_VERSION") +fi + +run "${cmd[@]}" -oneline -no-color 2>&1 | tee "$ARTIFACTS/actionlint.out" +rc=${PIPESTATUS[0]} +if [ "$rc" -eq 1 ]; then + sed -E 's/:[0-9]+:[0-9]+:/:/' "$ARTIFACTS/actionlint.out" | sort -u >"$ARTIFACTS/findings" + fingerprint_file "$ARTIFACTS/findings" + detail_block "$ARTIFACTS/actionlint.out" 25 + fail "actionlint reports $(wc -l <"$ARTIFACTS/findings") finding(s)" +elif [ "$rc" -ne 0 ]; then + skip "actionlint could not run (exit $rc); see log" +fi +pass "actionlint clean" diff --git a/scripts/dependency-pr/checks/actions-coverage.sh b/scripts/dependency-pr/checks/actions-coverage.sh new file mode 100755 index 000000000..339d48544 --- /dev/null +++ b/scripts/dependency-pr/checks/actions-coverage.sh @@ -0,0 +1,46 @@ +#!/usr/bin/env bash +# PR CI only exercises workflows that trigger on pull_request. Release +# workflows (release-please, manual-publish, the publish composite) never run +# on a PR, so a green PR says nothing about them. +source "$VERIFY_ROOT/lib/check.sh" + +if ! python3 -c 'import yaml' 2>/dev/null; then + skip "python3 with PyYAML is required for workflow analysis" +fi +updates_for github-actions | jq -s '.' >"$ARTIFACTS/updates.json" +if ! run python3 "$VERIFY_ROOT/lib/actions_analysis.py" "$WT" "$ARTIFACTS/updates.json" >"$ARTIFACTS/report.json"; then + skip "workflow analysis failed; see log" +fi +cat "$ARTIFACTS/report.json" + +detail "| Action | Change | Used in | Runs on PR CI? | Inputs |" +detail "|---|---|---|---|---|" +jq -r '.actions[] | + "| `\(.name)` | \(.from // "∅") → \(.to // "∅") | \([.usages[].file] | unique | join(", ")) | \( + if (.usages | length) == 0 then "n/a" + elif all(.usages[]; .runs_on_pr) then "yes" + elif any(.usages[]; .runs_on_pr) then "partly: not " + ([.usages[] | select(.runs_on_pr | not) | .workflows[]] | unique | join(", ")) + else "**no**" end) | \( + if .inputs == null then "not a major bump" + elif .inputs.status != "ok" then "could not fetch action.yml" + else ((if (.inputs.removed_but_used | length) > 0 then "removed: " + (.inputs.removed_but_used | join(", ")) + "; " else "" end) + + (if (.inputs.new_required | length) > 0 then "new required: " + (.inputs.new_required | join(", ")) + "; " else "" end) + + "runtime " + (.inputs.runs_using | join(" → "))) end) |"' "$ARTIFACTS/report.json" >>"$ARTIFACTS/details.md" + +broken=$(jq -r '[.actions[] | select(.inputs.status == "ok") | select((.inputs.removed_but_used + .inputs.new_required) | length > 0) | .name] | join(", ")' "$ARTIFACTS/report.json") +untested=$(jq -r '[.actions[].usages[] | select(.runs_on_pr | not) | (if (.workflows | length) > 0 then .workflows[] else .file end)] | unique | join(", ")' "$ARTIFACTS/report.json") +unfetched=$(jq -r '[.actions[] | select(.inputs.status == "unavailable") | .name] | join(", ")' "$ARTIFACTS/report.json") + +if [ -n "$broken" ]; then + fingerprint "$broken" + fail "Input incompatibility for: $broken" +fi +problems=() +[ -n "$untested" ] && problems+=("not exercised by PR CI: $untested") +[ -n "$unfetched" ] && problems+=("could not compare inputs for: $unfetched") +if [ ${#problems[@]} -gt 0 ]; then + fingerprint "$untested|$unfetched" + [ -n "$untested" ] && recommend "Dry-run the release path the bumped action affects (e.g. manual-publish with dry-run) or review the action's changelog for those workflows." + warn "$(IFS='; '; echo "${problems[*]}")" +fi +pass "Every usage runs on pull_request CI; inputs compatible" diff --git a/scripts/dependency-pr/checks/actions-pinning.sh b/scripts/dependency-pr/checks/actions-pinning.sh new file mode 100755 index 000000000..6d2c1f89f --- /dev/null +++ b/scripts/dependency-pr/checks/actions-pinning.sh @@ -0,0 +1,30 @@ +#!/usr/bin/env bash +# SEC-7924 (#668): third-party actions must be pinned to a full commit SHA with +# a version comment. GitHub-owned and LaunchDarkly-owned actions are exempt. +source "$VERIFY_ROOT/lib/check.sh" +cd "$WT" || exit 1 + +exempt="${PIN_EXEMPT_OWNERS:-actions github launchdarkly}" +: >"$ARTIFACTS/violations" +while IFS=$'\t' read -r file ref comment; do + case "$ref" in ./* | docker://*) continue ;; esac + owner="${ref%%/*}" + [[ " $exempt " == *" $owner "* ]] && continue + sha="${ref##*@}" + if ! [[ "$sha" =~ ^[0-9a-f]{40}$ ]]; then + printf '%s: %s (not pinned to a commit SHA)\n' "$file" "$ref" >>"$ARTIFACTS/violations" + elif [ -z "$comment" ]; then + printf '%s: %s (missing "# vX" version comment)\n' "$file" "$ref" >>"$ARTIFACTS/violations" + fi +done < <(rg --no-line-number --with-filename -o -g '*.yml' -g '*.yaml' \ + '^\s*-?\s*uses:\s*["'\'']?([^\s"'\''#]+)["'\'']?(?:\s*#\s*(\S+))?' -r '$1 $2' .github/workflows .github/actions 2>/dev/null | + sed -E 's/:/\t/' | sort -u) + +if [ -s "$ARTIFACTS/violations" ]; then + sort -u -o "$ARTIFACTS/violations" "$ARTIFACTS/violations" + fingerprint_file "$ARTIFACTS/violations" + detail_block "$ARTIFACTS/violations" 20 + recommend "Pin third-party actions to a full commit SHA with a \`# vX.Y.Z\` comment." + fail "$(wc -l <"$ARTIFACTS/violations") unpinned third-party action reference(s)" +fi +pass "All third-party actions pinned to commit SHAs" diff --git a/scripts/dependency-pr/checks/binary-smoke.sh b/scripts/dependency-pr/checks/binary-smoke.sh new file mode 100755 index 000000000..dd06912fa --- /dev/null +++ b/scripts/dependency-pr/checks/binary-smoke.sh @@ -0,0 +1,59 @@ +#!/usr/bin/env bash +# Builds the CLI and exercises it offline: version, help for every command, +# and a dev-server start that serves the embedded UI and the /dev API. +source "$VERIFY_ROOT/lib/check.sh" + +bin="$ARTIFACTS/ldcli" +if ! build_ldcli "$bin" >"$ARTIFACTS/build.out" 2>&1; then + cat "$ARTIFACTS/build.out" + fingerprint "$(sed -E 's/:[0-9]+:[0-9]+:/:/' "$ARTIFACTS/build.out")" + fail "go build fails" +fi + +problems=() +version=$("$bin" --version 2>&1) || problems+=("--version exits non-zero") +detail "- \`ldcli --version\`: $version" + +help_dump "$bin" "$ARTIFACTS/help" +n_cmds=$(wc -l <"$ARTIFACTS/help/.commands") +if [ -s "$ARTIFACTS/help/.failures" ]; then + sort -o "$ARTIFACTS/help/.failures" "$ARTIFACTS/help/.failures" + problems+=("--help fails for: $(head -n5 "$ARTIFACTS/help/.failures" | paste -sd, -)") +fi +[ "$n_cmds" -lt 20 ] && problems+=("only $n_cmds commands discovered") + +port=$(free_port) || fail "no free port for dev-server" +mkdir -p "$ARTIFACTS/xdg-state" +# The flag is required but unused when no project is configured, so the +# server starts without reaching LaunchDarkly. +XDG_STATE_HOME="$ARTIFACTS/xdg-state" "$bin" dev-server start --port "$port" --analytics-opt-out \ + --access-token verify-smoke-placeholder >"$ARTIFACTS/dev-server.log" 2>&1 & +pid=$! +trap 'kill $pid 2>/dev/null; wait $pid 2>/dev/null' EXIT + +up=false +for _ in $(seq 1 60); do + if curl -fsS -o "$ARTIFACTS/ui.html" "http://127.0.0.1:$port/ui/" 2>/dev/null; then + up=true + break + fi + kill -0 $pid 2>/dev/null || break + sleep 0.5 +done +if [ "$up" = true ]; then + grep -q '
' "$ARTIFACTS/ui.html" || problems+=("/ui/ does not serve the app shell") + ui_kb=$(($(wc -c <"$ARTIFACTS/ui.html") / 1024)) + projects=$(curl -fsS "http://127.0.0.1:$port/dev/projects" 2>/dev/null) + jq -e 'type == "array"' <<<"$projects" >/dev/null 2>&1 || problems+=("/dev/projects did not return a JSON array: ${projects:0:80}") + detail "- dev-server served /ui/ (${ui_kb} KB) and /dev/projects (\`${projects:0:40}\`)" +else + tail -n 20 "$ARTIFACTS/dev-server.log" + detail_block "$ARTIFACTS/dev-server.log" 15 + problems+=("dev-server did not serve /ui/ within 30s") +fi + +if [ ${#problems[@]} -gt 0 ]; then + fingerprint "$(printf '%s\n' "${problems[@]}" | sed -E 's/\([0-9]+ KB\)//')" + fail "$(IFS='; '; echo "${problems[*]}")" +fi +pass "--version, --help for $n_cmds commands, dev-server UI and API all OK" diff --git a/scripts/dependency-pr/checks/ci-status.sh b/scripts/dependency-pr/checks/ci-status.sh new file mode 100755 index 000000000..11018ad58 --- /dev/null +++ b/scripts/dependency-pr/checks/ci-status.sh @@ -0,0 +1,31 @@ +#!/usr/bin/env bash +source "$VERIFY_ROOT/lib/check.sh" + +if [ "$(jq -r '.ci == null' "$PR_META")" = "true" ]; then + skip "No PR metadata (branch mode without an open PR)" +fi + +summary=$(jq -r ' + [.ci[] | {name, state: ((.conclusion // .state // .status // "") | ascii_upcase)}] as $c + | { + failing: [$c[] | select(.state | IN("FAILURE", "TIMED_OUT", "CANCELLED", "ACTION_REQUIRED", "STARTUP_FAILURE", "ERROR")) | .name] | unique, + pending: [$c[] | select(.state | IN("QUEUED", "IN_PROGRESS", "PENDING", "WAITING", "REQUESTED", "EXPECTED", "")) | .name] | unique, + total: ($c | length) + }' "$PR_META") + +failing=$(jq -r '.failing | join(", ")' <<<"$summary") +pending=$(jq -r '.pending | join(", ")' <<<"$summary") +total=$(jq -r '.total' <<<"$summary") +head=$(jq -r '.head_sha[0:7]' "$PR_META") + +detail "- CI results are for the PR head \`$head\` as pushed, which may be behind the base branch." +if [ -n "$failing" ]; then + fail "Failing on PR head $head: $failing" +fi +if [ -n "$pending" ]; then + warn "Still pending on PR head $head: $pending" +fi +if [ "$total" -eq 0 ]; then + warn "No CI checks reported on PR head $head" +fi +pass "All $total CI checks green on PR head $head" diff --git a/scripts/dependency-pr/checks/cli-help-diff.sh b/scripts/dependency-pr/checks/cli-help-diff.sh new file mode 100755 index 000000000..2e3833042 --- /dev/null +++ b/scripts/dependency-pr/checks/cli-help-diff.sh @@ -0,0 +1,27 @@ +#!/usr/bin/env bash +# CLI-library bumps (cobra, pflag, viper, glamour) can change flags, defaults, +# or help rendering without failing any test. Diff the full help tree. +source "$VERIFY_ROOT/lib/check.sh" + +for side in base pr; do + wt="$BASE_WT" + [ "$side" = pr ] && wt="$PR_WT" + if ! (cd "$wt" && run go build -o "$ARTIFACTS/ldcli-$side" .); then + skip "could not build the $side binary" + fi + help_dump "$ARTIFACTS/ldcli-$side" "$ARTIFACTS/help-$side" +done + +if diff -u "$ARTIFACTS/help-base/all.txt" "$ARTIFACTS/help-pr/all.txt" >"$ARTIFACTS/help.diff"; then + pass "Help output identical for $(wc -l <"$ARTIFACTS/help-pr/.commands") commands" +fi +added=$(comm -13 "$ARTIFACTS/help-base/.commands" "$ARTIFACTS/help-pr/.commands" | paste -sd, -) +removed=$(comm -23 "$ARTIFACTS/help-base/.commands" "$ARTIFACTS/help-pr/.commands" | paste -sd, -) +sections=$(awk '/^[ +-]?### /{s=substr($0, 2)} /^[+-][^+-]/{print s}' "$ARTIFACTS/help.diff" | sed -E 's/^#* ?//' | sort -u) +n_sections=$(printf '%s\n' "$sections" | grep -c .) +[ -n "$added" ] && detail "- Commands added: $added" +[ -n "$removed" ] && detail "- Commands removed: $removed" +detail "- Help text changed for: $(printf '%s\n' "$sections" | head -n 15 | paste -sd, -)" +detail_block "$ARTIFACTS/help.diff" 60 +fingerprint_file "$ARTIFACTS/help.diff" +warn "Help output differs for $n_sections command(s)${removed:+; removed: $removed}${added:+; added: $added}" diff --git a/scripts/dependency-pr/checks/docker-image.sh b/scripts/dependency-pr/checks/docker-image.sh new file mode 100755 index 000000000..ed1494beb --- /dev/null +++ b/scripts/dependency-pr/checks/docker-image.sh @@ -0,0 +1,51 @@ +#!/usr/bin/env bash +# The published image (Dockerfile.goreleaser) is only built at release time. +source "$VERIFY_ROOT/lib/check.sh" +cd "$WT" || exit 1 + +dockerfile="Dockerfile.goreleaser" +while IFS= read -r u; do + [ -n "$u" ] || continue + name=$(jq -r '.name' <<<"$u") + tag=$(jq -r '.to' <<<"$u") + case "$name" in + */*.*/* | *.*/*) detail "- \`$name:$tag\`: not on Docker Hub; tag not resolved" ;; + *) + repo="$name" + [[ "$repo" == */* ]] || repo="library/$repo" + if info=$(curl -fsS "https://hub.docker.com/v2/repositories/$repo/tags/$tag"); then + detail "- \`$name:$tag\` resolves (digest \`$(jq -r '.digest // "?"' <<<"$info" | cut -c1-19)\`, pushed $(jq -r '.tag_last_pushed // .last_updated // "?"' <<<"$info"))" + else + fingerprint "missing-tag:$name:$tag" + fail "Base image tag $name:$tag not found on Docker Hub" + fi + ;; + esac +done < <(updates_for docker) + +if ! command -v docker >/dev/null 2>&1 || ! docker info >/dev/null 2>&1; then + skip "Docker unavailable: base tag resolves, but the release image was not built or run" +fi + +ctx="$ARTIFACTS/ctx" +mkdir -p "$ctx" +# Static, CGO-free binary so it runs on musl; the release binary is built with +# CGO in goreleaser-cross, which this does not reproduce. +run env CGO_ENABLED=0 GOOS=linux go build -o "$ctx/ldcli" . || fail "static go build failed" +cp "$dockerfile" "$ctx/Dockerfile" +img="ldcli-verify:$SIDE-$$" +if ! run docker build -q -t "$img" "$ctx" >"$ARTIFACTS/build.out" 2>&1; then + cat "$ARTIFACTS/build.out" + fingerprint_file "$ARTIFACTS/build.out" + detail_block "$ARTIFACTS/build.out" 20 + fail "docker build of $dockerfile fails" +fi +out=$(docker run --rm "$img" --version 2>&1) +rc=$? +docker run --rm --entrypoint cat "$img" /etc/os-release 2>/dev/null | grep -E '^PRETTY_NAME' | sed 's/^/- image: /' >>"$ARTIFACTS/details.md" +docker rmi -f "$img" >/dev/null 2>&1 +if [ $rc -ne 0 ]; then + fingerprint "run-failed" + fail "Image builds but \`ldcli --version\` fails inside it: $out" +fi +pass "Release image builds and runs (\`$out\`)" diff --git a/scripts/dependency-pr/checks/downgrades.sh b/scripts/dependency-pr/checks/downgrades.sh new file mode 100755 index 000000000..ddd1137f5 --- /dev/null +++ b/scripts/dependency-pr/checks/downgrades.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env bash +source "$VERIFY_ROOT/lib/check.sh" + +downs=$(jq -r '[.updates[] | select(.semver == "downgrade") | "\(.name) \(.from) → \(.to)"] | join(", ")' "$CLASSIFICATION") +if [ -n "$downs" ]; then + detail "- Downgrades usually mean the branch is stale and the base branch already moved past these versions." + recommend "Rebase the PR (comment \`@dependabot rebase\`) or drop the stale pins that would now be downgrades." + fail "Downgrades: $downs" +fi +pass "No downgrades" diff --git a/scripts/dependency-pr/checks/go-build-vet.sh b/scripts/dependency-pr/checks/go-build-vet.sh new file mode 100755 index 000000000..31b56725e --- /dev/null +++ b/scripts/dependency-pr/checks/go-build-vet.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env bash +source "$VERIFY_ROOT/lib/check.sh" +cd "$WT" || exit 1 + +run go build ./... 2>&1 | tee "$ARTIFACTS/build.out" +if [ "${PIPESTATUS[0]}" -ne 0 ]; then + grep -E '\.go:[0-9]+' "$ARTIFACTS/build.out" | sed -E 's/:[0-9]+:[0-9]+:/:/' | sort -u >"$ARTIFACTS/build.errs" + fingerprint_file "$ARTIFACTS/build.errs" + detail_block "$ARTIFACTS/build.out" 30 + fail "go build fails: $(head -n1 "$ARTIFACTS/build.errs")" +fi + +run go vet ./... 2>&1 | tee "$ARTIFACTS/vet.out" +if [ "${PIPESTATUS[0]}" -ne 0 ]; then + grep -E '\.go:[0-9]+' "$ARTIFACTS/vet.out" | sed -E 's/:[0-9]+:[0-9]+:/:/' | sort -u >"$ARTIFACTS/vet.errs" + fingerprint_file "$ARTIFACTS/vet.errs" + detail_block "$ARTIFACTS/vet.out" 30 + severity attention + fail "go vet reports $(wc -l <"$ARTIFACTS/vet.errs") finding(s)" +fi +pass "go build ./... and go vet ./... succeed" diff --git a/scripts/dependency-pr/checks/go-directive.sh b/scripts/dependency-pr/checks/go-directive.sh new file mode 100755 index 000000000..d31f560fc --- /dev/null +++ b/scripts/dependency-pr/checks/go-directive.sh @@ -0,0 +1,18 @@ +#!/usr/bin/env bash +source "$VERIFY_ROOT/lib/check.sh" + +read -r gofrom goto < <(jq -r '"\(.go_directive.from // "none") \(.go_directive.to // "none")"' "$CLASSIFICATION") +read -r tfrom tto < <(jq -r '"\(.toolchain.from // "none") \(.toolchain.to // "none")"' "$CLASSIFICATION") +detail "- Local toolchain: $(go version | awk '{print $3}')" + +changes=() +[ "$gofrom" != "$goto" ] && changes+=("go directive $gofrom → $goto") +[ "$tfrom" != "$tto" ] && changes+=("toolchain $tfrom → $tto") + +if [ ${#changes[@]} -gt 0 ]; then + detail "- CI picks its Go version from go.mod (\`go-version-file\`), so this changes the Go used by every workflow." + detail "- golangci-lint v1.63.4 (pre-commit) must be able to read the new Go's export data, and the release image (goreleaser-cross, pinned by digest) must ship a new enough Go." + recommend "Confirm golangci-lint and the goreleaser-cross image support the new Go version before merging." + fail "$(IFS='; '; echo "${changes[*]}")" +fi +pass "go $gofrom (unchanged)" diff --git a/scripts/dependency-pr/checks/go-generate-drift.sh b/scripts/dependency-pr/checks/go-generate-drift.sh new file mode 100755 index 000000000..d6934640a --- /dev/null +++ b/scripts/dependency-pr/checks/go-generate-drift.sh @@ -0,0 +1,47 @@ +#!/usr/bin/env bash +# Catches the #720 failure mode: a generator bump merges green, but the +# committed output was never regenerated and regenerating breaks the build. +source "$VERIFY_ROOT/lib/check.sh" +cd "$WT" || exit 1 + +restore_tree +n_gen=$(rg -l '^//go:generate' --glob '*.go' . | wc -l) + +run go generate ./... 2>&1 | tee "$ARTIFACTS/generate.out" +if [ "${PIPESTATUS[0]}" -ne 0 ]; then + grep -vE '^[0-9]{4}/[0-9]{2}/[0-9]{2} ' "$ARTIFACTS/generate.out" | sort -u >"$ARTIFACTS/generate.errs" + fingerprint_file "$ARTIFACTS/generate.errs" + detail_block "$ARTIFACTS/generate.out" 30 + restore_tree + severity block + fail "go generate fails" +fi + +if [ -z "$(git status --porcelain)" ]; then + pass "No drift across $n_gen go:generate directive(s)" +fi + +git diff >"$ARTIFACTS/drift.patch" +git status --porcelain >"$ARTIFACTS/drift.files" +git diff --stat=100 --stat-graph-width=20 >"$ARTIFACTS/drift.stat" +n_files=$(wc -l <"$ARTIFACTS/drift.files") +detail "Files rewritten by \`go generate ./...\` (patch saved as drift.patch next to this check's status):" +detail_block "$ARTIFACTS/drift.stat" 20 + +build_ok=true +if ! go build ./... >"$ARTIFACTS/rebuild.out" 2>&1; then + build_ok=false + sed -E 's/:[0-9]+:[0-9]+:/:/' "$ARTIFACTS/rebuild.out" | sort -u >"$ARTIFACTS/rebuild.errs" + detail "The regenerated code does not compile:" + detail_block "$ARTIFACTS/rebuild.out" 15 +fi +fingerprint "$(cat "$ARTIFACTS/drift.stat" "$ARTIFACTS/rebuild.errs" 2>/dev/null)" +restore_tree + +if [ "$build_ok" = false ]; then + severity block + recommend "Regenerate (\`make generate\`) in this PR and bump the generator's runtime library alongside it (e.g. oapi-codegen with oapi-codegen/runtime) so the regenerated code compiles." + fail "go generate rewrites $n_files file(s) and the regenerated code does not compile" +fi +recommend "Run \`make generate\` and commit the regenerated files." +fail "go generate rewrites $n_files file(s); regenerated code compiles" diff --git a/scripts/dependency-pr/checks/go-mod-tidy.sh b/scripts/dependency-pr/checks/go-mod-tidy.sh new file mode 100755 index 000000000..a12840a98 --- /dev/null +++ b/scripts/dependency-pr/checks/go-mod-tidy.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env bash +source "$VERIFY_ROOT/lib/check.sh" +cd "$WT" || exit 1 + +if ! run go mod tidy; then + restore_tree + fail "go mod tidy failed" +fi +if ! git diff --quiet -- go.mod go.sum; then + git diff -- go.mod go.sum >"$ARTIFACTS/tidy.patch" + git diff --stat -- go.mod go.sum >"$ARTIFACTS/tidy.stat" + detail "go mod tidy would change:" + detail_block "$ARTIFACTS/tidy.patch" 30 + fingerprint_file "$ARTIFACTS/tidy.patch" + n=$(grep -c '^[+-][^+-]' "$ARTIFACTS/tidy.patch") + restore_tree + recommend "Run \`go mod tidy\` and commit go.mod/go.sum." + fail "go mod tidy changes go.mod/go.sum ($n lines)" +fi +restore_tree +pass "go.mod/go.sum are tidy" diff --git a/scripts/dependency-pr/checks/go-test.sh b/scripts/dependency-pr/checks/go-test.sh new file mode 100755 index 000000000..e97340261 --- /dev/null +++ b/scripts/dependency-pr/checks/go-test.sh @@ -0,0 +1,16 @@ +#!/usr/bin/env bash +source "$VERIFY_ROOT/lib/check.sh" +cd "$WT" || exit 1 + +run go test ./... 2>&1 | tee "$ARTIFACTS/test.out" +rc=${PIPESTATUS[0]} +pkgs=$(grep -cE '^(ok|FAIL)\s' "$ARTIFACTS/test.out") + +if [ "$rc" -ne 0 ]; then + grep -E '^(--- FAIL|FAIL\s|panic:)' "$ARTIFACTS/test.out" | + sed -E 's/ \([0-9.]+s\)//; s/\s+[0-9.]+s$//; s/\s+\[[^]]*\]$//' | sort -u >"$ARTIFACTS/failures" + fingerprint_file "$ARTIFACTS/failures" + detail_block "$ARTIFACTS/failures" 30 + fail "$(grep -c '^FAIL\s' "$ARTIFACTS/failures") package(s) failing: $(grep '^FAIL\s' "$ARTIFACTS/failures" | awk '{print $2}' | sed 's#github.com/launchdarkly/ldcli/##' | paste -sd, -)" +fi +pass "$pkgs packages pass" diff --git a/scripts/dependency-pr/checks/golangci-lint.sh b/scripts/dependency-pr/checks/golangci-lint.sh new file mode 100755 index 000000000..c216d8d70 --- /dev/null +++ b/scripts/dependency-pr/checks/golangci-lint.sh @@ -0,0 +1,15 @@ +#!/usr/bin/env bash +source "$VERIFY_ROOT/lib/check.sh" +cd "$WT" || exit 1 + +if ! command -v golangci-lint >/dev/null 2>&1; then + skip "golangci-lint not installed (CI runs v1.63.4 through pre-commit)" +fi +run golangci-lint run ./... 2>&1 | tee "$ARTIFACTS/lint.out" +if [ "${PIPESTATUS[0]}" -ne 0 ]; then + grep -E '\.go:[0-9]+' "$ARTIFACTS/lint.out" | sed -E 's/:[0-9]+:[0-9]+:/:/' | sort -u >"$ARTIFACTS/lint.errs" + fingerprint_file "$ARTIFACTS/lint.errs" + detail_block "$ARTIFACTS/lint.out" 30 + fail "golangci-lint reports $(wc -l <"$ARTIFACTS/lint.errs") finding(s)" +fi +pass "golangci-lint clean ($(golangci-lint --version | awk '{print $4}'))" diff --git a/scripts/dependency-pr/checks/govulncheck.sh b/scripts/dependency-pr/checks/govulncheck.sh new file mode 100755 index 000000000..4da5aec21 --- /dev/null +++ b/scripts/dependency-pr/checks/govulncheck.sh @@ -0,0 +1,33 @@ +#!/usr/bin/env bash +# Compares reachable vulnerabilities between base and PR. Needs network for the vuln DB. +source "$VERIFY_ROOT/lib/check.sh" + +GOVULNCHECK_VERSION="v1.1.4" +vulns() { + if command -v govulncheck >/dev/null 2>&1; then + (cd "$1" && govulncheck ./...) + else + (cd "$1" && go run "golang.org/x/vuln/cmd/govulncheck@$GOVULNCHECK_VERSION" ./...) + fi +} + +vulns "$BASE_WT" >"$ARTIFACTS/base.out" 2>&1 +rc_base=$? +vulns "$PR_WT" >"$ARTIFACTS/pr.out" 2>&1 +rc_pr=$? +cat "$ARTIFACTS/pr.out" +# govulncheck exits 3 when vulnerabilities are found; anything else non-zero is a tool failure. +for rc in $rc_base $rc_pr; do + if [ "$rc" -ne 0 ] && [ "$rc" -ne 3 ]; then + skip "govulncheck could not run (exit $rc); see log" + fi +done + +ids() { grep -oE 'GO-[0-9]{4}-[0-9]+' "$1" | sort -u; } +new=$(comm -13 <(ids "$ARTIFACTS/base.out") <(ids "$ARTIFACTS/pr.out") | paste -sd, -) +fixed=$(comm -23 <(ids "$ARTIFACTS/base.out") <(ids "$ARTIFACTS/pr.out") | paste -sd, -) +[ -n "$fixed" ] && detail "- Fixed by this PR: $fixed" +if [ -n "$new" ]; then + fail "New reachable vulnerabilities: $new" +fi +pass "No new reachable vulnerabilities${fixed:+ (fixes $fixed)}" diff --git a/scripts/dependency-pr/checks/npm-wrapper-install.sh b/scripts/dependency-pr/checks/npm-wrapper-install.sh new file mode 100755 index 000000000..72c086a9b --- /dev/null +++ b/scripts/dependency-pr/checks/npm-wrapper-install.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env bash +# Installs the root npm package the way users do: the go-npm postinstall +# downloads the GitHub release binary for the version in package.json. +source "$VERIFY_ROOT/lib/check.sh" + +version=$(jq -r '.version' "$WT/package.json") +tmp="$ARTIFACTS/install" +rm -rf "$tmp" && mkdir -p "$tmp" +cp "$WT/package.json" "$WT/package-lock.json" "$tmp/" + +(cd "$tmp" && run npm ci --no-audit --no-fund) >"$ARTIFACTS/npm-ci.out" 2>&1 +rc=$? +cat "$ARTIFACTS/npm-ci.out" +if [ $rc -ne 0 ]; then + grep -E 'npm error|Error' "$ARTIFACTS/npm-ci.out" | grep -v 'A complete log' | sed -E "s#$tmp/?##g" | sort -u >"$ARTIFACTS/errs" + fingerprint_file "$ARTIFACTS/errs" + detail_block "$ARTIFACTS/errs" 20 + fail "npm ci of the wrapper package fails (postinstall downloads v$version)" +fi + +bin="$tmp/bin/ldcli" +if [ ! -x "$bin" ]; then + fingerprint "no-binary" + fail "postinstall finished but bin/ldcli is missing" +fi +out=$("$bin" --version 2>&1) +detail "- \`bin/ldcli --version\`: $out" +if ! grep -q "$version" <<<"$out"; then + fingerprint "version-mismatch" + fail "Installed binary reports '$out', expected $version" +fi + +(cd "$WT" && run npm pack --dry-run --json 2>/dev/null) >"$ARTIFACTS/pack.json" +files=$(jq -r '.[0].files | map(.path) | join(", ")' "$ARTIFACTS/pack.json" 2>/dev/null) +detail "- \`npm pack\` contents: ${files:-unavailable}" +pass "npm install fetches a working ldcli $version" diff --git a/scripts/dependency-pr/checks/pr-state.sh b/scripts/dependency-pr/checks/pr-state.sh new file mode 100755 index 000000000..f51d69380 --- /dev/null +++ b/scripts/dependency-pr/checks/pr-state.sh @@ -0,0 +1,34 @@ +#!/usr/bin/env bash +source "$VERIFY_ROOT/lib/check.sh" + +merge_status=$(jq -r '.merge.status' "$PR_META") +behind=$(jq -r '.behind_by // 0' "$PR_META") +author=$(jq -r '.author // ""' "$PR_META") +base_ref=$(jq -r '.base_ref' "$PR_META") + +if [ "$behind" -gt 0 ]; then + detail "- Branch is $behind commit(s) behind \`$base_ref\`; checks ran on the PR merged into the current \`$base_ref\`." +fi + +if [ "$merge_status" = "conflict" ]; then + files=$(jq -r '.merge.conflicts | join(", ")' "$PR_META") + detail "- Conflicting files: $files. Checks ran on the PR head as-is, compared with its merge base." + recommend "Rebase the PR (comment \`@dependabot rebase\`) to resolve conflicts with \`$base_ref\`." + fail "Conflicts with $base_ref ($files)" +fi + +problems=() +other=$(jq -r '.other_files | join(", ")' "$CLASSIFICATION") +if [ -n "$other" ]; then + detail "- Files outside dependency manifests: $other" + problems+=("touches non-manifest files: $other") +fi +case "$author" in + app/dependabot | dependabot\[bot\] | dependabot | "") ;; + *) problems+=("author is $author, not Dependabot") ;; +esac + +if [ ${#problems[@]} -gt 0 ]; then + warn "$(IFS='; '; echo "${problems[*]}")" +fi +pass "Merges cleanly into $base_ref; only dependency manifests changed" diff --git a/scripts/dependency-pr/checks/registry.json b/scripts/dependency-pr/checks/registry.json new file mode 100644 index 000000000..3b1ddc0f7 --- /dev/null +++ b/scripts/dependency-pr/checks/registry.json @@ -0,0 +1,38 @@ +{ + "schema": 1, + "_doc": "Baseline checks in execution order. when: ecosystems that trigger the check ('any' = always). packages: optional regex on updated package names. on_fail: block | attention. compare_base: re-run on base when the PR result is fail/warn to detect pre-existing issues. required: a skip counts as unverified (needs a human). profile: fast (default) or full.", + "checks": [ + { "id": "pr-state", "title": "PR merges cleanly and only touches dependency manifests", "script": "pr-state.sh", "when": ["any"], "on_fail": "attention", "compare_base": false, "required": false, "timeout": 60 }, + { "id": "ci-status", "title": "CI rollup on the PR head", "script": "ci-status.sh", "when": ["any"], "on_fail": "attention", "compare_base": false, "required": false, "timeout": 60 }, + { "id": "downgrades", "title": "No dependency is downgraded relative to base", "script": "downgrades.sh", "when": ["any"], "on_fail": "attention", "compare_base": false, "required": false, "timeout": 60 }, + + { "id": "go-mod-tidy", "title": "go mod tidy leaves go.mod/go.sum unchanged", "script": "go-mod-tidy.sh", "when": ["gomod"], "on_fail": "attention", "compare_base": true, "required": true, "timeout": 600 }, + { "id": "go-directive", "title": "go/toolchain directive unchanged", "script": "go-directive.sh", "when": ["gomod"], "on_fail": "attention", "compare_base": false, "required": true, "timeout": 60 }, + { "id": "go-build-vet", "title": "go build and go vet", "script": "go-build-vet.sh", "when": ["gomod"], "on_fail": "block", "compare_base": true, "required": true, "timeout": 900 }, + { "id": "go-test", "title": "go test ./... (hermetic env)", "script": "go-test.sh", "when": ["gomod"], "on_fail": "block", "compare_base": true, "required": true, "timeout": 1200 }, + { "id": "go-generate-drift", "title": "go generate leaves no diff and regenerated code builds", "script": "go-generate-drift.sh", "when": ["gomod"], "on_fail": "attention", "compare_base": true, "required": true, "timeout": 900 }, + { "id": "release-snapshot", "title": "All release targets cross-compile in goreleaser-cross (CGO)", "script": "release-snapshot.sh", "when": ["gomod"], "on_fail": "block", "compare_base": true, "required": false, "profile": "full", "timeout": 2400 }, + { "id": "govulncheck", "title": "govulncheck: no new reachable vulnerabilities", "script": "govulncheck.sh", "when": ["gomod"], "on_fail": "attention", "compare_base": false, "required": false, "profile": "full", "timeout": 900 }, + { "id": "golangci-lint", "title": "golangci-lint (pre-commit config)", "script": "golangci-lint.sh", "when": ["gomod"], "on_fail": "attention", "compare_base": true, "required": false, "profile": "full", "timeout": 900 }, + + { "id": "ui-npm-ci", "title": "npm ci resolves (no ERESOLVE / peer conflicts)", "script": "ui-npm-ci.sh", "when": ["npm-ui"], "on_fail": "block", "compare_base": true, "required": true, "timeout": 900 }, + { "id": "ui-lint", "title": "UI lint (eslint)", "script": "ui-lint.sh", "when": ["npm-ui"], "on_fail": "attention", "compare_base": true, "required": true, "timeout": 600 }, + { "id": "ui-prettier", "title": "UI formatting (prettier --check)", "script": "ui-prettier.sh", "when": ["npm-ui"], "on_fail": "attention", "compare_base": true, "required": true, "timeout": 600 }, + { "id": "ui-test", "title": "UI tests (vitest)", "script": "ui-test.sh", "when": ["npm-ui"], "on_fail": "block", "compare_base": true, "required": true, "timeout": 900 }, + { "id": "ui-build-drift", "title": "UI builds and committed dist/ is up to date", "script": "ui-build-drift.sh", "when": ["npm-ui"], "on_fail": "attention", "compare_base": true, "required": true, "timeout": 900 }, + { "id": "ui-npm-ls", "title": "npm ls: dependency tree valid (peers satisfied)", "script": "ui-npm-ls.sh", "when": ["npm-ui"], "on_fail": "attention", "compare_base": true, "required": false, "timeout": 300 }, + { "id": "ui-npm-audit", "title": "npm audit (runtime deps): no new advisories vs base", "script": "ui-npm-audit.sh", "when": ["npm-ui"], "on_fail": "attention", "compare_base": false, "required": false, "timeout": 300 }, + { "id": "ui-dep-usage", "title": "Updated UI dependencies are actually used", "script": "ui-dep-usage.sh", "when": ["npm-ui"], "on_fail": "attention", "compare_base": false, "required": false, "timeout": 120 }, + + { "id": "npm-wrapper-install", "title": "npm wrapper installs and its postinstall fetches a working binary", "script": "npm-wrapper-install.sh", "when": ["npm-wrapper"], "on_fail": "block", "compare_base": true, "required": true, "timeout": 600 }, + + { "id": "actions-pinning", "title": "Third-party actions pinned to a commit SHA (SEC-7924)", "script": "actions-pinning.sh", "when": ["github-actions"], "on_fail": "attention", "compare_base": true, "required": true, "timeout": 120 }, + { "id": "actionlint", "title": "actionlint", "script": "actionlint.sh", "when": ["github-actions"], "on_fail": "attention", "compare_base": true, "required": true, "timeout": 600 }, + { "id": "actions-coverage", "title": "Bumped actions: CI coverage and input compatibility", "script": "actions-coverage.sh", "when": ["github-actions"], "on_fail": "attention", "compare_base": false, "required": true, "timeout": 300 }, + + { "id": "docker-image", "title": "Docker base image resolves; release image builds and runs", "script": "docker-image.sh", "when": ["docker"], "on_fail": "block", "compare_base": true, "required": true, "timeout": 900 }, + + { "id": "binary-smoke", "title": "Binary smoke test: --version, help for all commands, dev-server serves UI and API", "script": "binary-smoke.sh", "when": ["gomod", "npm-ui", "docker"], "on_fail": "block", "compare_base": true, "required": true, "timeout": 600 }, + { "id": "cli-help-diff", "title": "CLI help output unchanged vs base", "script": "cli-help-diff.sh", "when": ["gomod"], "on_fail": "attention", "compare_base": false, "required": false, "timeout": 600 } + ] +} diff --git a/scripts/dependency-pr/checks/release-snapshot.sh b/scripts/dependency-pr/checks/release-snapshot.sh new file mode 100755 index 000000000..640c2689c --- /dev/null +++ b/scripts/dependency-pr/checks/release-snapshot.sh @@ -0,0 +1,34 @@ +#!/usr/bin/env bash +# Cross-compiles every release target the way releases do: goreleaser inside +# goreleaser-cross (CGO for SQLite with musl static, mingw, osxcross). PR CI +# only builds linux/amd64 with the host gcc. A CGO-off cross build is not a +# substitute: internal/dev_server/db/backup needs CGO to compile at all. +source "$VERIFY_ROOT/lib/check.sh" +cd "$WT" || exit 1 + +if ! command -v docker >/dev/null 2>&1 || ! docker info >/dev/null 2>&1; then + skip "Docker unavailable; release cross-compile (goreleaser-cross) not run" +fi +image=$(rg -o --no-filename 'ghcr\.io/launchdarkly/goreleaser-cross@sha256:[0-9a-f]{64}' .github/actions/publish/action.yml | head -n1) +[ -n "$image" ] || skip "could not find the goreleaser-cross image in .github/actions/publish/action.yml" +detail "- Image: \`${image:0:60}…\`" + +# A worktree's .git file points into the main repository's git dir, so both are +# mounted at their real paths (the publish action mounts "$PWD:$PWD" likewise). +common=$(cd "$WT" && cd "$(git rev-parse --git-common-dir)" && pwd) +run docker run --rm -v "$WT:$WT" -v "$common:$common" -w "$WT" \ + --entrypoint bash "$image" -c \ + "git config --global --add safe.directory '*' && goreleaser build --snapshot --clean --config .goreleaser.yaml" \ + 2>&1 | tee "$ARTIFACTS/goreleaser.out" +rc=${PIPESTATUS[0]} +# The container runs as root; remove its dist/ before restoring the tree. +docker run --rm -v "$WT:$WT" --entrypoint rm "$image" -rf "$WT/dist" >/dev/null 2>&1 +restore_tree + +if [ "$rc" -ne 0 ]; then + grep -E 'error|failed|\.go:[0-9]+' "$ARTIFACTS/goreleaser.out" | sed -E 's/:[0-9]+:[0-9]+:/:/; s/[0-9.]+m?s\b//g' | sort -u >"$ARTIFACTS/errs" + fingerprint_file "$ARTIFACTS/errs" + detail_block "$ARTIFACTS/errs" 30 + fail "goreleaser snapshot build fails" +fi +pass "All release targets build in goreleaser-cross" diff --git a/scripts/dependency-pr/checks/ui-build-drift.sh b/scripts/dependency-pr/checks/ui-build-drift.sh new file mode 100755 index 000000000..408c6858b --- /dev/null +++ b/scripts/dependency-pr/checks/ui-build-drift.sh @@ -0,0 +1,27 @@ +#!/usr/bin/env bash +# The rebuilt dist/ is left in place on purpose: binary-smoke runs later and +# embeds it, which tests what main would serve once dist is rebuilt. +source "$VERIFY_ROOT/lib/check.sh" +ensure_ui_deps || skip "npm ci failed; cannot build" +cd "$WT/$UI_DIR_REL" || exit 1 + +run npm run build 2>&1 | tee "$ARTIFACTS/build.out" +if [ "${PIPESTATUS[0]}" -ne 0 ]; then + grep -E 'error|Error' "$ARTIFACTS/build.out" | sed -E "s#$WT/##g; s/\([0-9]+,[0-9]+\)//" | sort -u >"$ARTIFACTS/build.errs" + fingerprint_file "$ARTIFACTS/build.errs" + detail_block "$ARTIFACTS/build.out" 30 + severity block + fail "npm run build fails" +fi + +changed=$(git -C "$WT" status --porcelain -- "$UI_DIR_REL") +if [ -n "$changed" ]; then + git -C "$WT" diff --stat -- "$UI_DIR_REL" >"$ARTIFACTS/drift.stat" + printf '%s\n' "$changed" >>"$ARTIFACTS/drift.stat" + fingerprint "$(git -C "$WT" diff -- "$UI_DIR_REL")" + detail "Build output differs from the committed files:" + detail_block "$ARTIFACTS/drift.stat" 15 + recommend "Rebuild the UI and commit dist: \`cd internal/dev_server/ui && npm ci && npm run build\` (the dev-server UI CI job fails until then)." + fail "Committed dist/ is stale: the build rewrites $(printf '%s\n' "$changed" | wc -l) file(s)" +fi +pass "Build succeeds; committed dist/ matches" diff --git a/scripts/dependency-pr/checks/ui-dep-usage.sh b/scripts/dependency-pr/checks/ui-dep-usage.sh new file mode 100755 index 000000000..7900db33e --- /dev/null +++ b/scripts/dependency-pr/checks/ui-dep-usage.sh @@ -0,0 +1,64 @@ +#!/usr/bin/env bash +# A dependency that is never imported (react-window in #831) passes CI no matter +# how breaking the bump is. Flag it so the reviewer can remove it instead. +source "$VERIFY_ROOT/lib/check.sh" +ui="$WT/$UI_DIR_REL" +ensure_ui_deps >/dev/null 2>&1 || true + +re_escape() { printf '%s' "$1" | sed -E 's/[][\.*^$+?(){}|/]/\\&/g'; } + +imported() { + local pat + pat=$(re_escape "$1") + rg -q -e "(from|import|require\()\s*['\"]${pat}(/[^'\"]*)?['\"]" \ + "$ui/src" "$ui"/*.config.* "$ui"/eslint.config.js "$ui/index.html" 2>/dev/null +} + +mentioned_in_tooling() { + local pat + pat=$(re_escape "$1") + rg -q -e "$pat" "$ui"/*.config.* "$ui"/tsconfig*.json "$ui"/eslint.config.js 2>/dev/null && return 0 + jq -e --arg n "$1" '.scripts // {} | to_entries | any(.value | contains($n))' "$ui/package.json" >/dev/null && return 0 + # Tools invoked through package.json scripts by their bin name. + local bins + bins=$(jq -r '.bin // {} | if type == "string" then empty else keys[] end' "$ui/node_modules/$1/package.json" 2>/dev/null) + for b in $bins; do + jq -e --arg b "$b" '.scripts // {} | to_entries | any(.value | test("(^|[ &|;])" + $b + "( |$)"))' "$ui/package.json" >/dev/null && return 0 + done + return 1 +} + +unused=() +while IFS= read -r u; do + [ -n "$u" ] || continue + name=$(jq -r '.name' <<<"$u") + dev=$(jq -r '.dev' <<<"$u") + target="$name" + if [[ "$name" == @types/* ]]; then + target="${name#@types/}" + [[ "$target" == *__* ]] && target="@${target/__//}" + fi + meta="$ui/node_modules/$name/package.json" + if [ -f "$meta" ]; then + engines=$(jq -r '.engines.node // empty' "$meta") + peers=$(jq -r '.peerDependencies // {} | to_entries | map("\(.key)@\(.value)") | join(", ")' "$meta") + deprecated=$(jq -r '.deprecated // empty' "$meta") + [ -n "$engines" ] && detail "- \`$name\` requires node \`$engines\`" + [ -n "$peers" ] && detail "- \`$name\` peers: $peers" + [ -n "$deprecated" ] && detail "- \`$name\` is deprecated: $deprecated" + fi + if imported "$target"; then + detail "- \`$name\` is imported by the UI" + elif [ "$dev" = "true" ] && mentioned_in_tooling "$target"; then + detail "- \`$name\` is used by tooling/config" + else + unused+=("$name") + detail "- \`$name\` is not imported in src/ or referenced by tooling" + fi +done < <(updates_for npm-ui | jq -c 'select(.direct)') + +if [ ${#unused[@]} -gt 0 ]; then + recommend "Remove unused dependencies instead of bumping them: ${unused[*]}" + warn "Not used anywhere: ${unused[*]} (the bump has no runtime effect; consider removing)" +fi +pass "All updated direct dependencies are used" diff --git a/scripts/dependency-pr/checks/ui-lint.sh b/scripts/dependency-pr/checks/ui-lint.sh new file mode 100755 index 000000000..da411b7bf --- /dev/null +++ b/scripts/dependency-pr/checks/ui-lint.sh @@ -0,0 +1,13 @@ +#!/usr/bin/env bash +source "$VERIFY_ROOT/lib/check.sh" +ensure_ui_deps || skip "npm ci failed; cannot lint" +cd "$WT/$UI_DIR_REL" || exit 1 + +run npm run lint 2>&1 | tee "$ARTIFACTS/lint.out" +if [ "${PIPESTATUS[0]}" -ne 0 ]; then + sed -E "s#$WT/##g" "$ARTIFACTS/lint.out" | grep -E 'error|warning' | sed -E 's/^\s*[0-9]+:[0-9]+\s+//' | sort -u >"$ARTIFACTS/lint.errs" + fingerprint_file "$ARTIFACTS/lint.errs" + detail_block "$ARTIFACTS/lint.out" 30 + fail "eslint reports problems ($(grep -oE '[0-9]+ problems?' "$ARTIFACTS/lint.out" | tail -n1))" +fi +pass "eslint clean" diff --git a/scripts/dependency-pr/checks/ui-npm-audit.sh b/scripts/dependency-pr/checks/ui-npm-audit.sh new file mode 100755 index 000000000..f321edeeb --- /dev/null +++ b/scripts/dependency-pr/checks/ui-npm-audit.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env bash +# Compares npm audit for runtime dependencies between base and PR. Needs the registry. +source "$VERIFY_ROOT/lib/check.sh" + +audit() { + (cd "$1/$UI_DIR_REL" && npm audit --omit=dev --json 2>/dev/null) | + jq '{error: (.error.summary // null), + vulns: ((.vulnerabilities // {}) | to_entries + | map({key: .key, value: .value.severity}) | from_entries)}' +} +audit "$BASE_WT" >"$ARTIFACTS/base.json" || skip "npm audit unavailable on base" +audit "$PR_WT" >"$ARTIFACTS/pr.json" || skip "npm audit unavailable on PR" +cat "$ARTIFACTS/pr.json" +for s in base pr; do + err=$(jq -r '.error // empty' "$ARTIFACTS/$s.json") + [ -n "$err" ] && skip "npm audit failed on $s: $err" +done + +jq -n --slurpfile b "$ARTIFACTS/base.json" --slurpfile p "$ARTIFACTS/pr.json" ' + def rank: {"info":0,"low":1,"moderate":2,"high":3,"critical":4}[.] // 0; + $b[0].vulns as $b | $p[0].vulns as $p + | { + new: [$p | to_entries[] | select(($b[.key] // null) == null or (.value | rank) > ($b[.key] | rank)) | "\(.key) (\(.value))"], + new_serious: [$p | to_entries[] | select((.value | rank) >= 3 and (($b[.key] // null) == null or (.value | rank) > ($b[.key] | rank))) | "\(.key) (\(.value))"], + fixed: [$b | to_entries[] | select(($p[.key] // null) == null) | "\(.key) (\(.value))"], + total_pr: ($p | length), total_base: ($b | length) + }' >"$ARTIFACTS/delta.json" + +fixed=$(jq -r '.fixed | join(", ")' "$ARTIFACTS/delta.json") +[ -n "$fixed" ] && detail "- Advisories resolved by this PR: $fixed" +serious=$(jq -r '.new_serious | join(", ")' "$ARTIFACTS/delta.json") +new=$(jq -r '.new | join(", ")' "$ARTIFACTS/delta.json") +read -r tb tp < <(jq -r '"\(.total_base) \(.total_pr)"' "$ARTIFACTS/delta.json") +[ -n "$serious" ] && fail "New high/critical advisories: $serious" +[ -n "$new" ] && warn "New advisories: $new" +pass "No new advisories (base $tb, PR $tp affected packages)" diff --git a/scripts/dependency-pr/checks/ui-npm-ci.sh b/scripts/dependency-pr/checks/ui-npm-ci.sh new file mode 100755 index 000000000..2771494b3 --- /dev/null +++ b/scripts/dependency-pr/checks/ui-npm-ci.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env bash +source "$VERIFY_ROOT/lib/check.sh" +cd "$WT/$UI_DIR_REL" || exit 1 + +detail "- node $(node --version), npm $(npm --version). CI uses \`node-version: lts/*\`." +run npm ci --no-audit --no-fund 2>&1 | tee "$ARTIFACTS/npm-ci.out" +if [ "${PIPESTATUS[0]}" -ne 0 ]; then + # Drop log-file paths and timestamps so base and PR fingerprints compare. + grep -E 'ERESOLVE|While resolving|Found:|Could not resolve|peer |Conflicting peer|code E' "$ARTIFACTS/npm-ci.out" | + grep -v 'A complete log' | sed -E "s#$WT/##g" | sort -u >"$ARTIFACTS/npm-ci.errs" + fingerprint_file "$ARTIFACTS/npm-ci.errs" + detail_block "$ARTIFACTS/npm-ci.errs" 25 + if grep -q ERESOLVE "$ARTIFACTS/npm-ci.out"; then + conflict=$(grep -m1 -E 'Could not resolve dependency|Conflicting peer dependency' -A2 "$ARTIFACTS/npm-ci.out" | grep -oE '(peer )?[@a-z0-9/._-]+@"?[^ "]+"?( from [@a-z0-9/._-]+@[^ ]+)?' | head -n2 | paste -sd' ' -) + recommend "Resolve the peer-dependency conflict with a coordinated upgrade or a scoped \`overrides\` entry (see #777), or close in favor of a focused PR." + fail "npm ci fails with ERESOLVE peer conflict${conflict:+: $conflict}" + fi + fail "npm ci fails: $(grep -m1 'npm error' "$ARTIFACTS/npm-ci.out" | sed 's/^npm error //')" +fi +sha256sum node_modules/.verify-lock-hash +pass "npm ci succeeds" diff --git a/scripts/dependency-pr/checks/ui-npm-ls.sh b/scripts/dependency-pr/checks/ui-npm-ls.sh new file mode 100755 index 000000000..5810cc086 --- /dev/null +++ b/scripts/dependency-pr/checks/ui-npm-ls.sh @@ -0,0 +1,14 @@ +#!/usr/bin/env bash +source "$VERIFY_ROOT/lib/check.sh" +ensure_ui_deps || skip "npm ci failed" +cd "$WT/$UI_DIR_REL" || exit 1 + +run npm ls --all >/dev/null 2>"$ARTIFACTS/ls.err" +if [ $? -ne 0 ]; then + cat "$ARTIFACTS/ls.err" + grep -E 'npm error (invalid|missing|extraneous|peer)' "$ARTIFACTS/ls.err" | sed -E "s#$WT/##g" | sort -u >"$ARTIFACTS/problems" + fingerprint_file "$ARTIFACTS/problems" + detail_block "$ARTIFACTS/problems" 20 + warn "npm ls reports $(wc -l <"$ARTIFACTS/problems") problem(s): $(head -n2 "$ARTIFACTS/problems" | sed -E 's/^npm error //; s# /?internal/dev_server/ui/node_modules/[^ ]*##' | paste -sd';' -)" +fi +pass "npm ls --all reports a valid tree" diff --git a/scripts/dependency-pr/checks/ui-prettier.sh b/scripts/dependency-pr/checks/ui-prettier.sh new file mode 100755 index 000000000..0e9e3b4eb --- /dev/null +++ b/scripts/dependency-pr/checks/ui-prettier.sh @@ -0,0 +1,14 @@ +#!/usr/bin/env bash +source "$VERIFY_ROOT/lib/check.sh" +ensure_ui_deps || skip "npm ci failed; cannot run prettier" +cd "$WT/$UI_DIR_REL" || exit 1 + +run npx --no-install prettier . --check 2>&1 | tee "$ARTIFACTS/prettier.out" +if [ "${PIPESTATUS[0]}" -ne 0 ]; then + grep -E '^\[warn\] ' "$ARTIFACTS/prettier.out" | grep -v 'Code style issues' | sort -u >"$ARTIFACTS/prettier.files" + fingerprint_file "$ARTIFACTS/prettier.files" + detail_block "$ARTIFACTS/prettier.files" 20 + recommend "Run \`npm run prettier:write\` in internal/dev_server/ui and commit (a prettier bump can reformat files)." + fail "prettier would reformat $(wc -l <"$ARTIFACTS/prettier.files") file(s)" +fi +pass "prettier --check clean ($(npx --no-install prettier --version))" diff --git a/scripts/dependency-pr/checks/ui-test.sh b/scripts/dependency-pr/checks/ui-test.sh new file mode 100755 index 000000000..4291d2e9b --- /dev/null +++ b/scripts/dependency-pr/checks/ui-test.sh @@ -0,0 +1,15 @@ +#!/usr/bin/env bash +source "$VERIFY_ROOT/lib/check.sh" +ensure_ui_deps || skip "npm ci failed; cannot run tests" +cd "$WT/$UI_DIR_REL" || exit 1 + +run npm test 2>&1 | tee "$ARTIFACTS/test.out" +rc=${PIPESTATUS[0]} +counts=$(grep -E '^\s*Tests\s' "$ARTIFACTS/test.out" | tail -n1 | sed -E 's/\s+\(.*//; s/^\s*Tests\s+//') +if [ "$rc" -ne 0 ]; then + grep -E '(FAIL|×|✗)\s' "$ARTIFACTS/test.out" | sed -E "s#$WT/##g; s/ [0-9]+ms$//" | sort -u >"$ARTIFACTS/failures" + fingerprint_file "$ARTIFACTS/failures" + detail_block "$ARTIFACTS/failures" 30 + fail "vitest fails${counts:+ ($counts)}" +fi +pass "vitest passes${counts:+ ($counts)}" diff --git a/scripts/dependency-pr/lib/actions_analysis.py b/scripts/dependency-pr/lib/actions_analysis.py new file mode 100755 index 000000000..7f5cc5605 --- /dev/null +++ b/scripts/dependency-pr/lib/actions_analysis.py @@ -0,0 +1,136 @@ +#!/usr/bin/env python3 +"""Where each bumped action is used, whether those workflows run on pull_request, +and (when gh is available) whether the inputs the repo passes still exist upstream. + +Usage: actions_analysis.py (prints a JSON report) +""" +import base64 +import glob +import json +import os +import subprocess +import sys + +import yaml + + +def load(path): + with open(path) as f: + return yaml.safe_load(f) or {} + + +def triggers(doc): + # PyYAML (YAML 1.1) parses the bare key `on` as boolean True. + on = doc.get("on", doc.get(True)) + if isinstance(on, str): + return [on] + if isinstance(on, list): + return on + if isinstance(on, dict): + return list(on.keys()) + return [] + + +def steps_of(doc): + for job_id, job in (doc.get("jobs") or {}).items(): + if not isinstance(job, dict): + continue + if isinstance(job.get("uses"), str): + yield job_id, {"uses": job["uses"], "with": job.get("with") or {}} + for step in job.get("steps") or []: + if isinstance(step, dict): + yield job_id, step + runs = doc.get("runs") or {} + for step in runs.get("steps") or []: + if isinstance(step, dict): + yield "(composite)", step + + +def gh_action_yml(name, ref): + parts = name.split("/") + repo, sub = "/".join(parts[:2]), "/".join(parts[2:]) + for fname in ("action.yml", "action.yaml"): + path = f"{sub}/{fname}" if sub else fname + try: + out = subprocess.run( + ["gh", "api", f"repos/{repo}/contents/{path}?ref={ref}", "--jq", ".content"], + capture_output=True, text=True, timeout=60, + ) + except (OSError, subprocess.TimeoutExpired): + return None + if out.returncode == 0 and out.stdout.strip(): + return yaml.safe_load(base64.b64decode(out.stdout.strip())) + return None + + +def main(): + wt, updates_path = sys.argv[1], sys.argv[2] + with open(updates_path) as f: + updates = json.load(f) + + workflows = {} + for p in sorted(glob.glob(os.path.join(wt, ".github/workflows/*.y*ml"))): + workflows[os.path.relpath(p, wt)] = load(p) + composites = {} + for p in sorted(glob.glob(os.path.join(wt, ".github/actions/**/action.y*ml"), recursive=True)): + composites[os.path.relpath(os.path.dirname(p), wt)] = (os.path.relpath(p, wt), load(p)) + + on_pr = {f: bool({"pull_request", "pull_request_target", "merge_group"} & set(map(str, triggers(d)))) + for f, d in workflows.items()} + callers = {} + for f, d in workflows.items(): + for _, step in steps_of(d): + uses = step.get("uses", "") + if uses.startswith("./"): + callers.setdefault(uses[2:].rstrip("/"), []).append(f) + + report = [] + for u in updates: + name = u["name"] + usages = [] + sources = [(f, d, None) for f, d in workflows.items()] + sources += [(path, d, cdir) for cdir, (path, d) in composites.items()] + for f, d, cdir in sources: + for job, step in steps_of(d): + uses = step.get("uses", "") + if uses.split("@")[0] != name: + continue + if cdir is None: + wfs = [f] + else: + wfs = callers.get(cdir, []) + usages.append({ + "file": f, + "job": job, + "with": sorted((step.get("with") or {}).keys()), + "workflows": wfs, + "runs_on_pr": any(on_pr.get(w, False) for w in wfs), + }) + entry = {"name": name, "from": u.get("from"), "to": u.get("to"), "usages": usages} + + if u.get("breaking") and u.get("from_refs") and u.get("to_refs"): + old = gh_action_yml(name, u["from_refs"][-1]) + new = gh_action_yml(name, u["to_refs"][-1]) + if old is None or new is None: + entry["inputs"] = {"status": "unavailable"} + else: + old_in = old.get("inputs") or {} + new_in = new.get("inputs") or {} + used = sorted({k for us in usages for k in us["with"]}) + provided = {k for us in usages for k in us["with"]} + entry["inputs"] = { + "status": "ok", + "used": used, + "removed_but_used": [k for k in used if k not in new_in], + "new_required": [k for k, v in new_in.items() + if k not in old_in and isinstance(v, dict) and v.get("required") + and "default" not in v and k not in provided], + "runs_using": [str((old.get("runs") or {}).get("using")), str((new.get("runs") or {}).get("using"))], + } + report.append(entry) + + json.dump({"workflows_on_pr": on_pr, "actions": report}, sys.stdout, indent=2) + + +if __name__ == "__main__": + main() diff --git a/scripts/dependency-pr/lib/check.sh b/scripts/dependency-pr/lib/check.sh new file mode 100644 index 000000000..b5ce56a64 --- /dev/null +++ b/scripts/dependency-pr/lib/check.sh @@ -0,0 +1,148 @@ +# Helpers sourced by every check script (baseline and generated). +# +# Environment provided by the runner: +# WT worktree under test (the check's working directory) +# SIDE "pr" or "base" +# BASE_WT PR_WT both worktrees, for checks that compare sides +# ARTIFACTS per-check, per-side directory for status and evidence +# CLASSIFICATION path to classification.json +# PR_META path to pr.json (PR metadata, CI rollup, merge state) +# PROFILE "fast" or "full" +# VERIFY_ROOT scripts/dependency-pr +# +# Protocol: finish by calling exactly one of pass/fail/warn/skip. A script that +# exits without doing so (crash, timeout, `set -e` abort) is recorded as +# "error". Everything printed to stdout/stderr goes to the check's log. + +: "${ARTIFACTS:?ARTIFACTS must be set by the runner}" +: "${WT:?WT must be set by the runner}" +mkdir -p "$ARTIFACTS" + +_finish() { + printf '%s\n' "$1" >"$ARTIFACTS/status" + shift + printf '%s\n' "$*" >"$ARTIFACTS/summary" + exit 0 +} +pass() { _finish pass "$@"; } +fail() { _finish fail "$@"; } +warn() { _finish warn "$@"; } +skip() { _finish skip "$@"; } + +# Overrides the registry's on_fail severity for this run ("block" or "attention"). +severity() { printf '%s\n' "$1" >"$ARTIFACTS/severity"; } + +# Suggested follow-up action shown in the comment (e.g. rebuild-dist). +recommend() { printf '%s\n' "$*" >>"$ARTIFACTS/recommendations"; } + +# Markdown lines shown under the check in the comment's details section. +detail() { printf '%s\n' "$*" >>"$ARTIFACTS/details.md"; } +detail_block() { + # detail_block [max_lines] + local f="$1" max="${2:-40}" + { + printf '```\n' + head -n "$max" "$f" + local n + n=$(wc -l <"$f") + [ "$n" -gt "$max" ] && printf '… (%s more lines, see log)\n' "$((n - max))" + printf '```\n' + } >>"$ARTIFACTS/details.md" +} + +# Fingerprint of a failure. When a check fails on the PR it is re-run on base; +# an identical fingerprint there means the failure is pre-existing on main. +# Keep it free of absolute paths, timings, and line numbers that can shift. +fingerprint() { printf '%s' "$*" | sha256sum | cut -c1-16 >"$ARTIFACTS/fingerprint"; } +fingerprint_file() { sha256sum <"$1" | cut -c1-16 >"$ARTIFACTS/fingerprint"; } + +run() { + printf '+ %s\n' "$*" + "$@" +} + +# Updates of one ecosystem from classification.json as compact JSON lines. +updates_for() { + jq -c --arg e "$1" '.updates[] | select(.ecosystem == $e)' "$CLASSIFICATION" +} + +has_ecosystem() { + jq -e --arg e "$1" '.ecosystems | index($e) != null' "$CLASSIFICATION" >/dev/null +} + +UI_DIR_REL="internal/dev_server/ui" + +# Installs UI dependencies once per lockfile content. +ensure_ui_deps() { + local dir="$WT/$UI_DIR_REL" stamp hash + stamp="$dir/node_modules/.verify-lock-hash" + hash=$(sha256sum <"$dir/package-lock.json" | cut -c1-16) + if [ -f "$stamp" ] && [ "$(cat "$stamp")" = "$hash" ]; then + return 0 + fi + (cd "$dir" && run npm ci --no-audit --no-fund) || return 1 + printf '%s\n' "$hash" >"$stamp" +} + +build_ldcli() { + # build_ldcli + (cd "$WT" && run go build -o "$1" .) +} + +free_port() { + local p + for _ in $(seq 1 50); do + p=$((20000 + RANDOM % 20000)) + if ! (exec 3<>"/dev/tcp/127.0.0.1/$p") 2>/dev/null; then + printf '%s\n' "$p" + return 0 + fi + done + return 1 +} + +JOBS="$(nproc 2>/dev/null || echo 4)" + +_subcommands_of() { + "$1" __complete "$2" "" 2>/dev/null | + awk -F'\t' -v p="$2" '!/^:/ && $1 != "" && $1 !~ /^-/ && $1 != "help" {print p " " $1}' +} +_help_one() { + # _help_one ; unquoted $3 splits "flags list" into args. + local f + f="$2/$(printf '%s' "$3" | tr ' ' '_').txt" + # shellcheck disable=SC2086 + { printf '### ldcli %s\n' "$3"; "$1" $3 --help 2>&1; } >"$f" || printf '%s\n' "$3" >>"$2/.failures" +} +export -f _subcommands_of _help_one + +# Top-level commands and their direct subcommands, via cobra's hidden +# completion command, one per line ("flags", "flags list", ...). +list_commands() { + local bin="$1" top + top=$("$bin" __complete "" 2>/dev/null | awk -F'\t' '!/^:/ && $1 != "" && $1 != "help" {print $1}') + { + printf '%s\n' "$top" + printf '%s\n' "$top" | xargs -P "$JOBS" -I{} bash -c '_subcommands_of "$0" "$1"' "$bin" {} + } | sort -u +} + +# help_dump : writes /all.txt (help for every command, sorted), +# /.commands and /.failures (commands whose --help exits non-zero). +help_dump() { + local bin="$1" dir="$2" c + mkdir -p "$dir/cmd" + : >"$dir/.failures" + list_commands "$bin" >"$dir/.commands" + xargs -P "$JOBS" -I{} bash -c '_help_one "$0" "$1" "$2"' "$bin" "$dir/cmd" {} <"$dir/.commands" + mv "$dir/cmd/.failures" "$dir/.failures" 2>/dev/null || true + { + printf '### ldcli\n' + "$bin" --help 2>&1 + while IFS= read -r c; do cat "$dir/cmd/$(printf '%s' "$c" | tr ' ' '_').txt"; done <"$dir/.commands" + } >"$dir/all.txt" +} + +restore_tree() { + git -C "$WT" checkout -q -- . && git -C "$WT" clean -fdq +} diff --git a/scripts/dependency-pr/lib/classify.sh b/scripts/dependency-pr/lib/classify.sh new file mode 100755 index 000000000..720fe43fb --- /dev/null +++ b/scripts/dependency-pr/lib/classify.sh @@ -0,0 +1,189 @@ +#!/usr/bin/env bash +# Usage: classify.sh +# +# Compares the base and PR worktrees (not the PR diff) so updates reflect what +# would actually change on the base branch after merging. +set -euo pipefail +source "$(dirname "$0")/common.sh" + +BASE_WT="$1" PR_WT="$2" CHANGED="$3" PR_JSON="$4" OUT="$5" +UI_DIR="internal/dev_server/ui" +TMP=$(mktemp -d) +trap 'rm -rf "$TMP"' EXIT + +go_mod_json() { + if [ -f "$1/go.mod" ]; then (cd "$1" && go mod edit -json); else echo '{}'; fi +} + +npm_json() { + # npm_json : {deps, dev, lock} for one package directory + local d="$1/$2" pkg lock + pkg="$d/package.json" + lock="$d/package-lock.json" + [ -f "$pkg" ] || pkg=/dev/null + [ -f "$lock" ] || lock=/dev/null + jq -n --slurpfile p <(cat "$pkg" 2>/dev/null || echo '{}') --slurpfile l <(cat "$lock" 2>/dev/null || echo '{}') ' + ($p[0] // {}) as $p | ($l[0] // {}) as $l + | { + deps: ($p.dependencies // {}), + dev: ($p.devDependencies // {}), + lock: (($l.packages // {}) | to_entries + | map(select(.key | test("^node_modules/(@[^/]+/)?[^/]+$"))) + | map({key: (.key | sub("^node_modules/"; "")), value: .value.version}) + | from_entries) + }' +} + +actions_uses() { + # Prints "namerefcomment-version" for each non-local `uses:`. + local dirs=() + [ -d "$1/.github/workflows" ] && dirs+=("$1/.github/workflows") + [ -d "$1/.github/actions" ] && dirs+=("$1/.github/actions") + [ ${#dirs[@]} -gt 0 ] || return 0 + rg --no-filename --no-line-number -o -g '*.yml' -g '*.yaml' \ + '^\s*-?\s*uses:\s*["'\'']?([^\s"'\''#]+)["'\'']?(?:\s*#\s*(\S+))?' -r '$1 $2' "${dirs[@]}" | + awk -F'\t' '$1 !~ /^\.\// && $1 !~ /^docker:\/\// && index($1, "@") > 0 { + at = index($1, "@"); printf "%s\t%s\t%s\n", substr($1, 1, at - 1), substr($1, at + 1), $2 }' | + sort -u +} + +actions_json() { + actions_uses "$1" | jq -R -s ' + split("\n") | map(select(length > 0) | split("\t")) + | map({name: .[0], ref: .[1], + version: (if (.[1] | test("^[0-9a-f]{40}$")) and ((.[2] // "") != "") then .[2] else .[1] end)}) + | group_by(.name) | map({key: .[0].name, value: {refs: (map(.ref) | unique), versions: (map(.version) | unique)}}) + | from_entries' +} + +docker_json() { + local f out='{}' + for f in "$1"/Dockerfile*; do + [ -f "$f" ] || continue + out=$(rg --no-line-number -o '^FROM\s+(\S+)' -r '$1' "$f" | jq -R -s --argjson acc "$out" ' + split("\n") | map(select(length > 0)) + | map(sub("@sha256:.*$"; "") | capture("^(?[^:]+)(:(?.+))?$") | {key: .name, value: (.tag // "latest")}) + | from_entries | $acc + .') + done + printf '%s\n' "$out" +} + +go_mod_json "$BASE_WT" >"$TMP/go.base.json" +go_mod_json "$PR_WT" >"$TMP/go.pr.json" +npm_json "$BASE_WT" "$UI_DIR" >"$TMP/ui.base.json" +npm_json "$PR_WT" "$UI_DIR" >"$TMP/ui.pr.json" +npm_json "$BASE_WT" "." >"$TMP/root.base.json" +npm_json "$PR_WT" "." >"$TMP/root.pr.json" +actions_json "$BASE_WT" >"$TMP/actions.base.json" +actions_json "$PR_WT" >"$TMP/actions.pr.json" +docker_json "$BASE_WT" >"$TMP/docker.base.json" +docker_json "$PR_WT" >"$TMP/docker.pr.json" + +jq -n -L "$VERIFY_ROOT/lib" \ + --rawfile changed "$CHANGED" \ + --slurpfile pr "$PR_JSON" \ + --slurpfile risk "$VERIFY_ROOT/risk-map.json" \ + --slurpfile gob "$TMP/go.base.json" --slurpfile gop "$TMP/go.pr.json" \ + --slurpfile uib "$TMP/ui.base.json" --slurpfile uip "$TMP/ui.pr.json" \ + --slurpfile rootb "$TMP/root.base.json" --slurpfile rootp "$TMP/root.pr.json" \ + --slurpfile actb "$TMP/actions.base.json" --slurpfile actp "$TMP/actions.pr.json" \ + --slurpfile dockb "$TMP/docker.base.json" --slurpfile dockp "$TMP/docker.pr.json" ' +include "semver"; + +def maxv: sort_by(vparse | if . == null then [-1] else .nums end) | last; + +def go_updates: + def reqmap: (.Require // []) | map({key: .Path, value: {v: .Version, indirect: (.Indirect // false)}}) | from_entries; + ($gob[0] | reqmap) as $b | ($gop[0] | reqmap) as $p + | [ (($b | keys) + ($p | keys) | unique)[] as $k + | select($b[$k].v != $p[$k].v) + | {ecosystem: "gomod", name: $k, from: $b[$k].v, to: $p[$k].v, + direct: ((($p[$k] // $b[$k]).indirect) | not), dev: false} ]; + +def npm_updates($eco; $b; $p): + [ (($b.lock | keys) + ($p.lock | keys) | unique)[] as $k + | select($b.lock[$k] != $p.lock[$k]) + | ([$b.deps, $p.deps] | any(has($k))) as $rt + | ([$b.dev, $p.dev] | any(has($k))) as $dv + | {ecosystem: $eco, name: $k, from: $b.lock[$k], to: $p.lock[$k], + direct: ($rt or $dv), dev: ($dv and ($rt | not))} ]; + +def action_updates: + $actb[0] as $b | $actp[0] as $p + | [ (($b | keys) + ($p | keys) | unique)[] as $k + | select($b[$k].refs != $p[$k].refs) + | {ecosystem: "github-actions", name: $k, + from: (($b[$k].versions // []) | maxv), to: (($p[$k].versions // []) | maxv), + from_all: ($b[$k].versions // []), to_all: ($p[$k].versions // []), + from_refs: ($b[$k].refs // []), to_refs: ($p[$k].refs // []), + direct: true, dev: false} ]; + +def docker_updates: + $dockb[0] as $b | $dockp[0] as $p + | [ (($b | keys) + ($p | keys) | unique)[] as $k + | select($b[$k] != $p[$k]) + | {ecosystem: "docker", name: $k, from: $b[$k], to: $p[$k], direct: true, dev: false} ]; + +def ecosystems_from($files): + [ $files[] + | if test("^go\\.(mod|sum)$") then "gomod" + elif test("^internal/dev_server/ui/package(-lock)?\\.json$") then "npm-ui" + elif test("^package(-lock)?\\.json$") then "npm-wrapper" + elif test("^\\.github/(workflows|actions)/") then "github-actions" + elif test("(^|/)Dockerfile[^/]*$") then "docker" + else empty end ] | unique; + +def manifest_file: test("^go\\.(mod|sum)$|(^|/)package(-lock)?\\.json$|^\\.github/(workflows|actions)/|(^|/)Dockerfile[^/]*$"); + +($changed | split("\n") | map(select(length > 0))) as $files +| ($pr[0] // {}) as $pr +| (go_updates + npm_updates("npm-ui"; $uib[0]; $uip[0]) + npm_updates("npm-wrapper"; $rootb[0]; $rootp[0]) + + action_updates + docker_updates) as $raw +| $risk[0].rules as $rules +| [ $raw[] + | semver_class(.from; .to) as $c + | (if $c == "none" and .ecosystem == "github-actions" then "digest" else $c end) as $c + | . + {semver: $c, breaking: is_breaking(.from; .to)} + | . as $u + | [ $rules[] | . as $rule | select($u.name | test($rule.match)) ] as $hits + | ( if $c == "downgrade" then "medium" + elif .breaking and .direct then "high" + elif $c == "minor" and .direct and (.dev | not) then "medium" + else "low" end ) as $base + | ($hits | map(.tier) | reduce .[] as $t ("low"; max_tier(.; $t))) as $rt + | (if .direct then $rt else max_tier("low"; (if $rt == "high" then "medium" else $rt end)) end) as $rt + | . + {tier: max_tier($base; $rt), + tags: ($hits | map(.tags[]) | unique), + risk_notes: ($hits | map(.why) | unique)} ] as $updates +| ($updates | map(select(.direct)) | length) as $ndirect +| {from: ($gob[0].Go // null), to: ($gop[0].Go // null)} as $godir +| {from: ($gob[0].Toolchain.Name // null), to: ($gop[0].Toolchain.Name // null)} as $tool +| (ecosystems_from($files) + ($updates | map(.ecosystem)) | unique) as $ecos +| ( [ ($updates[] | select(.tier != "low") | "\(.name) \(.from // "∅") → \(.to // "∅"): \(.tier) (\(.semver)\(if .breaking then ", breaking range" else "" end)\(if (.tags | length) > 0 then "; " + (.tags | join(", ")) else "" end))"), + (if $godir.from != $godir.to then "go directive \($godir.from) → \($godir.to): high" else empty end), + (if $tool.from != $tool.to then "toolchain \($tool.from) → \($tool.to): high" else empty end), + (if ($ecos | index("docker")) then "docker base image: at least medium" else empty end), + (if $ndirect > 3 then "\($ndirect) direct updates in one PR: high" else empty end), + (if ($updates | length) == 0 then "no dependency change detected between base and PR: medium" else empty end) + ] ) as $reasons +| ( ($updates | map(.tier)) + [ + (if $godir.from != $godir.to or $tool.from != $tool.to then "high" else "low" end), + (if ($ecos | index("docker")) then "medium" else "low" end), + (if $ndirect > 3 then "high" else "low" end), + (if ($updates | length) == 0 then "medium" else "low" end) + ] | reduce .[] as $t ("low"; max_tier(.; $t)) ) as $tier +| { + schema: 1, + ecosystems: $ecos, + changed_files: $files, + other_files: ($files | map(select(manifest_file | not))), + updates: $updates, + direct_update_count: $ndirect, + go_directive: $godir, + toolchain: $tool, + group: ($ndirect > 1 or (($pr.title // "") | test("group"; "i"))), + security: ((($pr.title // "") + " " + ($pr.body // "")) | test("GHSA-|CVE-[0-9]{4}-|\\[security\\]"; "i")), + tier: $tier, + tags: ($updates | map(.tags[]) | unique), + tier_reasons: $reasons + }' >"$OUT" diff --git a/scripts/dependency-pr/lib/common.sh b/scripts/dependency-pr/lib/common.sh new file mode 100644 index 000000000..a0d95e6e6 --- /dev/null +++ b/scripts/dependency-pr/lib/common.sh @@ -0,0 +1,61 @@ +# Shared helpers for scripts/dependency-pr. Source, do not execute. + +VERIFY_ROOT="${VERIFY_ROOT:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}" +export VERIFY_ROOT + +log() { printf '[verify] %s\n' "$*" >&2; } + +die_infra() { + printf '[verify] ERROR: %s\n' "$*" >&2 + exit 2 +} + +require_tools() { + local missing=() + for t in "$@"; do + command -v "$t" >/dev/null 2>&1 || missing+=("$t") + done + [ ${#missing[@]} -eq 0 ] || die_infra "missing required tools: ${missing[*]}" +} + +# Local credentials and CLI config leak into `go test` and the binary smoke +# test (several cmd/ tests fail when LD_ACCESS_TOKEN or ~/.config/ldcli exist), +# so every check runs with LD_* unset and private XDG config/state/data dirs. +# Build and module caches are kept so runs stay fast. +hermetic_env_args() { + local sandbox="$1" + mkdir -p "$sandbox/config" "$sandbox/state" "$sandbox/data" + local args=() + local v + while IFS= read -r v; do + args+=("-u" "$v") + done < <(env | sed -n 's/^\(LD_[A-Za-z0-9_]*\)=.*/\1/p') + args+=( + "XDG_CONFIG_HOME=$sandbox/config" + "XDG_STATE_HOME=$sandbox/state" + "XDG_DATA_HOME=$sandbox/data" + "GOCACHE=${GOCACHE_REAL}" + "GOMODCACHE=${GOMODCACHE_REAL}" + "LD_ANALYTICS_OPT_OUT=true" + "CI=true" + "NO_COLOR=1" + "npm_config_fund=false" + "npm_config_update_notifier=false" + ) + printf '%s\n' "${args[@]}" +} + +init_go_cache_env() { + if command -v go >/dev/null 2>&1; then + GOCACHE_REAL="$(go env GOCACHE)" + GOMODCACHE_REAL="$(go env GOMODCACHE)" + else + GOCACHE_REAL="${HOME}/.cache/go-build" + GOMODCACHE_REAL="${HOME}/go/pkg/mod" + fi + export GOCACHE_REAL GOMODCACHE_REAL +} + +now_s() { date +%s; } + +sanitize() { printf '%s' "$1" | tr -c 'A-Za-z0-9._-' '-'; } diff --git a/scripts/dependency-pr/lib/semver.jq b/scripts/dependency-pr/lib/semver.jq new file mode 100644 index 000000000..0ef4aa338 --- /dev/null +++ b/scripts/dependency-pr/lib/semver.jq @@ -0,0 +1,48 @@ +# jq module: version parsing and update classification. +# Use with: jq -L "$VERIFY_ROOT/lib" 'include "semver"; ...' + +# Accepts "v1.2.3", "1.2", "release-secrets-v1.2.0", Go pseudo-versions, +# and npm/semver prerelease suffixes. Returns null when no number is found. +def vparse: + tostring as $raw + | ($raw | sub("^[^0-9]*"; "")) as $s + | ($s | capture("^(?[0-9]+(\\.[0-9]+)*)(?.*)$")) // null + | if . == null then null + else { + raw: $raw, + nums: ((.core | split(".") | map(tonumber)) + [0, 0, 0])[0:3], + pre: .rest, + pseudo: (.rest | test("[0-9]{14}-[0-9a-f]{12}$")) + } + end; + +# One of: added, removed, none, major, minor, patch, prerelease, pseudo, +# downgrade, unknown. +def semver_class($from; $to): + if $from == null then "added" + elif $to == null then "removed" + else + ($from | vparse) as $f | ($to | vparse) as $t + | if $f == null or $t == null then (if $from == $to then "none" else "unknown" end) + elif $f.nums == $t.nums then + (if $f.pre == $t.pre then "none" + elif $f.pseudo or $t.pseudo then "pseudo" + else "prerelease" end) + elif $f.nums > $t.nums then "downgrade" + elif $f.nums[0] != $t.nums[0] then "major" + elif $f.nums[1] != $t.nums[1] then "minor" + else "patch" + end + end; + +# Semver allows breaking changes on a major bump, and on a minor bump while +# the major version is 0. +def is_breaking($from; $to): + semver_class($from; $to) as $c + | if $c == "major" then true + elif $c == "minor" then (($from | vparse).nums[0] == 0) + else false + end; + +def tier_rank: {"low": 0, "medium": 1, "high": 2}[.] // 0; +def max_tier($a; $b): if ($a | tier_rank) >= ($b | tier_rank) then $a else $b end; diff --git a/scripts/dependency-pr/lib/verdict.jq b/scripts/dependency-pr/lib/verdict.jq new file mode 100644 index 000000000..9925fade7 --- /dev/null +++ b/scripts/dependency-pr/lib/verdict.jq @@ -0,0 +1,105 @@ +# jq module: turns check records, classification, and agent notes into a verdict. +# Use with: jq -L "$VERIFY_ROOT/lib" 'include "verdict"; ...' +include "semver"; + +def nonpass: . == "fail" or . == "warn"; + +# Baseline check outcome. A failure that also fails on base with the same +# fingerprint is pre-existing and does not count against the PR. +def baseline_outcome: + .pr.status as $p | (.base.status // null) as $b + | if $p == "pass" then "pass" + elif $p == "skip" then "skip" + elif $p == "error" then "error" + elif $b == null then $p + elif $b == "pass" then "regression" + elif ($b | nonpass) then + (if (.pr.fingerprint // .pr.summary) == (.base.fingerprint // .base.summary) + then "pre-existing" else "changed" end) + else "base-inconclusive" + end; + +# Generated check outcome. A discriminating check counts ("proven") only when +# it fails on the old version and passes on the new one. +def generated_outcome: + .pr.status as $p | .base.status as $b + | if $p == "error" or $b == "error" then "error" + elif $p == "skip" or $b == "skip" then "skip" + elif .kind == "discriminating" then + (if ($b | nonpass) and $p == "pass" then "proven" + elif $b == "pass" and $p == "pass" then "not-discriminating" + elif $b == "pass" then "regression" + else "fails-both" end) + else + (if $b == "pass" and $p == "pass" then "holds" + elif $b == "pass" then "regression" + else "invalid" end) + end; + +def level_for($sev): if $sev == "block" then "block" else "needs-human" end; + +def build_result($meta; $cls; $checks; $gen; $impact; $profile; $generated_at): + ($checks | map(. + {outcome: baseline_outcome})) as $checks + | ($gen | map(. + {outcome: generated_outcome} | . + {counted: (.outcome == "proven")})) as $gen + | ($gen | map(select(.counted)) | length) as $proven + | (if $impact != null and ($impact.tier // null) != null + then max_tier($cls.tier; $impact.tier) else $cls.tier end) as $tier + | [ + ( $checks[] + | select(.outcome | IN("fail", "warn", "regression", "changed", "base-inconclusive")) + | {level: (if .pr.status == "fail" then level_for(.pr.severity // .on_fail) else "needs-human" end), + source: .id, + text: ("\(.title): \(.pr.summary)" + + (if .outcome == "changed" then " (also fails on base, but this PR changes the result)" + elif .outcome == "base-inconclusive" then " (could not compare with base)" + else "" end))} ), + ( $checks[] | select(.outcome == "error") + | {level: "needs-human", source: .id, text: "Verifier error in \(.id): \(.pr.summary)"} ), + ( $checks[] | select(.outcome == "skip" and .required and ((.pr.profile_skipped // false) | not)) + | {level: "needs-human", source: .id, text: "Required check not run (\(.id)): \(.pr.summary)"} ), + ( $gen[] | select(.outcome == "regression") + | {level: level_for(.severity // "attention"), source: "generated:\(.id)", + text: "Generated check regressed: \(.title): \(.pr.summary)"} ), + ( $gen[] | select(.outcome == "fails-both") + | {level: "needs-human", source: "generated:\(.id)", + text: "Generated check fails on both versions: \(.title): \(.pr.summary)"} ), + ( $gen[] | select(.outcome == "error") + | {level: "needs-human", source: "generated:\(.id)", text: "Generated check errored: \(.id)"} ), + ( if $tier == "high" then + {level: "needs-human", source: "risk", + text: "High risk tier: \(($cls.tier_reasons + (($impact.tier_reasons) // [])) | join("; "))"} + else empty end ), + ( if $tier == "medium" and $impact == null then + {level: "needs-human", source: "risk", text: "Medium risk tier and no agent impact review recorded (agent/impact.json)"} + else empty end ), + ( if $tier == "medium" and $proven == 0 then + {level: "needs-human", source: "risk", + text: "Medium risk tier and no generated check has proven itself (it must fail on the old version and pass on the new one)"} + else empty end ), + ( ($impact.findings // [])[] | select(.severity == "block" or .severity == "warn") + | {level: (if .severity == "block" then "block" else "needs-human" end), source: "impact", text: .text} ) + ] as $reasons + | (if any($reasons[]; .level == "block") then "block" + elif ($reasons | length) > 0 then "needs-human" + else "safe-to-merge" end) as $verdict + | ([$checks[], $gen[]] | any(.outcome == "error")) as $infra + | { + schema: 1, + generated_at: $generated_at, + profile: $profile, + pr: $meta, + classification: $cls, + tier: $tier, + verdict: $verdict, + exit_code: (if $infra then 2 elif $verdict == "safe-to-merge" then 0 else 1 end), + reasons: $reasons, + recommendations: ( + [ ($checks[] | select(.outcome | IN("pass", "pre-existing", "skip") | not) | (.pr.recommendations // [])[]), + ($gen[] | select(.outcome == "regression") | (.pr.recommendations // [])[]), + (($impact.recommendations // [])[]) ] | unique), + pre_existing: [ $checks[] | select(.outcome == "pre-existing") | {id, title, summary: .pr.summary} ], + checks: $checks, + generated_checks: $gen, + generated_proven: $proven, + impact: $impact + }; diff --git a/scripts/dependency-pr/post-comment.sh b/scripts/dependency-pr/post-comment.sh new file mode 100755 index 000000000..3b38fbab4 --- /dev/null +++ b/scripts/dependency-pr/post-comment.sh @@ -0,0 +1,61 @@ +#!/usr/bin/env bash +# Posts (or updates) the single verifier comment on a PR. This is the only +# script in scripts/dependency-pr that writes to GitHub, and it only comments: +# it never approves, requests changes, or merges. verify.sh never calls it. +# +# Usage: post-comment.sh --out-dir DIR [--repo OWNER/NAME] [--dry-run] [--force] +# --out-dir DIR a verify.sh output directory (needs result.json and comment.md) +# --dry-run print what would be posted and where, without writing +# --force post even if the PR head moved since verification +# +# Exit: 0 posted (or dry run), 1 refused (stale result, not a PR run), 2 error. +set -euo pipefail + +OUT="" REPO="" DRY_RUN=false FORCE=false +MARKER="" + +while [ $# -gt 0 ]; do + case "$1" in + --out-dir) OUT="${2:?}"; shift 2 ;; + --repo) REPO="${2:?}"; shift 2 ;; + --dry-run) DRY_RUN=true; shift ;; + --force) FORCE=true; shift ;; + -h | --help) sed -n '2,/^set -euo/p' "$0" | sed '$d; s/^# \{0,1\}//'; exit 0 ;; + *) echo "unknown argument: $1" >&2; exit 2 ;; + esac +done + +[ -n "$OUT" ] || { echo "--out-dir is required" >&2; exit 2; } +result="$OUT/result.json" comment="$OUT/comment.md" +[ -f "$result" ] && [ -f "$comment" ] || { echo "missing $result or $comment" >&2; exit 2; } +grep -qF "$MARKER" "$comment" || { echo "$comment lacks the verifier marker" >&2; exit 2; } + +pr=$(jq -r '.pr.number // empty' "$result") +[ -n "$pr" ] || { echo "result.json has no PR number (branch run without a PR); nothing to post" >&2; exit 1; } +REPO="${REPO:-$(jq -r '.pr.repo' "$result")}" +verified=$(jq -r '.pr.head_sha' "$result") + +current=$(gh pr view "$pr" --repo "$REPO" --json headRefOid --jq .headRefOid) || { echo "cannot read PR #$pr" >&2; exit 2; } +if [ "$current" != "$verified" ] && [ "$FORCE" != true ]; then + echo "refusing to post: PR #$pr head is now ${current:0:7}, but the result is for ${verified:0:7}. Re-run verify.sh." >&2 + exit 1 +fi + +existing=$(gh api "repos/$REPO/issues/$pr/comments" --paginate \ + --jq ".[] | select(.body | contains(\"$MARKER\")) | .id" | tail -n1) + +if [ "$DRY_RUN" = true ]; then + if [ -n "$existing" ]; then + echo "[dry-run] would update comment $existing on $REPO#$pr ($(wc -c <"$comment") bytes)" + else + echo "[dry-run] would create a comment on $REPO#$pr ($(wc -c <"$comment") bytes)" + fi + exit 0 +fi + +body=$(jq -Rs '{body: .}' "$comment") +if [ -n "$existing" ]; then + gh api -X PATCH "repos/$REPO/issues/comments/$existing" --input - <<<"$body" --jq .html_url +else + gh api -X POST "repos/$REPO/issues/$pr/comments" --input - <<<"$body" --jq .html_url +fi diff --git a/scripts/dependency-pr/render-comment.sh b/scripts/dependency-pr/render-comment.sh new file mode 100755 index 000000000..7c484b27f --- /dev/null +++ b/scripts/dependency-pr/render-comment.sh @@ -0,0 +1,128 @@ +#!/usr/bin/env bash +# Usage: render-comment.sh — prints the PR comment as Markdown. +set -euo pipefail + +RESULT="${1:?usage: render-comment.sh }" +MAX_CHARS=60000 # GitHub caps comments at 65536 characters. + +render() { + local with_details="$1" + jq -r --argjson with_details "$with_details" ' + def esc: tostring | gsub("\\|"; "\\|") | gsub("\n"; " "); + def rlabel: + {"pass": "pass", "fail": "**FAIL**", "warn": "warn", "skip": "skipped", "error": "**ERROR**", + "regression": "**FAIL** (passes on base)", "changed": "**FAIL** (differs from base)", + "pre-existing": "pre-existing on base", "base-inconclusive": "**FAIL** (base inconclusive)"}[.] // .; + def glabel: + {"proven": "proven (fails on base, passes on PR)", "not-discriminating": "passes on both: does not count", + "regression": "**REGRESSION** (passes on base, fails on PR)", "fails-both": "**fails on both**", + "holds": "holds on both", "invalid": "invalid (fails on base): discarded", + "error": "**ERROR**", "skip": "skipped"}[.] // .; + def sha7: if . == null then "?" else .[0:7] end; + + . as $r + | $r.pr as $pr + | $r.classification as $c + | ($c.updates | map(select(.direct))) as $direct + | ($c.updates | map(select(.direct | not))) as $transitive + | [ + "", + "## Dependency verification: \({"safe-to-merge": "SAFE TO MERGE", "needs-human": "NEEDS HUMAN REVIEW", "block": "BLOCK"}[$r.verdict])", + "", + "Risk tier **\($r.tier)**\(if ($c.tags | length) > 0 then " (" + ($c.tags | join(", ")) + ")" else "" end) · ecosystems: \($c.ecosystems | join(", ")) · head `\($pr.head_sha | sha7)` " + + (if $pr.merge.status == "merged" + then "tested as merged into `\($pr.base_ref)` @ `\($pr.base_sha | sha7)`" + else "conflicts with `\($pr.base_ref)`; tested as-is against merge base `\($pr.merge_base | sha7)`" end), + "", + (if ($r.reasons | length) > 0 then + "**Why**", "", ($r.reasons[] | "- \(if .level == "block" then "**block**: " else "" end)\(.text)"), "" + else "All checks passed and the risk tier allows merging on these results.", "" end), + (if ($r.recommendations | length) > 0 then + "**Suggested actions**", "", ($r.recommendations[] | "- \(.)"), "" + else empty end), + + "### Updates", + "", + (if ($direct | length) == 0 and ($transitive | length) == 0 then "No dependency version change detected.", "" + else + "| Package | From | To | Change | Ecosystem | Tier |", + "|---|---|---|---|---|---|", + ($direct[] | "| `\(.name)` | \(.from // "∅") | \(.to // "∅") | \(.semver)\(if .breaking and .semver != "major" then " (0.x: breaking allowed)" else "" end) | \(.ecosystem)\(if .dev then ", dev" else "" end) | \(.tier) |"), + (if ($transitive | length) > 0 then + "", + "
\($transitive | length) transitive update(s)", + "", + ($transitive[0:60][] | "- `\(.name)` \(.from // "∅") → \(.to // "∅") (\(.semver))"), + (if ($transitive | length) > 60 then "- …" else empty end), + "
" + else empty end), + "" + end), + (if ($c.go_directive.from != $c.go_directive.to) then "Go directive: `\($c.go_directive.from)` → `\($c.go_directive.to)`", "" else empty end), + + "### Baseline checks", + "", + "| Check | Result | Summary |", + "|---|---|---|", + ($r.checks[] | select((.pr.profile_skipped // false) | not) + | "| \(.title | esc) | \(.outcome | rlabel) | \(.pr.summary | esc) |"), + (if any($r.checks[]; .pr.profile_skipped // false) then + "", "Not run in the fast profile: \([$r.checks[] | select(.pr.profile_skipped // false) | .id] | join(", "))." + else empty end), + "", + + (if ($r.pre_existing | length) > 0 then + "### Already failing on `\($pr.base_ref)` (not caused by this PR)", + "", + ($r.pre_existing[] | "- **\(.title)**: \(.summary)"), + "" + else empty end), + + "### Generated checks", + "", + (if ($r.generated_checks | length) == 0 then + "None recorded for this PR.", "" + else + "A generated check counts only when it fails on the old version and passes on the new one. \($r.generated_proven) of \($r.generated_checks | length) proven.", + "", + "| Check | Kind | Base | PR | Outcome |", + "|---|---|---|---|---|", + ($r.generated_checks[] | "| \(.title | esc) | \(.kind) | \(.base.status) | \(.pr.status) | \(.outcome | glabel) |"), + "" + end), + + (if $r.impact != null then + "### Impact review", + "", + ($r.impact.summary // empty), + "", + (($r.impact.changelog // [])[] | "- **\(.package)** \(.range // ""): \(.notes)\(if .breaking then " **(breaking)**" else "" end)\(if .url then " ([source](\(.url)))" else "" end)"), + (if (($r.impact.usage // []) | length) > 0 then "- Used in: " + ($r.impact.usage | map("`\(.)`") | join(", ")) else empty end), + (($r.impact.findings // [])[] | "- \(.severity): \(.text)\(if ((.evidence // []) | length) > 0 then " (" + (.evidence | join(", ")) + ")" else "" end)"), + "" + else empty end), + + (if $with_details then + ([$r.checks[], ($r.generated_checks[] | . + {id: ("generated: " + .id)})] + | map(select(.pr.details != null or (.base.details // null) != null))) as $d + | if ($d | length) > 0 then + "
Check details", + "", + ($d[] | "#### \(.id)", "", (.pr.details // .base.details), ""), + "
", + "" + else empty end + else + "_Check details omitted to fit GitHub'\''s comment size limit; see the run logs._", "" + end), + + "Generated by `scripts/dependency-pr/verify.sh` (profile \($r.profile)) at \($r.generated_at). This comment is informational: the verifier never approves or merges." + ] + | .[]' "$RESULT" +} + +out=$(render true) +if [ "${#out}" -gt "$MAX_CHARS" ]; then + out=$(render false) +fi +printf '%s\n' "$out" diff --git a/scripts/dependency-pr/risk-map.json b/scripts/dependency-pr/risk-map.json new file mode 100644 index 000000000..351a1a3ec --- /dev/null +++ b/scripts/dependency-pr/risk-map.json @@ -0,0 +1,25 @@ +{ + "rules": [ + { "match": "^github.com/oapi-codegen/", "tags": ["codegen"], "tier": "high", "why": "Generator output (server.gen.go) is committed; tool and runtime must move together (#720)." }, + { "match": "^github.com/getkin/kin-openapi$", "tags": ["codegen"], "tier": "high", "why": "Drives cmd/resources/gen_resources.go and oapi-codegen; can rewrite ~600 KB of generated commands." }, + { "match": "^github.com/iancoleman/strcase$", "tags": ["codegen"], "tier": "high", "why": "Used by the resource command generator; changes command and flag names." }, + { "match": "^go.uber.org/mock$", "tags": ["codegen", "mocks"], "tier": "medium", "why": "Six go:generate mockgen directives produce committed mocks." }, + { "match": "^github.com/mattn/go-sqlite3$", "tags": ["cgo", "dev-server"], "tier": "medium", "why": "CGO; PR CI builds linux/amd64 glibc only while releases cross-compile (musl static, mingw, osxcross)." }, + { "match": "^github.com/launchdarkly/(go-server-sdk|go-sdk-common|go-server-sdk-evaluation)", "tags": ["ld-sdk", "dev-server"], "tier": "medium", "why": "The dev server proxies SDK streaming and evaluation." }, + { "match": "^github.com/launchdarkly/api-client-go", "tags": ["ld-sdk", "codegen"], "tier": "medium", "why": "Generated resource commands call the API client." }, + { "match": "^github.com/launchdarkly/sdk-meta", "tags": ["ld-sdk", "setup"], "tier": "medium", "why": "Feeds the setup/quickstart SDK lists." }, + { "match": "^github.com/spf13/(cobra|pflag|viper)$", "tags": ["cli-surface"], "tier": "medium", "why": "Flag parsing, config precedence, usage templates." }, + { "match": "^github.com/go-viper/mapstructure", "tags": ["cli-surface"], "tier": "medium", "why": "Config decoding behind viper." }, + { "match": "^github.com/charmbracelet/", "tags": ["tui"], "tier": "medium", "why": "Interactive setup/quickstart flows with little test coverage." }, + { "match": "^@launchpad-ui/", "tags": ["ui-design-system", "peer-deps"], "tier": "medium", "why": "Peer-dependency tangles with React 18 / react-router-dom v6 overrides (#638, #642, #723)." }, + { "match": "^(react|react-dom|react-router|react-router-dom|@remix-run/router)$", "tags": ["ui-framework", "peer-deps"], "tier": "medium", "why": "Framework peers of @launchpad-ui; overrides pin react-router-dom v6." }, + { "match": "^launchdarkly-js-client-sdk$", "tags": ["ld-sdk", "dev-server-ui"], "tier": "medium", "why": "UI evaluates flags against the dev server." }, + { "match": "^(vite|vite-plugin-singlefile|@vitejs/plugin-react|typescript)$", "tags": ["ui-build"], "tier": "medium", "why": "Changes the embedded single-file dist/index.html." }, + { "match": "^googleapis/release-please-action$", "tags": ["release-tooling"], "tier": "high", "why": "Release workflow never runs on pull_request." }, + { "match": "^launchdarkly/gh-actions", "tags": ["release-tooling"], "tier": "medium", "why": "Release secrets / shared org workflows." }, + { "match": "^actions/(attest|upload-artifact|setup-node)$", "tags": ["release-tooling"], "tier": "medium", "why": "Used by publish/attestation steps that only run at release." }, + { "match": "^docker/", "tags": ["release-tooling"], "tier": "medium", "why": "Image build happens only at release." }, + { "match": "^alpine$", "tags": ["docker-base-image", "release-tooling"], "tier": "medium", "why": "Base of the published Docker image; never built on PRs." }, + { "match": "^@go-task/go-npm$", "tags": ["npm-wrapper", "release-tooling"], "tier": "high", "why": "postinstall downloads the release binary for every npm user." } + ] +} diff --git a/scripts/dependency-pr/test/run.sh b/scripts/dependency-pr/test/run.sh new file mode 100755 index 000000000..03df35d2e --- /dev/null +++ b/scripts/dependency-pr/test/run.sh @@ -0,0 +1,120 @@ +#!/usr/bin/env bash +# Unit tests for the deterministic logic (semver classification, verdict rules, +# comment rendering). No network, git, or Go needed. Run: scripts/dependency-pr/test/run.sh +set -uo pipefail +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +LIB="$ROOT/lib" +failures=0 + +check() { + # check + if [ "$2" = "$3" ]; then + printf 'ok %s\n' "$1" + else + printf 'FAIL %s\n expected: %s\n actual: %s\n' "$1" "$2" "$3" + failures=$((failures + 1)) + fi +} + +semver() { jq -nr -L "$LIB" --arg f "$1" --arg t "$2" 'include "semver"; semver_class($f; $t)'; } +breaking() { jq -nr -L "$LIB" --arg f "$1" --arg t "$2" 'include "semver"; is_breaking($f; $t)'; } + +check "patch" patch "$(semver v1.14.28 v1.14.52)" +check "minor" minor "$(semver 7.12.0 7.18.2)" +check "major" major "$(semver 1.8.10 2.3.2)" +check "action major, short ref" major "$(semver v4 v6.0.3)" +check "prefixed tag" minor "$(semver release-secrets-v1.0.1 release-secrets-v1.2.0)" +check "downgrade" downgrade "$(semver v0.50.0 v0.46.0)" +check "pseudo-version" pseudo "$(semver v0.21.1-0.20250623103423-23b8fd6302d7 v0.21.1-0.20250801000000-abcdefabcdef)" +check "prerelease" prerelease "$(semver 1.0.0-rc.1 1.0.0)" +check "docker tag" minor "$(semver 3.19.1 3.24.2)" +check "0.x minor is breaking" true "$(breaking v0.4.8 v0.7.4)" +check "1.x minor is not breaking" false "$(breaking v1.9.1 v1.10.2)" +check "major is breaking" true "$(breaking 1.8.10 2.3.2)" + +# ---- verdict rules + +res() { jq -c -n "$1"; } +verdict() { + # verdict [impact-json] + jq -n -L "$LIB" --argjson checks "$1" --argjson gen "$2" --arg tier "$3" --argjson impact "${4:-null}" ' + include "verdict"; + build_result({head_sha: "abc"}; {tier: $tier, tier_reasons: ["test"], updates: [], ecosystems: ["gomod"], tags: []}; + $checks; $gen; $impact; "fast"; "now")' +} +chk() { + # chk [base-status] [pr-fp] [base-fp] + jq -n --arg id "$1" --arg sev "$2" --arg p "$3" --arg b "${4:-}" --arg pf "${5:-}" --arg bf "${6:-}" ' + {id: $id, title: $id, on_fail: $sev, required: true, + pr: {status: $p, summary: "s", fingerprint: (if $pf == "" then null else $pf end)}, + base: (if $b == "" then null else {status: $b, summary: "s", fingerprint: (if $bf == "" then null else $bf end)} end)}' +} +gen() { + # gen + jq -n --arg id "$1" --arg k "$2" --arg b "$3" --arg p "$4" \ + '{id: $id, title: $id, kind: $k, severity: "attention", base: {status: $b, summary: "s"}, pr: {status: $p, summary: "s"}}' +} +IMPACT='{"summary": "reviewed", "findings": []}' + +out=$(verdict "[$(chk a block pass)]" '[]' low) +check "all pass, low tier → safe" "safe-to-merge 0" "$(jq -r '"\(.verdict) \(.exit_code)"' <<<"$out")" + +out=$(verdict "[$(chk drift attention fail fail fp1 fp1)]" '[]' low) +check "same failure on base → pre-existing" "pre-existing safe-to-merge" "$(jq -r '"\(.checks[0].outcome) \(.verdict)"' <<<"$out")" + +out=$(verdict "[$(chk drift attention fail fail fp1 fp2)]" '[]' low) +check "different failure on base → changed" "changed needs-human" "$(jq -r '"\(.checks[0].outcome) \(.verdict)"' <<<"$out")" + +out=$(verdict "[$(chk build block fail pass fp1)]" '[]' low) +check "block check regresses → block" "regression block 1" "$(jq -r '"\(.checks[0].outcome) \(.verdict) \(.exit_code)"' <<<"$out")" + +out=$(verdict "[$(chk tidy attention fail pass fp1)]" '[]' low) +check "attention check regresses → needs-human" "needs-human" "$(jq -r '.verdict' <<<"$out")" + +out=$(verdict "[$(chk smoke block error)]" '[]' low) +check "check error → exit 2" "needs-human 2" "$(jq -r '"\(.verdict) \(.exit_code)"' <<<"$out")" + +out=$(verdict "[$(chk docker block skip)]" '[]' low) +check "required check skipped → needs-human" "needs-human" "$(jq -r '.verdict' <<<"$out")" + +out=$(verdict "[$(chk a block pass)]" "[$(gen g1 discriminating fail pass)]" medium "$IMPACT") +check "medium + impact + proven check → safe" "proven true safe-to-merge" "$(jq -r '"\(.generated_checks[0].outcome) \(.generated_checks[0].counted) \(.verdict)"' <<<"$out")" + +out=$(verdict "[$(chk a block pass)]" "[$(gen g1 discriminating pass pass)]" medium "$IMPACT") +check "passes on old version → does not count" "not-discriminating false needs-human" "$(jq -r '"\(.generated_checks[0].outcome) \(.generated_checks[0].counted) \(.verdict)"' <<<"$out")" + +out=$(verdict "[$(chk a block pass)]" "[$(gen g1 discriminating fail pass)]" medium) +check "medium without impact review → needs-human" "needs-human" "$(jq -r '.verdict' <<<"$out")" + +out=$(verdict "[$(chk a block pass)]" "[$(gen g1 guard pass fail)]" low) +check "guard regresses → finding" "regression needs-human" "$(jq -r '"\(.generated_checks[0].outcome) \(.verdict)"' <<<"$out")" + +out=$(verdict "[$(chk a block pass)]" "[$(gen g1 guard fail fail)]" low) +check "guard failing on base → invalid, ignored" "invalid safe-to-merge" "$(jq -r '"\(.generated_checks[0].outcome) \(.verdict)"' <<<"$out")" + +out=$(verdict "[$(chk a block pass)]" '[]' high "$IMPACT") +check "high tier → needs-human" "needs-human" "$(jq -r '.verdict' <<<"$out")" + +out=$(verdict "[$(chk a block pass)]" '[]' low '{"findings": [{"severity": "block", "text": "breaking API used"}]}') +check "agent block finding → block" "block" "$(jq -r '.verdict' <<<"$out")" + +out=$(verdict "[$(chk a block pass)]" '[]' low '{"tier": "high", "findings": []}') +check "agent can raise the tier" "high needs-human" "$(jq -r '"\(.tier) \(.verdict)"' <<<"$out")" + +# ---- rendering + +tmp=$(mktemp -d) +trap 'rm -rf "$tmp"' EXIT +verdict "[$(chk drift attention fail fail fp1 fp1)]" "[$(gen g1 discriminating fail pass)]" medium "$IMPACT" | + jq '.pr += {base_ref: "main", base_sha: "def", merge: {status: "merged"}} | .classification += {go_directive: {}}' >"$tmp/result.json" +"$ROOT/render-comment.sh" "$tmp/result.json" >"$tmp/comment.md" +check "comment has marker" 1 "$(grep -c '^$' "$tmp/comment.md")" +check "comment lists pre-existing issue" 1 "$(grep -c 'Already failing on `main`' "$tmp/comment.md")" +check "comment shows proven generated check" 1 "$(grep -c 'proven (fails on base, passes on PR)' "$tmp/comment.md")" + +echo +if [ "$failures" -gt 0 ]; then + echo "$failures test(s) failed" + exit 1 +fi +echo "all tests passed" diff --git a/scripts/dependency-pr/verify.sh b/scripts/dependency-pr/verify.sh new file mode 100755 index 000000000..e21d46835 --- /dev/null +++ b/scripts/dependency-pr/verify.sh @@ -0,0 +1,362 @@ +#!/usr/bin/env bash +# Verifies a dependency-update PR (Dependabot) without side effects on GitHub. +# +# Usage: +# scripts/dependency-pr/verify.sh (--pr N | --branch NAME) [options] +# +# Options: +# --repo OWNER/NAME GitHub repo for PR metadata (default: launchdarkly/ldcli) +# --remote NAME git remote to fetch from (default: origin) +# --base BRANCH base branch for --branch mode (default: main; --pr uses the PR's base) +# --out-dir DIR output directory (default: .verify-out/pr-N or .verify-out/branch-NAME) +# --profile P fast (default) | full (adds govulncheck, golangci-lint) +# --phase P all (default) | generated (re-run generated checks only, reusing +# the worktrees and baseline results) | render (recompute verdict and comment) +# --only IDS comma-separated baseline check ids to run (debugging) +# -h, --help +# +# Outputs (in the out dir): result.json, comment.md, logs//.log, +# work/{base,pr} (git worktrees), state/ (raw check records). +# Generated per-PR checks are read from /generated/checks.json and the +# agent's impact review from /agent/impact.json, when present. +# +# Exit: 0 safe to merge, 1 needs a human or block, 2 verifier/infra error. +# Never pushes, comments, approves, or merges. Posting is post-comment.sh. +set -uo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/lib/common.sh" + +PR="" BRANCH="" REPO="launchdarkly/ldcli" REMOTE="origin" BASE_BRANCH="main" +OUT="" PROFILE="fast" PHASE="all" ONLY="" + +usage() { sed -n '2,/^set -uo/p' "$0" | sed '$d; s/^# \{0,1\}//'; } + +while [ $# -gt 0 ]; do + case "$1" in + --pr) PR="${2:?}"; shift 2 ;; + --branch) BRANCH="${2:?}"; shift 2 ;; + --repo) REPO="${2:?}"; shift 2 ;; + --remote) REMOTE="${2:?}"; shift 2 ;; + --base) BASE_BRANCH="${2:?}"; shift 2 ;; + --out-dir) OUT="${2:?}"; shift 2 ;; + --profile) PROFILE="${2:?}"; shift 2 ;; + --phase) PHASE="${2:?}"; shift 2 ;; + --only) ONLY="${2:?}"; shift 2 ;; + -h | --help) usage; exit 0 ;; + *) usage >&2; die_infra "unknown argument: $1" ;; + esac +done + +[ -n "$PR$BRANCH" ] || { usage >&2; die_infra "pass --pr N or --branch NAME"; } +[ -z "$PR" ] || [ -z "$BRANCH" ] || die_infra "pass only one of --pr and --branch" +[ -z "$PR" ] || [[ "$PR" =~ ^[0-9]+$ ]] || die_infra "--pr must be a number" +case "$PROFILE" in fast | full) ;; *) die_infra "--profile must be fast or full" ;; esac +case "$PHASE" in all | generated | render) ;; *) die_infra "--phase must be all, generated, or render" ;; esac + +REPO_ROOT="$(git -C "$SCRIPT_DIR" rev-parse --show-toplevel)" || die_infra "not inside a git repository" +RUN_KEY="pr-$PR" +[ -n "$BRANCH" ] && RUN_KEY="branch-$(sanitize "$BRANCH")" +OUT="${OUT:-$REPO_ROOT/.verify-out/$RUN_KEY}" +mkdir -p "$OUT" +OUT="$(cd "$OUT" && pwd)" +WORK="$OUT/work" STATE="$OUT/state" LOGS="$OUT/logs" +BASE_WT="$WORK/base" PR_WT="$WORK/pr" +REF_NS="refs/verify/$RUN_KEY" +mkdir -p "$STATE" "$LOGS" + +require_tools git jq +init_go_cache_env + +# ---------------------------------------------------------------- setup + +fetch_with_retry() { + local i + for i in 1 2 3 4; do + git -C "$REPO_ROOT" fetch -q --no-tags "$REMOTE" "$@" && return 0 + log "fetch failed (attempt $i); retrying" + sleep $((2 ** (i + 1))) + done + return 1 +} + +setup_refs() { + local gh_json="$STATE/gh-pr.json" + echo 'null' >"$gh_json" + if [ -z "$PR" ] && command -v gh >/dev/null 2>&1; then + PR=$(gh pr list --repo "$REPO" --head "$BRANCH" --state open --json number --jq '.[0].number // empty' 2>/dev/null || true) + [ -n "$PR" ] && log "branch $BRANCH has open PR #$PR" + fi + if [ -n "$PR" ]; then + require_tools gh + gh pr view "$PR" --repo "$REPO" \ + --json number,url,title,body,author,headRefName,headRefOid,baseRefName,labels,statusCheckRollup,state,isDraft \ + >"$gh_json" || die_infra "could not read PR #$PR from $REPO (is gh authenticated?)" + [ -n "$BRANCH" ] || BASE_BRANCH=$(jq -r '.baseRefName' "$gh_json") + fi + + local head_src="refs/pull/$PR/head" + [ -n "$BRANCH" ] && head_src="refs/heads/$BRANCH" + log "fetching $head_src and $BASE_BRANCH from $REMOTE" + if ! fetch_with_retry "+$head_src:$REF_NS/head" "+refs/heads/$BASE_BRANCH:$REF_NS/base"; then + if [ -n "$BRANCH" ] && git -C "$REPO_ROOT" rev-parse -q --verify "$BRANCH^{commit}" >/dev/null && + git -C "$REPO_ROOT" rev-parse -q --verify "$BASE_BRANCH^{commit}" >/dev/null; then + log "using local refs for $BRANCH and $BASE_BRANCH" + git -C "$REPO_ROOT" update-ref "$REF_NS/head" "$BRANCH" + git -C "$REPO_ROOT" update-ref "$REF_NS/base" "$BASE_BRANCH" + else + die_infra "could not fetch $head_src / $BASE_BRANCH from $REMOTE" + fi + fi + HEAD_SHA=$(git -C "$REPO_ROOT" rev-parse "$REF_NS/head") + BASE_SHA=$(git -C "$REPO_ROOT" rev-parse "$REF_NS/base") + local expected + expected=$(jq -r '.headRefOid // empty' "$gh_json") + if [ -n "$expected" ] && [ "$expected" != "$HEAD_SHA" ]; then + log "warning: PR head moved during setup ($expected → $HEAD_SHA); verifying $HEAD_SHA" + fi +} + +remove_worktree() { + if [ -d "$1" ]; then + git -C "$REPO_ROOT" worktree remove --force "$1" 2>/dev/null || rm -rf "$1" + fi + git -C "$REPO_ROOT" worktree prune +} + +setup_worktrees() { + remove_worktree "$BASE_WT" + remove_worktree "$PR_WT" + mkdir -p "$WORK" + MERGE_BASE=$(git -C "$REPO_ROOT" merge-base "$BASE_SHA" "$HEAD_SHA") || die_infra "no merge base between PR and $BASE_BRANCH" + BEHIND=$(git -C "$REPO_ROOT" rev-list --count "$MERGE_BASE..$BASE_SHA") + git -C "$REPO_ROOT" diff --name-only "$MERGE_BASE" "$HEAD_SHA" >"$STATE/changed-files.txt" + + git -C "$REPO_ROOT" worktree add -q --detach "$PR_WT" "$BASE_SHA" || die_infra "worktree add failed" + MERGE_STATUS="merged" CONFLICTS="[]" BASE_TESTED="$BASE_SHA" + if ! git -C "$PR_WT" -c user.name="dependency-pr-verify" -c user.email="verify@localhost" \ + merge -q --no-ff --no-edit -m "verify: merge $RUN_KEY into $BASE_BRANCH" "$HEAD_SHA" >"$LOGS/merge.log" 2>&1; then + CONFLICTS=$(git -C "$PR_WT" diff --name-only --diff-filter=U | jq -R -s 'split("\n") | map(select(length > 0))') + git -C "$PR_WT" merge --abort 2>/dev/null + git -C "$PR_WT" checkout -q --detach "$HEAD_SHA" || die_infra "checkout of PR head failed" + MERGE_STATUS="conflict" + # Compare against the commit the PR branched from, so main's newer changes + # don't show up as downgrades. + BASE_TESTED="$MERGE_BASE" + log "PR conflicts with $BASE_BRANCH; testing the PR head against its merge base" + fi + git -C "$REPO_ROOT" worktree add -q --detach "$BASE_WT" "$BASE_TESTED" || die_infra "worktree add failed" + PR_TESTED=$(git -C "$PR_WT" rev-parse HEAD) +} + +write_meta() { + jq -n \ + --slurpfile gh "$STATE/gh-pr.json" \ + --arg mode "$([ -n "$BRANCH" ] && echo branch || echo pr)" \ + --arg repo "$REPO" --arg branch "$BRANCH" --arg base_ref "$BASE_BRANCH" \ + --arg head "$HEAD_SHA" --arg base "$BASE_SHA" --arg mb "$MERGE_BASE" \ + --arg base_tested "$BASE_TESTED" --arg pr_tested "$PR_TESTED" \ + --argjson behind "$BEHIND" --arg merge "$MERGE_STATUS" --argjson conflicts "$CONFLICTS" ' + ($gh[0]) as $g + | { + mode: $mode, repo: $repo, + number: ($g.number // null), url: ($g.url // null), + title: ($g.title // null), body: ($g.body // null), + author: ($g.author.login // null), state: ($g.state // null), + labels: (($g.labels // []) | map(.name)), + head_ref: ($g.headRefName // $branch), head_sha: $head, + base_ref: $base_ref, base_sha: $base, merge_base: $mb, behind_by: $behind, + merge: {status: $merge, conflicts: $conflicts}, + tested: {base: $base_tested, pr: $pr_tested}, + ci: (if $g == null then null + else ($g.statusCheckRollup // []) | map({name: (.name // .context), status, conclusion, state, workflow: .workflowName}) + end) + }' >"$STATE/pr.json" +} + +# ---------------------------------------------------------------- runner + +# run_one