Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
110 changes: 104 additions & 6 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ permissions:
actions: read
contents: read
packages: write
pull-requests: read

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can the whole PR be reduced to just

diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index ce8191d..fbc1a50 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -8,6 +8,7 @@ permissions:
   actions: read
   contents: read
   packages: write
+  pull-requests: read
 
 concurrency:
   group: |
@@ -25,6 +26,7 @@ jobs:
     runs-on: ${{ vars.RUNNER_CHECK_SKIP || 'ubuntu-24.04-arm' }}
     outputs:
       skip: ${{ steps.skip-check.outputs.skip }}
+      run-build-tests: ${{ steps.classify-changes.outputs.run-build-tests }}
       runner-amd64: ${{ steps.select-runner.outputs.runner_amd64 }}
       runner-arm64: ${{ steps.select-runner.outputs.runner_arm64 }}
       use-blacksmith: ${{ steps.select-runner.outputs.use_blacksmith }}
@@ -47,6 +49,24 @@ jobs:
             echo "skip=false" >> $GITHUB_OUTPUT
           fi
 
+      - name: Classify changed paths
+        id: classify-changes
+        if: ${{ steps.skip-check.outputs.skip == 'false' }}
+        env:
+          GH_TOKEN: ${{ github.token }}
+        run: |
+          RUN=true
+          if [[ "${{ github.event_name }}" == "pull_request_target" ]]; then
+            FILES="$(gh api --paginate \
+              "repos/${{ github.repository }}/pulls/${{ github.event.pull_request.number }}/files" \
+              --jq '.[] | .filename, (.previous_filename // empty)')" || FILES=""
+            if [[ -n "$FILES" ]] && ! grep -qvE '\.md$' <<< "$FILES" && ! grep -qE '^src/' <<< "$FILES"; then
+              RUN=false
+            fi
+          fi
+          echo "Full build and test matrix: $RUN"
+          echo "run-build-tests=$RUN" >> "$GITHUB_OUTPUT"
+
       - name: Checkout code
         if: ${{ steps.skip-check.outputs.skip == 'false' }}
         uses: actions/checkout@v6
@@ -86,7 +106,9 @@ jobs:
   cache-sources:
     name: Cache depends sources
     needs: [check-skip]
-    if: ${{ needs.check-skip.outputs.skip == 'false' }}
+    if: |
+      needs.check-skip.outputs.skip == 'false' &&
+      needs.check-skip.outputs.run-build-tests == 'true'
     uses: ./.github/workflows/cache-depends-sources.yml
     with:
       runs-on: ${{ needs.check-skip.outputs['runner-arm64'] }}
@@ -94,7 +116,9 @@ jobs:
   container:
     name: Build container
     needs: [check-skip]
-    if: ${{ needs.check-skip.outputs.skip == 'false' }}
+    if: |
+      needs.check-skip.outputs.skip == 'false' &&
+      needs.check-skip.outputs.run-build-tests == 'true'
     uses: ./.github/workflows/build-container.yml
     with:
       context: ./contrib/containers/ci
-- 

?

This classify_ci_changes.py is over-complicated


concurrency:
group: |
Expand All @@ -25,6 +26,7 @@ jobs:
runs-on: ${{ vars.RUNNER_CHECK_SKIP || 'ubuntu-24.04-arm' }}
outputs:
skip: ${{ steps.skip-check.outputs.skip }}
run-build-tests: ${{ steps.classify-changes.outputs.run-build-tests }}
runner-amd64: ${{ steps.select-runner.outputs.runner_amd64 }}
runner-arm64: ${{ steps.select-runner.outputs.runner_arm64 }}
use-blacksmith: ${{ steps.select-runner.outputs.use_blacksmith }}
Expand All @@ -39,18 +41,108 @@ jobs:
run: |
if [[ "${{ github.event_name }}" == "push" && "${{ vars.SKIP_ON_PUSH }}" != "" ]]; then
echo "Skipping build on push due to SKIP_ON_PUSH environment variable"
echo "skip=true" >> $GITHUB_OUTPUT
echo "skip=true" >> "$GITHUB_OUTPUT"
elif [[ "${{ github.event_name }}" == "pull_request_target" && "${{ vars.SKIP_ON_PR }}" != "" ]]; then
echo "Skipping build on pull request due to SKIP_ON_PR environment variable"
echo "skip=true" >> $GITHUB_OUTPUT
echo "skip=true" >> "$GITHUB_OUTPUT"
else
echo "skip=false" >> $GITHUB_OUTPUT
echo "skip=false" >> "$GITHUB_OUTPUT"
fi

