From 0964eebb101b8d1c5ea90c9a9ef0ae622957de31 Mon Sep 17 00:00:00 2001 From: Alex Gorevski Date: Tue, 17 Feb 2026 11:17:00 -0800 Subject: [PATCH] refactor(ci): extract large inline scripts to scripts/ci/ (#587) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The CI workflow contained a ~90-line bash script for change-detection (lines 38-128) and a ~80-line JavaScript block for lint feedback (lines 292-370) directly inline in the YAML. Large inline scripts are harder to test, lint, and maintain than standalone files. Extract: - Change-detection logic → scripts/ci/detect_change_scope.sh - Lint feedback logic → scripts/ci/lint_feedback.js The workflow now calls these external scripts. GitHub expression values that were previously interpolated inline are passed as environment variables instead. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/ci.yml | 177 ++---------------------------- scripts/ci/detect_change_scope.sh | 95 ++++++++++++++++ scripts/ci/lint_feedback.js | 90 +++++++++++++++ 3 files changed, 193 insertions(+), 169 deletions(-) create mode 100755 scripts/ci/detect_change_scope.sh create mode 100644 scripts/ci/lint_feedback.js diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e377d15..2c30159 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -35,97 +35,10 @@ jobs: - name: Detect docs-only changes id: scope shell: bash - run: | - set -euo pipefail - - write_empty_docs_files() { - { - echo "docs_files<> "$GITHUB_OUTPUT" - } - - if [ "${{ github.event_name }}" = "pull_request" ]; then - BASE="${{ github.event.pull_request.base.sha }}" - else - BASE="${{ github.event.before }}" - fi - - if [ -z "$BASE" ] || ! git cat-file -e "$BASE^{commit}" 2>/dev/null; then - { - echo "docs_only=false" - echo "docs_changed=false" - echo "rust_changed=true" - echo "workflow_changed=false" - echo "base_sha=" - } >> "$GITHUB_OUTPUT" - write_empty_docs_files - exit 0 - fi - - CHANGED="$(git diff --name-only "$BASE" HEAD || true)" - if [ -z "$CHANGED" ]; then - { - echo "docs_only=false" - echo "docs_changed=false" - echo "rust_changed=false" - echo "workflow_changed=false" - echo "base_sha=$BASE" - } >> "$GITHUB_OUTPUT" - write_empty_docs_files - exit 0 - fi - - docs_only=true - docs_changed=false - rust_changed=false - workflow_changed=false - docs_files=() - while IFS= read -r file; do - [ -z "$file" ] && continue - - if [[ "$file" == .github/workflows/* ]]; then - workflow_changed=true - fi - - if [[ "$file" == docs/* ]] \ - || [[ "$file" == *.md ]] \ - || [[ "$file" == *.mdx ]] \ - || [[ "$file" == "LICENSE" ]] \ - || [[ "$file" == ".markdownlint-cli2.yaml" ]] \ - || [[ "$file" == .github/ISSUE_TEMPLATE/* ]] \ - || [[ "$file" == .github/pull_request_template.md ]]; then - if [[ "$file" == *.md ]] \ - || [[ "$file" == *.mdx ]] \ - || [[ "$file" == "LICENSE" ]] \ - || [[ "$file" == .github/pull_request_template.md ]]; then - docs_changed=true - docs_files+=("$file") - fi - continue - fi - - docs_only=false - - if [[ "$file" == src/* ]] \ - || [[ "$file" == tests/* ]] \ - || [[ "$file" == "Cargo.toml" ]] \ - || [[ "$file" == "Cargo.lock" ]] \ - || [[ "$file" == "deny.toml" ]]; then - rust_changed=true - fi - done <<< "$CHANGED" - - { - echo "docs_only=$docs_only" - echo "docs_changed=$docs_changed" - echo "rust_changed=$rust_changed" - echo "workflow_changed=$workflow_changed" - echo "base_sha=$BASE" - echo "docs_files<> "$GITHUB_OUTPUT" + env: + EVENT_NAME: ${{ github.event_name }} + BASE_SHA: ${{ github.event_name == 'pull_request' && github.event.pull_request.base.sha || github.event.before }} + run: ./scripts/ci/detect_change_scope.sh lint: name: Lint Gate (Format + Clippy) @@ -279,6 +192,8 @@ jobs: pull-requests: write issues: write steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - name: Post actionable lint failure summary if: always() uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 @@ -290,84 +205,8 @@ jobs: DOCS_RESULT: ${{ needs.docs-quality.result }} with: script: | - const owner = context.repo.owner; - const repo = context.repo.repo; - const issueNumber = context.payload.pull_request?.number; - if (!issueNumber) return; - - const marker = ""; - const rustChanged = process.env.RUST_CHANGED === "true"; - const docsChanged = process.env.DOCS_CHANGED === "true"; - const lintResult = process.env.LINT_RESULT || "skipped"; - const lintDeltaResult = process.env.LINT_DELTA_RESULT || "skipped"; - const docsResult = process.env.DOCS_RESULT || "skipped"; - - const failures = []; - if (rustChanged && !["success", "skipped"].includes(lintResult)) { - failures.push("`Lint Gate (Format + Clippy)` failed."); - } - if (rustChanged && !["success", "skipped"].includes(lintDeltaResult)) { - failures.push("`Lint Gate (Strict Delta)` failed."); - } - if (docsChanged && !["success", "skipped"].includes(docsResult)) { - failures.push("`Docs Quality` failed."); - } - - const comments = await github.paginate(github.rest.issues.listComments, { - owner, - repo, - issue_number: issueNumber, - per_page: 100, - }); - const existing = comments.find((comment) => (comment.body || "").includes(marker)); - - if (failures.length === 0) { - if (existing) { - await github.rest.issues.deleteComment({ - owner, - repo, - comment_id: existing.id, - }); - } - core.info("No lint/docs gate failures. No feedback comment required."); - return; - } - - const runUrl = `${context.serverUrl}/${owner}/${repo}/actions/runs/${context.runId}`; - const body = [ - marker, - "### CI lint feedback", - "", - "This PR failed one or more fast lint/documentation gates:", - "", - ...failures.map((item) => `- ${item}`), - "", - "Open the failing logs in this run:", - `- ${runUrl}`, - "", - "Local fix commands:", - "- `./scripts/ci/rust_quality_gate.sh`", - "- `./scripts/ci/rust_strict_delta_gate.sh`", - "- `./scripts/ci/docs_quality_gate.sh`", - "", - "After fixes, push a new commit and CI will re-run automatically.", - ].join("\n"); - - if (existing) { - await github.rest.issues.updateComment({ - owner, - repo, - comment_id: existing.id, - body, - }); - } else { - await github.rest.issues.createComment({ - owner, - repo, - issue_number: issueNumber, - body, - }); - } + const script = require('./scripts/ci/lint_feedback.js'); + await script({github, context, core}); workflow-owner-approval: name: Workflow Owner Approval diff --git a/scripts/ci/detect_change_scope.sh b/scripts/ci/detect_change_scope.sh new file mode 100755 index 0000000..a00e6d8 --- /dev/null +++ b/scripts/ci/detect_change_scope.sh @@ -0,0 +1,95 @@ +#!/usr/bin/env bash +# Detect change scope for CI pipeline. +# Classifies changed files into docs-only, rust, workflow categories +# and writes results to $GITHUB_OUTPUT. +# +# Required environment variables: +# GITHUB_OUTPUT — GitHub Actions output file +# EVENT_NAME — github.event_name (push or pull_request) +# BASE_SHA — base commit SHA to diff against +set -euo pipefail + +write_empty_docs_files() { + { + echo "docs_files<> "$GITHUB_OUTPUT" +} + +BASE="$BASE_SHA" + +if [ -z "$BASE" ] || ! git cat-file -e "$BASE^{commit}" 2>/dev/null; then + { + echo "docs_only=false" + echo "docs_changed=false" + echo "rust_changed=true" + echo "workflow_changed=false" + echo "base_sha=" + } >> "$GITHUB_OUTPUT" + write_empty_docs_files + exit 0 +fi + +CHANGED="$(git diff --name-only "$BASE" HEAD || true)" +if [ -z "$CHANGED" ]; then + { + echo "docs_only=false" + echo "docs_changed=false" + echo "rust_changed=false" + echo "workflow_changed=false" + echo "base_sha=$BASE" + } >> "$GITHUB_OUTPUT" + write_empty_docs_files + exit 0 +fi + +docs_only=true +docs_changed=false +rust_changed=false +workflow_changed=false +docs_files=() +while IFS= read -r file; do + [ -z "$file" ] && continue + + if [[ "$file" == .github/workflows/* ]]; then + workflow_changed=true + fi + + if [[ "$file" == docs/* ]] \ + || [[ "$file" == *.md ]] \ + || [[ "$file" == *.mdx ]] \ + || [[ "$file" == "LICENSE" ]] \ + || [[ "$file" == ".markdownlint-cli2.yaml" ]] \ + || [[ "$file" == .github/ISSUE_TEMPLATE/* ]] \ + || [[ "$file" == .github/pull_request_template.md ]]; then + if [[ "$file" == *.md ]] \ + || [[ "$file" == *.mdx ]] \ + || [[ "$file" == "LICENSE" ]] \ + || [[ "$file" == .github/pull_request_template.md ]]; then + docs_changed=true + docs_files+=("$file") + fi + continue + fi + + docs_only=false + + if [[ "$file" == src/* ]] \ + || [[ "$file" == tests/* ]] \ + || [[ "$file" == "Cargo.toml" ]] \ + || [[ "$file" == "Cargo.lock" ]] \ + || [[ "$file" == "deny.toml" ]]; then + rust_changed=true + fi +done <<< "$CHANGED" + +{ + echo "docs_only=$docs_only" + echo "docs_changed=$docs_changed" + echo "rust_changed=$rust_changed" + echo "workflow_changed=$workflow_changed" + echo "base_sha=$BASE" + echo "docs_files<> "$GITHUB_OUTPUT" diff --git a/scripts/ci/lint_feedback.js b/scripts/ci/lint_feedback.js new file mode 100644 index 0000000..8b90161 --- /dev/null +++ b/scripts/ci/lint_feedback.js @@ -0,0 +1,90 @@ +// Post actionable lint failure summary as a PR comment. +// Used by the lint-feedback CI job via actions/github-script. +// +// Required environment variables: +// RUST_CHANGED — "true" if Rust files changed +// DOCS_CHANGED — "true" if docs files changed +// LINT_RESULT — result of the lint job +// LINT_DELTA_RESULT — result of the strict delta lint job +// DOCS_RESULT — result of the docs-quality job + +module.exports = async ({ github, context, core }) => { + const owner = context.repo.owner; + const repo = context.repo.repo; + const issueNumber = context.payload.pull_request?.number; + if (!issueNumber) return; + + const marker = ""; + const rustChanged = process.env.RUST_CHANGED === "true"; + const docsChanged = process.env.DOCS_CHANGED === "true"; + const lintResult = process.env.LINT_RESULT || "skipped"; + const lintDeltaResult = process.env.LINT_DELTA_RESULT || "skipped"; + const docsResult = process.env.DOCS_RESULT || "skipped"; + + const failures = []; + if (rustChanged && !["success", "skipped"].includes(lintResult)) { + failures.push("`Lint Gate (Format + Clippy)` failed."); + } + if (rustChanged && !["success", "skipped"].includes(lintDeltaResult)) { + failures.push("`Lint Gate (Strict Delta)` failed."); + } + if (docsChanged && !["success", "skipped"].includes(docsResult)) { + failures.push("`Docs Quality` failed."); + } + + const comments = await github.paginate(github.rest.issues.listComments, { + owner, + repo, + issue_number: issueNumber, + per_page: 100, + }); + const existing = comments.find((comment) => (comment.body || "").includes(marker)); + + if (failures.length === 0) { + if (existing) { + await github.rest.issues.deleteComment({ + owner, + repo, + comment_id: existing.id, + }); + } + core.info("No lint/docs gate failures. No feedback comment required."); + return; + } + + const runUrl = `${context.serverUrl}/${owner}/${repo}/actions/runs/${context.runId}`; + const body = [ + marker, + "### CI lint feedback", + "", + "This PR failed one or more fast lint/documentation gates:", + "", + ...failures.map((item) => `- ${item}`), + "", + "Open the failing logs in this run:", + `- ${runUrl}`, + "", + "Local fix commands:", + "- `./scripts/ci/rust_quality_gate.sh`", + "- `./scripts/ci/rust_strict_delta_gate.sh`", + "- `./scripts/ci/docs_quality_gate.sh`", + "", + "After fixes, push a new commit and CI will re-run automatically.", + ].join("\n"); + + if (existing) { + await github.rest.issues.updateComment({ + owner, + repo, + comment_id: existing.id, + body, + }); + } else { + await github.rest.issues.createComment({ + owner, + repo, + issue_number: issueNumber, + body, + }); + } +};