- name: Checkout code
if: ${{ steps.skip-check.outputs.skip == 'false' }}
uses: actions/checkout@v6

- name: Collect changed paths
id: changed-paths
if: ${{ steps.skip-check.outputs.skip == 'false' }}
uses: actions/github-script@v8
env:
CHANGED_PATHS_FILE: ${{ runner.temp }}/changed-paths.json
with:
script: |
const fs = require('fs');
let complete = true;
let files = [];

try {
if (context.eventName === 'pull_request_target') {
files = await github.paginate(github.rest.pulls.listFiles, {
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: context.payload.pull_request.number,
per_page: 100,
});
if (files.length >= 3000) {
complete = false;
core.warning('Pull request file list reached the GitHub API limit');
}
} else if (context.eventName === 'push') {
const after = context.payload.after;
if (context.ref.startsWith('refs/tags/')) {
complete = false;
} else {
// Comparing with the last successful run preserves coverage when a
// later push cancels an in-flight run before its build matrix finishes.
const previousRuns = await github.rest.actions.listWorkflowRuns({
owner: context.repo.owner,
repo: context.repo.repo,
workflow_id: 'build.yml',
branch: context.ref.replace('refs/heads/', ''),
event: 'push',
status: 'success',
per_page: 1,
});
const previousSha = previousRuns.data.workflow_runs[0]?.head_sha;
if (!previousSha) {
complete = false;
} else {
const comparison = await github.rest.repos.compareCommitsWithBasehead({
owner: context.repo.owner,
repo: context.repo.repo,
basehead: `${previousSha}...${after}`,
});
files = comparison.data.files || [];
if (files.length >= 300) {
complete = false;
core.warning('Push file list reached the GitHub API limit');
}
}
}
} else {
complete = false;
}
} catch (error) {
complete = false;
core.warning(`Unable to collect a complete changed path list: ${error.message}`);
}

const paths = [...new Set(files.flatMap(file =>
[file.filename, file.previous_filename].filter(Boolean)
))].sort();
fs.writeFileSync(process.env.CHANGED_PATHS_FILE, JSON.stringify(paths));
core.setOutput('complete', complete.toString());
core.setOutput('removed-or-renamed', files.some(file =>
file.status === 'removed' || file.status === 'renamed'
).toString());

- name: Classify changed paths
id: classify-changes
if: ${{ steps.skip-check.outputs.skip == 'false' }}
env:
CHANGED_PATHS_COMPLETE: ${{ steps.changed-paths.outputs.complete }}
CHANGED_PATHS_FILE: ${{ runner.temp }}/changed-paths.json
CHANGED_PATHS_REMOVED_OR_RENAMED: ${{ steps.changed-paths.outputs.removed-or-renamed }}
run: |
if ! python3 .github/workflows/classify_ci_changes.py \
--complete "$CHANGED_PATHS_COMPLETE" \
--removed-or-renamed "$CHANGED_PATHS_REMOVED_OR_RENAMED" \
"$CHANGED_PATHS_FILE"; then
echo "Path classification failed; running the full build and test matrix"
echo "run-build-tests=true" >> "$GITHUB_OUTPUT"
echo "decision-reason=path classification failed" >> "$GITHUB_OUTPUT"
fi

- name: Select runners
id: select-runner
if: ${{ steps.skip-check.outputs.skip == 'false' }}
Expand All @@ -65,7 +157,9 @@ jobs:

- name: Get base image digest
id: base-image
if: ${{ steps.skip-check.outputs.skip == 'false' }}
if: |
steps.skip-check.outputs.skip == 'false' &&
steps.classify-changes.outputs.run-build-tests == 'true'
run: |
# Fetch the canonical manifest digest for ubuntu:noble so the
# depends cache key changes when Canonical pushes base image
Expand All @@ -86,15 +180,19 @@ jobs:
cache-sources:
name: Cache depends sources
needs: [check-skip]
if: ${{ needs.check-skip.outputs.skip == 'false' }}
if: |
needs.check-skip.outputs.skip == 'false' &&
needs.check-skip.outputs.run-build-tests == 'true'
uses: ./.github/workflows/cache-depends-sources.yml
with:
runs-on: ${{ needs.check-skip.outputs['runner-arm64'] }}

container:
name: Build container
needs: [check-skip]
if: ${{ needs.check-skip.outputs.skip == 'false' }}
if: |
needs.check-skip.outputs.skip == 'false' &&
needs.check-skip.outputs.run-build-tests == 'true'
uses: ./.github/workflows/build-container.yml
with:
context: ./contrib/containers/ci
Expand Down
163 changes: 163 additions & 0 deletions .github/workflows/classify_ci_changes.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
# Copyright (c) 2026 The Dash Core developers
# Distributed under the MIT software license, see the accompanying
# file COPYING or http://www.opensource.org/licenses/mit-license.php.

import argparse
import json
import os
from pathlib import PurePosixPath
from typing import Dict, List, Optional, Sequence


EXCLUDED_EXACT_PATHS = {
"doc/.gitignore",
}
EXCLUDED_PREFIXES = (
".github/ISSUE_TEMPLATE/",
".github/PULL_REQUEST_TEMPLATE/",
)
EXCLUDED_DOC_SUFFIXES = {".1", ".5", ".8", ".png", ".svg", ".txt"}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep packaged man pages build-relevant

When a change deletes or renames a tracked doc/man/*.1 file without updating the packaging rules, this suffix exclusion skips the build matrix even though doc/man/Makefile.am:3-25 lists those files in dist_man1_MANS. The source build runs make distdir at ci/dash/build_src.sh:34, which would catch the resulting missing distribution input, while the remaining lint job does not exercise that target; such a packaging-breaking change can therefore pass CI.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed and fixed in 5cde031 at the broader packaging boundary. Any removed or renamed path now forces the full build/test matrix, so stale dist_man1_MANS entries and equivalent distribution-manifest failures are exercised while content-only manpage edits can remain in the documentation exclusion zone. Focused classifier coverage verifies the fail-open decision.


🤖 Posted autonomously by Codex on behalf of pasta.

BUILD_RELEVANT_PATHS = {"COPYING", "doc/README_windows.txt"}


def is_excluded_path(path: str) -> bool:
if not path or path in BUILD_RELEVANT_PATHS:
return False
if path in EXCLUDED_EXACT_PATHS or path.startswith(EXCLUDED_PREFIXES):
return True

suffix = PurePosixPath(path).suffix
if suffix == ".md":
return True
return path.startswith("doc/") and suffix in EXCLUDED_DOC_SUFFIXES

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep Windows installer inputs build-relevant

When a change deletes or renames doc/README_windows.txt, this condition classifies both the old and new .txt paths as excluded, so the build matrix is skipped. However, that file is part of WINDOWS_PACKAGING in Makefile.am:60-63 and is embedded in the NSIS installer by share/setup.nsi.in:77; the source workflow validates it through make distdir, and the Windows configuration runs the deploy target. Since the remaining lint job exercises neither path, such a change can pass CI while breaking distribution or Windows installer creation, so this installer input should be exempted from the documentation exclusion.

Useful? React with 👍 / 👎.

@PastaPastaPasta PastaPastaPasta Aug 29, 2026

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed and fixed in 2e2e88d. Both Windows installer inputs, COPYING and doc/README_windows.txt, are now explicitly build-relevant, with focused classifier coverage.


🤖 Posted autonomously by Codex on behalf of pasta.



def classify_changes(
paths: Sequence[str], complete: bool, removed_or_renamed: bool = False
) -> Dict[str, object]:
unique_paths = sorted(set(paths))
if not complete:
return {
"run_build_tests": True,
"reason": "changed path list is incomplete",
"paths": unique_paths,
"triggering_paths": [],
}
if removed_or_renamed:
return {
"run_build_tests": True,
"reason": "a changed path was removed or renamed",
"paths": unique_paths,
"triggering_paths": [],
}
if not unique_paths:
return {
"run_build_tests": True,
"reason": "no changed paths were reported",
"paths": unique_paths,
"triggering_paths": [],
}

triggering_paths = [path for path in unique_paths if not is_excluded_path(path)]
if triggering_paths:
reason = "build-relevant or unclassified paths changed"
else:
reason = "all changed paths are in CI exclusion zones"
return {
"run_build_tests": bool(triggering_paths),
"reason": reason,
"paths": unique_paths,
"triggering_paths": triggering_paths,
}


def load_paths(path: str) -> List[str]:
with open(path, "r", encoding="utf-8") as file:
paths = json.load(file)
if not isinstance(paths, list) or not all(isinstance(item, str) for item in paths):
raise ValueError("changed paths must be a JSON array of strings")
return paths


def write_github_output(path: Optional[str], result: Dict[str, object]) -> None:
if not path:
return
with open(path, "a", encoding="utf-8") as file:
file.write(
"run-build-tests={}\n".format(
"true" if result["run_build_tests"] else "false"
)
)
file.write("decision-reason={}\n".format(result["reason"]))


def write_step_summary(
path: Optional[str], result: Dict[str, object], complete: bool
) -> None:
if not path:
return

triggering_paths = result["triggering_paths"]
with open(path, "a", encoding="utf-8") as file:
file.write("### Build and test path classification\n")
file.write(
"- Changed path list complete: {}\n".format(
"yes" if complete else "no"
)
)
file.write("- Changed paths examined: {}\n".format(len(result["paths"])))
file.write(
"- Full build and test matrix: {}\n".format(
"required" if result["run_build_tests"] else "skipped"
)
)
file.write("- Decision: `{}`\n".format(result["reason"]))
if triggering_paths:
file.write("- Triggering paths (up to 20):\n")
for changed_path in triggering_paths[:20]:
formatted_path = json.dumps(changed_path, ensure_ascii=True).replace(
"`", "\\u0060"
)
file.write(
" - `{}`\n".format(formatted_path)
)


def parse_args(argv: Sequence[str]) -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Classify changed paths for Dash CI.")
parser.add_argument("paths_json", help="JSON file containing changed paths")
parser.add_argument(
"--complete",
choices=("true", "false"),
required=True,
help="Whether the changed path list is known to be complete",
)
parser.add_argument(
"--removed-or-renamed",
choices=("true", "false"),
required=True,
help="Whether a changed path was removed or renamed",
)
return parser.parse_args(argv)


def main(argv: Sequence[str]) -> int:
args = parse_args(argv)
complete = args.complete == "true"
result = classify_changes(
load_paths(args.paths_json), complete, args.removed_or_renamed == "true"
)
write_github_output(os.environ.get("GITHUB_OUTPUT"), result)
write_step_summary(os.environ.get("GITHUB_STEP_SUMMARY"), result, complete)
print(
"run-build-tests={} ({})".format(
"true" if result["run_build_tests"] else "false", result["reason"]
)
)
return 0


if __name__ == "__main__":
import sys

sys.exit(main(sys.argv[1:]))
Loading