From 977df1c8ade75d61d495e4d1b5ce0bb84489a43e Mon Sep 17 00:00:00 2001 From: Romain Floreani Date: Wed, 30 Sep 2026 13:57:34 -0400 Subject: [PATCH 1/9] New github workflow that show diff for changes in pixi.lock --- .github/workflows/reusable-pixi-lock_diff.yml | 310 ++++++++++++++++++ 1 file changed, 310 insertions(+) create mode 100644 .github/workflows/reusable-pixi-lock_diff.yml diff --git a/.github/workflows/reusable-pixi-lock_diff.yml b/.github/workflows/reusable-pixi-lock_diff.yml new file mode 100644 index 00000000..1297e825 --- /dev/null +++ b/.github/workflows/reusable-pixi-lock_diff.yml @@ -0,0 +1,310 @@ +# Report the changes made to a pixi.lock file in a readable Markdown format. +# The report is written to the job summary, and posted as a PR comment on pull requests +# (a single comment, updated on each run). + +name: Pixi lock diff + +on: + workflow_call: + inputs: + lockfile-path: + description: Path of the pixi lock file, relative to the repository root + required: false + type: string + default: pixi.lock + manifest-path: + description: > + Path of the pixi manifest, relative to the repository root. Used to tell explicit + dependencies (listed in the manifest) from implicit ones. + required: false + type: string + default: pixi.toml + merge-dependencies: + description: > + How pixi-diff-to-markdown merges the changes of all environments and platforms: + 'no' (one table per environment and platform), 'yes' (a single table), + or 'split-explicit' (one table for explicit dependencies, one for implicit ones). + required: false + type: string + default: split-explicit + environments: + description: > + Comma-separated list of pixi environments to report on (e.g. 'default,test-engine'). + Reports on all environments when empty. + required: false + type: string + default: '' + comment-on-pr: + description: Post the report as a comment on the pull request + required: false + type: boolean + default: true + pixi-version: + description: Version of Pixi to use + required: false + type: string + default: v0.67.2 # default to last version before lock file version v7 + timeout-minutes: + description: Timeout in minutes for the job + required: false + type: number + default: 10 + +permissions: {} + +env: + PIXI_DIFF_VERSION: 0.1.8 + PIXI_DIFF_TO_MARKDOWN_VERSION: 0.3.9 + COMMENT_MARKER: + +jobs: + pixi-lock-diff: + name: pixi.lock diff + runs-on: ubuntu-latest + timeout-minutes: ${{ inputs.timeout-minutes }} + permissions: + contents: read + pull-requests: write + defaults: + run: + shell: bash + env: + LOCKFILE_PATH: ${{ inputs.lockfile-path }} + MANIFEST_PATH: ${{ inputs.manifest-path }} + steps: + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 + with: + fetch-depth: 0 + persist-credentials: false + # the files are read from git history: no need for a full working tree (nor LFS files) + sparse-checkout: | + ${{ inputs.lockfile-path }} + ${{ inputs.manifest-path }} + sparse-checkout-cone-mode: false + + - name: Select commits to compare + id: commits + env: + EVENT_NAME: ${{ github.event_name }} + BASE_REF: ${{ github.base_ref }} + PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }} + PUSH_BEFORE: ${{ github.event.before }} + DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} + run: | + if [[ "$EVENT_NAME" == "pull_request" || "$EVENT_NAME" == "pull_request_target" ]]; then + # compare to the merge base, so that changes made on the base branch since the PR + # was branched off are not reported as changes of the PR + after="$PR_HEAD_SHA" + before=$(git merge-base "origin/$BASE_REF" "$after") + elif [[ "$EVENT_NAME" == "push" ]]; then + after="$GITHUB_SHA" + if [[ -n "$PUSH_BEFORE" && ! "$PUSH_BEFORE" =~ ^0+$ ]] && git cat-file -e "${PUSH_BEFORE}^{commit}" 2>/dev/null; then + before="$PUSH_BEFORE" + else + # new branch, or previous head no longer available (force push) + echo "::notice::Previous commit of the push is not available: comparing to the merge base with '$DEFAULT_BRANCH'" + before=$(git merge-base "origin/$DEFAULT_BRANCH" "$after") + fi + else + echo "::error::Unsupported event '$EVENT_NAME': expected pull_request or push" + exit 1 + fi + echo "before: $before" + echo "after: $after" + echo "before=$before" >> "$GITHUB_OUTPUT" + echo "after=$after" >> "$GITHUB_OUTPUT" + + - name: Extract lock files + id: files + env: + BEFORE: ${{ steps.commits.outputs.before }} + AFTER: ${{ steps.commits.outputs.after }} + run: | + WORK_DIR="$RUNNER_TEMP/pixi-lock-diff" + echo "WORK_DIR=$WORK_DIR" >> "$GITHUB_ENV" + mkdir -p "$WORK_DIR/before" "$WORK_DIR/after" + if ! git show "$AFTER:$LOCKFILE_PATH" > "$WORK_DIR/after/pixi.lock"; then + echo "::error::Lock file '$LOCKFILE_PATH' not found at $AFTER" + exit 1 + fi + if git show "$BEFORE:$LOCKFILE_PATH" > "$WORK_DIR/before/pixi.lock" 2>/dev/null; then + echo "new-lockfile=false" >> "$GITHUB_OUTPUT" + else + echo "::notice::Lock file '$LOCKFILE_PATH' does not exist at $BEFORE: reporting all packages as added" + printf 'version: 6\nenvironments: {}\npackages: []\n' > "$WORK_DIR/before/pixi.lock" + echo "new-lockfile=true" >> "$GITHUB_OUTPUT" + fi + # keep the manifest file name: pixi reads pixi.toml and pyproject.toml differently + manifest="$WORK_DIR/after/$(basename "$MANIFEST_PATH")" + if git show "$AFTER:$MANIFEST_PATH" > "$manifest" 2>/dev/null; then + echo "manifest=$manifest" >> "$GITHUB_OUTPUT" + else + echo "::warning::Manifest '$MANIFEST_PATH' not found at $AFTER: explicit dependencies will not be identified" + rm -f "$manifest" + fi + + - uses: prefix-dev/setup-pixi@5185adfbffb4bd703da3010310260805d89ebb11 # v0.9.6 + with: + pixi-version: ${{ inputs.pixi-version }} + run-install: false + cache: false + + - name: Compute diff + env: + MANIFEST: ${{ steps.files.outputs.manifest }} + ENVIRONMENTS: ${{ inputs.environments }} + run: | + manifest_args=() + if [ -n "$MANIFEST" ]; then + manifest_args=(--manifest-path "$MANIFEST") + fi + pixi exec -s "pixi-diff==$PIXI_DIFF_VERSION" pixi-diff \ + --before "$WORK_DIR/before/pixi.lock" --after "$WORK_DIR/after/pixi.lock" \ + "${manifest_args[@]}" > "$WORK_DIR/diff.json" + + if [ -n "$ENVIRONMENTS" ]; then + echo "Keeping environments: $ENVIRONMENTS" + jq --arg envs "$ENVIRONMENTS" \ + '($envs | split(",") | map(gsub(" "; ""))) as $keep + | .environment |= with_entries(select(.key as $k | $keep | index($k)))' \ + "$WORK_DIR/diff.json" > "$WORK_DIR/diff-filtered.json" + mv "$WORK_DIR/diff-filtered.json" "$WORK_DIR/diff.json" + fi + + - name: Convert diff to Markdown + env: + MERGE_DEPENDENCIES: ${{ inputs.merge-dependencies }} + run: | + pixi exec -s "pixi-diff-to-markdown==$PIXI_DIFF_TO_MARKDOWN_VERSION" pixi-diff-to-markdown \ + --merge-dependencies "$MERGE_DEPENDENCIES" < "$WORK_DIR/diff.json" > "$WORK_DIR/dependencies.md" + + - name: List git dependency changes + shell: python + run: | + """Tabulate the commits of git dependencies: pixi-diff-to-markdown reports them as 'unknown'.""" + from __future__ import annotations + + import json + import os + import re + from pathlib import Path + from urllib.parse import unquote + + work_dir = Path(os.environ["WORK_DIR"]) + diff = json.loads((work_dir / "diff.json").read_text(encoding="utf-8")) + git_url = re.compile(r"^git\+(?P[^?#]+?)(?:\.git)?(?:\?rev=(?P[^#]*))?#(?P[0-9a-f]{7,40})$") + + def parse(package: dict | None) -> dict | None: + if not package: + return None + match = git_url.match(package.get("pypi", "")) + if not match: + return None + return {**match.groupdict(), "version": package.get("version", "")} + + def describe(ref: dict | None) -> str: + if ref is None: + return "" + rev = f"`{unquote(ref['rev'])}` @ " if ref["rev"] else "" + return f"{rev}[`{ref['sha'][:8]}`]({ref['repo']}/commit/{ref['sha']}) ({ref['version']})" + + changes = {} + for platforms in diff.get("environment", {}).values(): + for packages in platforms.values(): + for package in packages: + before, after = parse(package.get("before")), parse(package.get("after")) + if before or after: + changes.setdefault(package["name"], (before, after)) + + rows = [] + for name, (before, after) in sorted(changes.items()): + if before and after and before["repo"] == after["repo"] and "github.com" in after["repo"]: + compare = f"[compare]({after['repo']}/compare/{before['sha']}...{after['sha']})" + else: + compare = "" + rows.append(f"|{name}|{describe(before)}|{describe(after)}|{compare}|") + + markdown = "" + if rows: + markdown = "\n".join( + ["### Git dependencies", "", "|Dependency|Before|After|Changes|", "|-|-|-|-|", *rows, ""] + ) + (work_dir / "git-dependencies.md").write_text(markdown, encoding="utf-8") + print(markdown or "No git dependency changes") + + - name: Assemble report + env: + BEFORE: ${{ steps.commits.outputs.before }} + AFTER: ${{ steps.commits.outputs.after }} + NEW_LOCKFILE: ${{ steps.files.outputs.new-lockfile }} + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + REPO_URL: ${{ github.server_url }}/${{ github.repository }} + ENVIRONMENTS: ${{ inputs.environments }} + run: | + report="$WORK_DIR/report.md" + { + echo "$COMMENT_MARKER" + echo "## 📦 Changes in \`$LOCKFILE_PATH\`" + echo + echo "Comparing [\`${BEFORE:0:8}\`]($REPO_URL/commit/$BEFORE) to [\`${AFTER:0:8}\`]($REPO_URL/commit/$AFTER)${ENVIRONMENTS:+, environments: \`$ENVIRONMENTS\`} ([workflow run]($RUN_URL))." + echo + if [ "$NEW_LOCKFILE" == "true" ]; then + echo "> [!NOTE]" + echo "> \`$LOCKFILE_PATH\` is new: all packages are reported as added." + echo + fi + cat "$WORK_DIR/git-dependencies.md" + echo + if [ "$(tr -d '[:space:]' < "$WORK_DIR/dependencies.md")" == "Nodiff" ]; then + echo "*No package changes.*" + else + # demote the headings so that they nest under the report title + sed -E 's/^# /### /' "$WORK_DIR/dependencies.md" + fi + } > "$report" + cat "$report" >> "$GITHUB_STEP_SUMMARY" + + - name: Comment on pull request + if: > + inputs.comment-on-pr && + github.event_name == 'pull_request' && + github.event.pull_request.head.repo.full_name == github.repository + env: + GH_TOKEN: ${{ github.token }} + PR_NUMBER: ${{ github.event.pull_request.number }} + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + run: | + body="$WORK_DIR/comment.md" + cp "$WORK_DIR/report.md" "$body" + # GitHub rejects comments longer than 65536 characters + if [ "$(wc -m < "$body")" -gt 60000 ]; then + echo "::notice::Report too large for a PR comment: posting the git dependencies only" + { + sed -n '1,/^Comparing /p' "$WORK_DIR/report.md" + echo + cat "$WORK_DIR/git-dependencies.md" + echo + echo "> [!NOTE]" + echo "> The package changes are too large for a comment: see the [job summary]($RUN_URL)." + } > "$body" + fi + + existing=$(gh api --paginate "repos/$GITHUB_REPOSITORY/issues/$PR_NUMBER/comments" \ + --jq ".[] | select(.user.login == \"github-actions[bot]\" and (.body | startswith(\"$COMMENT_MARKER\"))) | \"\(.id) \(.node_id)\"" \ + | head -n 1) + if [ -n "$existing" ]; then + read -r comment_id node_id <<< "$existing" + minimized=$(gh api graphql -f query='query($id: ID!) { node(id: $id) { ... on IssueComment { isMinimized } } }' \ + -f id="$node_id" --jq '.data.node.isMinimized') + if [ "$minimized" == "true" ]; then + # a hidden comment would not be noticed: replace it with a new one + echo "Existing comment $comment_id is hidden: replacing it" + gh api -X DELETE "repos/$GITHUB_REPOSITORY/issues/comments/$comment_id" + else + echo "Updating comment $comment_id" + gh api -X PATCH "repos/$GITHUB_REPOSITORY/issues/comments/$comment_id" -F body=@"$body" > /dev/null + exit 0 + fi + fi + echo "Creating comment" + gh api "repos/$GITHUB_REPOSITORY/issues/$PR_NUMBER/comments" -F body=@"$body" > /dev/null From c502a52ce15c6e6056cd12c2beca0004ff9bd8a2 Mon Sep 17 00:00:00 2001 From: Romain Floreani Date: Wed, 30 Sep 2026 13:59:11 -0400 Subject: [PATCH 2/9] Fix pixi.lock diff reading the previous lock file --- .github/workflows/reusable-pixi-lock_diff.yml | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/.github/workflows/reusable-pixi-lock_diff.yml b/.github/workflows/reusable-pixi-lock_diff.yml index 1297e825..84f55ff9 100644 --- a/.github/workflows/reusable-pixi-lock_diff.yml +++ b/.github/workflows/reusable-pixi-lock_diff.yml @@ -74,13 +74,11 @@ jobs: steps: - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 with: + # full history, with all blobs: the lock files of both commits are read with 'git show'. + # Do not use sparse-checkout: it makes a partial clone, and without persisted credentials + # the blobs of the compared commits cannot be fetched on demand. fetch-depth: 0 persist-credentials: false - # the files are read from git history: no need for a full working tree (nor LFS files) - sparse-checkout: | - ${{ inputs.lockfile-path }} - ${{ inputs.manifest-path }} - sparse-checkout-cone-mode: false - name: Select commits to compare id: commits @@ -127,7 +125,11 @@ jobs: echo "::error::Lock file '$LOCKFILE_PATH' not found at $AFTER" exit 1 fi - if git show "$BEFORE:$LOCKFILE_PATH" > "$WORK_DIR/before/pixi.lock" 2>/dev/null; then + if [ -n "$(git ls-tree "$BEFORE" -- "$LOCKFILE_PATH")" ]; then + if ! git show "$BEFORE:$LOCKFILE_PATH" > "$WORK_DIR/before/pixi.lock"; then + echo "::error::Could not read lock file '$LOCKFILE_PATH' at $BEFORE" + exit 1 + fi echo "new-lockfile=false" >> "$GITHUB_OUTPUT" else echo "::notice::Lock file '$LOCKFILE_PATH' does not exist at $BEFORE: reporting all packages as added" From e8ed0fee2df159b6fe552901f59f60ecbb05b581 Mon Sep 17 00:00:00 2001 From: Romain Floreani Date: Wed, 30 Sep 2026 14:18:11 -0400 Subject: [PATCH 3/9] Updating the workflow again --- .github/workflows/reusable-pixi-lock_diff.yml | 44 +++++++------------ 1 file changed, 16 insertions(+), 28 deletions(-) diff --git a/.github/workflows/reusable-pixi-lock_diff.yml b/.github/workflows/reusable-pixi-lock_diff.yml index 84f55ff9..b9bad5ee 100644 --- a/.github/workflows/reusable-pixi-lock_diff.yml +++ b/.github/workflows/reusable-pixi-lock_diff.yml @@ -1,6 +1,4 @@ -# Report the changes made to a pixi.lock file in a readable Markdown format. -# The report is written to the job summary, and posted as a PR comment on pull requests -# (a single comment, updated on each run). +# Report pixi.lock changes as Markdown in the job summary and in a PR comment (updated on each run). name: Pixi lock diff @@ -8,44 +6,37 @@ on: workflow_call: inputs: lockfile-path: - description: Path of the pixi lock file, relative to the repository root + description: Lock file path required: false type: string default: pixi.lock manifest-path: - description: > - Path of the pixi manifest, relative to the repository root. Used to tell explicit - dependencies (listed in the manifest) from implicit ones. + description: Manifest path, to identify explicit dependencies required: false type: string default: pixi.toml merge-dependencies: - description: > - How pixi-diff-to-markdown merges the changes of all environments and platforms: - 'no' (one table per environment and platform), 'yes' (a single table), - or 'split-explicit' (one table for explicit dependencies, one for implicit ones). + description: pixi-diff-to-markdown table merging ('no', 'yes' or 'split-explicit') required: false type: string default: split-explicit environments: - description: > - Comma-separated list of pixi environments to report on (e.g. 'default,test-engine'). - Reports on all environments when empty. + description: Comma-separated environments to report on (all if empty) required: false type: string default: '' comment-on-pr: - description: Post the report as a comment on the pull request + description: Post the report as a PR comment required: false type: boolean default: true pixi-version: - description: Version of Pixi to use + description: Pixi version required: false type: string - default: v0.67.2 # default to last version before lock file version v7 + default: v0.67.2 # last version before lock file v7 timeout-minutes: - description: Timeout in minutes for the job + description: Job timeout in minutes required: false type: number default: 10 @@ -74,9 +65,7 @@ jobs: steps: - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 with: - # full history, with all blobs: the lock files of both commits are read with 'git show'. - # Do not use sparse-checkout: it makes a partial clone, and without persisted credentials - # the blobs of the compared commits cannot be fetched on demand. + # no sparse-checkout: its partial clone cannot fetch old lock files without credentials fetch-depth: 0 persist-credentials: false @@ -90,8 +79,7 @@ jobs: DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} run: | if [[ "$EVENT_NAME" == "pull_request" || "$EVENT_NAME" == "pull_request_target" ]]; then - # compare to the merge base, so that changes made on the base branch since the PR - # was branched off are not reported as changes of the PR + # merge base: ignore base branch changes made after the PR branched off after="$PR_HEAD_SHA" before=$(git merge-base "origin/$BASE_REF" "$after") elif [[ "$EVENT_NAME" == "push" ]]; then @@ -136,7 +124,7 @@ jobs: printf 'version: 6\nenvironments: {}\npackages: []\n' > "$WORK_DIR/before/pixi.lock" echo "new-lockfile=true" >> "$GITHUB_OUTPUT" fi - # keep the manifest file name: pixi reads pixi.toml and pyproject.toml differently + # keep the file name: pixi.toml and pyproject.toml are parsed differently manifest="$WORK_DIR/after/$(basename "$MANIFEST_PATH")" if git show "$AFTER:$MANIFEST_PATH" > "$manifest" 2>/dev/null; then echo "manifest=$manifest" >> "$GITHUB_OUTPUT" @@ -183,7 +171,7 @@ jobs: - name: List git dependency changes shell: python run: | - """Tabulate the commits of git dependencies: pixi-diff-to-markdown reports them as 'unknown'.""" + """Git dependency commits (pixi-diff-to-markdown shows them as 'unknown').""" from __future__ import annotations import json @@ -260,7 +248,7 @@ jobs: if [ "$(tr -d '[:space:]' < "$WORK_DIR/dependencies.md")" == "Nodiff" ]; then echo "*No package changes.*" else - # demote the headings so that they nest under the report title + # nest headings under the report title sed -E 's/^# /### /' "$WORK_DIR/dependencies.md" fi } > "$report" @@ -278,7 +266,7 @@ jobs: run: | body="$WORK_DIR/comment.md" cp "$WORK_DIR/report.md" "$body" - # GitHub rejects comments longer than 65536 characters + # GitHub comment limit: 65536 characters if [ "$(wc -m < "$body")" -gt 60000 ]; then echo "::notice::Report too large for a PR comment: posting the git dependencies only" { @@ -299,7 +287,7 @@ jobs: minimized=$(gh api graphql -f query='query($id: ID!) { node(id: $id) { ... on IssueComment { isMinimized } } }' \ -f id="$node_id" --jq '.data.node.isMinimized') if [ "$minimized" == "true" ]; then - # a hidden comment would not be noticed: replace it with a new one + # replace a hidden comment so the new report is visible echo "Existing comment $comment_id is hidden: replacing it" gh api -X DELETE "repos/$GITHUB_REPOSITORY/issues/comments/$comment_id" else From 011fac89f5ef14c8680c18846862f28862b2a8a7 Mon Sep 17 00:00:00 2001 From: Romain Floreani Date: Wed, 30 Sep 2026 14:30:36 -0400 Subject: [PATCH 4/9] Fixes based on copilot comments --- .github/workflows/reusable-pixi-lock_diff.yml | 30 ++++++++++++------- 1 file changed, 20 insertions(+), 10 deletions(-) diff --git a/.github/workflows/reusable-pixi-lock_diff.yml b/.github/workflows/reusable-pixi-lock_diff.yml index b9bad5ee..e5d82f97 100644 --- a/.github/workflows/reusable-pixi-lock_diff.yml +++ b/.github/workflows/reusable-pixi-lock_diff.yml @@ -53,6 +53,10 @@ jobs: name: pixi.lock diff runs-on: ubuntu-latest timeout-minutes: ${{ inputs.timeout-minutes }} + # one run at a time per PR (or branch): avoids duplicate or outdated comments + concurrency: + group: pixi-lock-diff-${{ github.event.pull_request.number || github.ref }}-${{ inputs.lockfile-path }} + cancel-in-progress: true permissions: contents: read pull-requests: write @@ -78,7 +82,7 @@ jobs: PUSH_BEFORE: ${{ github.event.before }} DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} run: | - if [[ "$EVENT_NAME" == "pull_request" || "$EVENT_NAME" == "pull_request_target" ]]; then + if [[ "$EVENT_NAME" == "pull_request" ]]; then # merge base: ignore base branch changes made after the PR branched off after="$PR_HEAD_SHA" before=$(git merge-base "origin/$BASE_REF" "$after") @@ -178,24 +182,28 @@ jobs: import os import re from pathlib import Path - from urllib.parse import unquote + from urllib.parse import parse_qs, urlsplit work_dir = Path(os.environ["WORK_DIR"]) diff = json.loads((work_dir / "diff.json").read_text(encoding="utf-8")) - git_url = re.compile(r"^git\+(?P[^?#]+?)(?:\.git)?(?:\?rev=(?P[^#]*))?#(?P[0-9a-f]{7,40})$") def parse(package: dict | None) -> dict | None: - if not package: + """Parse 'git+[?rev=|branch=|tag=][&subdirectory=...]#'.""" + url = (package or {}).get("pypi", "") + if not url.startswith("git+"): return None - match = git_url.match(package.get("pypi", "")) - if not match: + parts = urlsplit(url.removeprefix("git+")) + if not re.fullmatch(r"[0-9a-f]{7,40}", parts.fragment): return None - return {**match.groupdict(), "version": package.get("version", "")} + query = parse_qs(parts.query) + rev = next((query[key][0] for key in ("rev", "branch", "tag") if key in query), "") + repo = f"{parts.scheme}://{parts.netloc}{parts.path}".removesuffix(".git") + return {"repo": repo, "rev": rev, "sha": parts.fragment, "version": package.get("version", "")} def describe(ref: dict | None) -> str: if ref is None: return "" - rev = f"`{unquote(ref['rev'])}` @ " if ref["rev"] else "" + rev = f"`{ref['rev']}` @ " if ref["rev"] else "" return f"{rev}[`{ref['sha'][:8]}`]({ref['repo']}/commit/{ref['sha']}) ({ref['version']})" changes = {} @@ -204,10 +212,12 @@ jobs: for package in packages: before, after = parse(package.get("before")), parse(package.get("after")) if before or after: - changes.setdefault(package["name"], (before, after)) + # one row per distinct change: environments may resolve different commits + key = (package["name"], (before or {}).get("sha", ""), (after or {}).get("sha", "")) + changes.setdefault(key, (before, after)) rows = [] - for name, (before, after) in sorted(changes.items()): + for (name, _, _), (before, after) in sorted(changes.items()): if before and after and before["repo"] == after["repo"] and "github.com" in after["repo"]: compare = f"[compare]({after['repo']}/compare/{before['sha']}...{after['sha']})" else: From 62a43d4151bba2f4dc8e7cd7cecfa50cc06aefce Mon Sep 17 00:00:00 2001 From: Romain Floreani Date: Mon, 5 Oct 2026 09:09:15 -0400 Subject: [PATCH 5/9] Post a short PR comment linking to the job summary instead of the full report --- .github/workflows/reusable-pixi-lock_diff.yml | 40 ++++++++++++------- 1 file changed, 26 insertions(+), 14 deletions(-) diff --git a/.github/workflows/reusable-pixi-lock_diff.yml b/.github/workflows/reusable-pixi-lock_diff.yml index e5d82f97..975b0ff9 100644 --- a/.github/workflows/reusable-pixi-lock_diff.yml +++ b/.github/workflows/reusable-pixi-lock_diff.yml @@ -1,4 +1,4 @@ -# Report pixi.lock changes as Markdown in the job summary and in a PR comment (updated on each run). +# Report pixi.lock changes as Markdown in the job summary, with a short PR comment (updated on each run) linking to it. name: Pixi lock diff @@ -273,21 +273,33 @@ jobs: GH_TOKEN: ${{ github.token }} PR_NUMBER: ${{ github.event.pull_request.number }} RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + JOB_ID: ${{ job.check_run_id }} run: | + # short comment linking to the job summary: the full tables are not duplicated in the conversation + summary_url="$RUN_URL${JOB_ID:+#summary-$JOB_ID}" + # one status per package name, across environments and platforms + stats=$(jq -r ' + [.environment[][][] + | {name, explicit, status: (if .before == null then "added" elif .after == null then "removed" else "updated" end)}] + | group_by(.name) + | map({name: .[0].name, explicit: any(.[]; .explicit), + status: (if all(.[]; .status == "added") then "added" + elif all(.[]; .status == "removed") then "removed" else "updated" end)}) + | if length == 0 then "*No package changes.*" else + "**\(length)** packages changed: \(map(select(.status == "added")) | length) added, \(map(select(.status == "removed")) | length) removed, \(map(select(.status == "updated")) | length) updated." + + (map(select(.explicit) | "`\(.name)`") | if length > 0 then " Explicit: \(join(", "))." else "" end) + end' "$WORK_DIR/diff.json") + body="$WORK_DIR/comment.md" - cp "$WORK_DIR/report.md" "$body" - # GitHub comment limit: 65536 characters - if [ "$(wc -m < "$body")" -gt 60000 ]; then - echo "::notice::Report too large for a PR comment: posting the git dependencies only" - { - sed -n '1,/^Comparing /p' "$WORK_DIR/report.md" - echo - cat "$WORK_DIR/git-dependencies.md" - echo - echo "> [!NOTE]" - echo "> The package changes are too large for a comment: see the [job summary]($RUN_URL)." - } > "$body" - fi + { + sed -n '1,/^Comparing /p' "$WORK_DIR/report.md" + echo + echo "$stats" + echo + cat "$WORK_DIR/git-dependencies.md" + echo + echo "📋 **[Full report in the job summary]($summary_url)**" + } > "$body" existing=$(gh api --paginate "repos/$GITHUB_REPOSITORY/issues/$PR_NUMBER/comments" \ --jq ".[] | select(.user.login == \"github-actions[bot]\" and (.body | startswith(\"$COMMENT_MARKER\"))) | \"\(.id) \(.node_id)\"" \ From 4d4baaaa2542c010680732d2076a9a072de4602b Mon Sep 17 00:00:00 2001 From: Romain Floreani Date: Mon, 5 Oct 2026 09:24:50 -0400 Subject: [PATCH 6/9] Add change counts and explicit dependency versions to the PR comment, with a size safety net - Count changed packages once each: upgraded (major), downgraded, rebuilt (same version), added, removed - Show the explicit dependency version changes in the comment (collapsed above 15 rows) - Remove details step by step when the comment exceeds 60000 characters --- .github/workflows/reusable-pixi-lock_diff.yml | 176 +++++++++++++++--- 1 file changed, 146 insertions(+), 30 deletions(-) diff --git a/.github/workflows/reusable-pixi-lock_diff.yml b/.github/workflows/reusable-pixi-lock_diff.yml index 975b0ff9..2dbd61fa 100644 --- a/.github/workflows/reusable-pixi-lock_diff.yml +++ b/.github/workflows/reusable-pixi-lock_diff.yml @@ -172,10 +172,10 @@ jobs: pixi exec -s "pixi-diff-to-markdown==$PIXI_DIFF_TO_MARKDOWN_VERSION" pixi-diff-to-markdown \ --merge-dependencies "$MERGE_DEPENDENCIES" < "$WORK_DIR/diff.json" > "$WORK_DIR/dependencies.md" - - name: List git dependency changes + - name: Summarize changes shell: python run: | - """Git dependency commits (pixi-diff-to-markdown shows them as 'unknown').""" + """Change counts, explicit dependency changes and git dependency commits (pixi-diff-to-markdown shows them as 'unknown').""" from __future__ import annotations import json @@ -232,6 +232,79 @@ jobs: (work_dir / "git-dependencies.md").write_text(markdown, encoding="utf-8") print(markdown or "No git dependency changes") + def version_build(package: dict) -> tuple[str, str]: + """Conda: from the file name '--.conda'. PyPI: version only.""" + if "conda" in package: + filename = package["conda"].rsplit("/", 1)[-1].removesuffix(".conda").removesuffix(".tar.bz2") + parts = filename.rsplit("-", 2) + if len(parts) == 3: + return parts[1], parts[2] + return package.get("version", ""), "" + + def release(version: str) -> tuple[int, ...] | None: + """Leading numeric part, without epoch: '1!2.10.0rc1' -> (2, 10, 0).""" + match = re.match(r"\d+(?:\.\d+)*", version.split("!")[-1]) + return tuple(int(part) for part in match.group().split(".")) if match else None + + def classify(before: dict | None, after: dict | None) -> str: + if before is None: + return "added" + if after is None: + return "removed" + before_version, after_version = version_build(before)[0], version_build(after)[0] + if before_version == after_version: + return "rebuilt" + old, new = release(before_version), release(after_version) + if old is None or new is None: + return "updated" + size = max(len(old), len(new)) + old, new = old + (0,) * (size - len(old)), new + (0,) * (size - len(new)) + if new == old: + return "updated" # pre-release, post-release or local version change + if new < old: + return "downgraded" + return "major" if new[0] != old[0] else "upgraded" + + # one status per package name, the most significant across environments and platforms + priority = ["downgraded", "major", "upgraded", "updated", "rebuilt", "added", "removed"] + status, explicit = {}, set() + explicit_diff = {key: value for key, value in diff.items() if key != "environment"} + explicit_diff["environment"] = {} + for environment, platforms in diff.get("environment", {}).items(): + for platform, packages in platforms.items(): + for package in packages: + name, change = package["name"], classify(package.get("before"), package.get("after")) + if name not in status or priority.index(change) < priority.index(status[name]): + status[name] = change + if package.get("explicit"): + explicit.add(name) + # version changes only: git dependencies have their own table + is_git = parse(package.get("before")) or parse(package.get("after")) + if change != "rebuilt" and not is_git: + explicit_diff["environment"].setdefault(environment, {}).setdefault(platform, []).append(package) + + counts = {change: sum(value == change for value in status.values()) for change in priority} + if status: + labels = [ + ("upgraded", counts["upgraded"] + counts["major"], f" ({counts['major']} major)" if counts["major"] else ""), + ("⚠️ downgraded", counts["downgraded"], ""), + ("other version changes", counts["updated"], ""), + ("rebuilt (same version)", counts["rebuilt"], ""), + ("added", counts["added"], ""), + ("removed", counts["removed"], ""), + ] + stats = f"**{len(status)}** packages changed: " + ", ".join( + f"{count} {label}{detail}" for label, count, detail in labels if count + ) + "." + else: + stats = "*No package changes.*" + (work_dir / "stats.md").write_text(stats + "\n", encoding="utf-8") + print(stats) + + names = ", ".join(f"`{name}`" for name in sorted(explicit)) + (work_dir / "explicit-names.md").write_text(f"Explicit dependencies: {names}.\n\n" if names else "", encoding="utf-8") + (work_dir / "explicit-diff.json").write_text(json.dumps(explicit_diff), encoding="utf-8") + - name: Assemble report env: BEFORE: ${{ steps.commits.outputs.before }} @@ -253,54 +326,97 @@ jobs: echo "> \`$LOCKFILE_PATH\` is new: all packages are reported as added." echo fi + cat "$WORK_DIR/stats.md" + echo cat "$WORK_DIR/git-dependencies.md" echo - if [ "$(tr -d '[:space:]' < "$WORK_DIR/dependencies.md")" == "Nodiff" ]; then - echo "*No package changes.*" - else + if [ "$(tr -d '[:space:]' < "$WORK_DIR/dependencies.md")" != "Nodiff" ]; then # nest headings under the report title sed -E 's/^# /### /' "$WORK_DIR/dependencies.md" fi } > "$report" cat "$report" >> "$GITHUB_STEP_SUMMARY" - - name: Comment on pull request + - name: Assemble PR comment + id: comment if: > inputs.comment-on-pr && github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository env: - GH_TOKEN: ${{ github.token }} - PR_NUMBER: ${{ github.event.pull_request.number }} RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} JOB_ID: ${{ job.check_run_id }} + # GitHub comment limit: 65536 characters + COMMENT_MAX_CHARS: 60000 run: | - # short comment linking to the job summary: the full tables are not duplicated in the conversation + # short comment linking to the job summary: the implicit dependencies are not duplicated in the conversation summary_url="$RUN_URL${JOB_ID:+#summary-$JOB_ID}" - # one status per package name, across environments and platforms - stats=$(jq -r ' - [.environment[][][] - | {name, explicit, status: (if .before == null then "added" elif .after == null then "removed" else "updated" end)}] - | group_by(.name) - | map({name: .[0].name, explicit: any(.[]; .explicit), - status: (if all(.[]; .status == "added") then "added" - elif all(.[]; .status == "removed") then "removed" else "updated" end)}) - | if length == 0 then "*No package changes.*" else - "**\(length)** packages changed: \(map(select(.status == "added")) | length) added, \(map(select(.status == "removed")) | length) removed, \(map(select(.status == "updated")) | length) updated." - + (map(select(.explicit) | "`\(.name)`") | if length > 0 then " Explicit: \(join(", "))." else "" end) - end' "$WORK_DIR/diff.json") + explicit="$WORK_DIR/explicit.md" + pixi exec -s "pixi-diff-to-markdown==$PIXI_DIFF_TO_MARKDOWN_VERSION" pixi-diff-to-markdown \ + --merge-dependencies yes --hide-tables no < "$WORK_DIR/explicit-diff.json" > "$explicit.tmp" + if [ "$(tr -d '[:space:]' < "$explicit.tmp")" == "Nodiff" ]; then + : > "$explicit" + else + rows=$(grep -c '^|\[\?\*\*' "$explicit.tmp" || true) + { + # collapse long tables + if [ "$rows" -le 15 ]; then + echo "### Explicit dependencies" + echo + else + echo "
" + echo "Explicit dependencies ($rows changes)" + echo + fi + # all rows are explicit: drop the bold footnote, and the downgrade footnote when unused + unused_footnotes=(-e '/^\[^2\]:/d') + if grep -q '^|.*\[^2\]' "$explicit.tmp"; then + unused_footnotes=() + fi + sed -e 's/^|Dependency\[^1\]|/|Dependency|/' -e '/^\[^1\]:/d' "${unused_footnotes[@]}" "$explicit.tmp" + if [ "$rows" -gt 15 ]; then + echo "
" + echo + fi + } > "$explicit" + fi + + # drop details until the comment fits body="$WORK_DIR/comment.md" - { - sed -n '1,/^Comparing /p' "$WORK_DIR/report.md" - echo - echo "$stats" - echo - cat "$WORK_DIR/git-dependencies.md" - echo - echo "📋 **[Full report in the job summary]($summary_url)**" - } > "$body" + for level in full names minimal; do + { + sed -n '1,/^Comparing /p' "$WORK_DIR/report.md" + echo + cat "$WORK_DIR/stats.md" + echo + case "$level" in + full) cat "$explicit" "$WORK_DIR/git-dependencies.md" ;; + names) cat "$WORK_DIR/explicit-names.md" "$WORK_DIR/git-dependencies.md" ;; + esac + echo + if [ "$level" != "full" ]; then + echo "> [!NOTE]" + echo "> Some details are too large for a comment: see the full report." + echo + fi + echo "📋 **[Full report in the job summary]($summary_url)**" + } > "$body" + size=$(wc -m < "$body") + echo "Comment with '$level' details: $size characters" + if [ "$size" -le "$COMMENT_MAX_CHARS" ]; then + break + fi + echo "::notice::PR comment too large with '$level' details ($size characters): removing details" + done + - name: Comment on pull request + if: steps.comment.outcome == 'success' + env: + GH_TOKEN: ${{ github.token }} + PR_NUMBER: ${{ github.event.pull_request.number }} + run: | + body="$WORK_DIR/comment.md" existing=$(gh api --paginate "repos/$GITHUB_REPOSITORY/issues/$PR_NUMBER/comments" \ --jq ".[] | select(.user.login == \"github-actions[bot]\" and (.body | startswith(\"$COMMENT_MARKER\"))) | \"\(.id) \(.node_id)\"" \ | head -n 1) From 5911b18944ea254d3eda14a555aa6aac6b09a4b3 Mon Sep 17 00:00:00 2001 From: Romain Floreani Date: Mon, 5 Oct 2026 09:39:39 -0400 Subject: [PATCH 7/9] New updated version of the workflow --- .github/workflows/reusable-pixi-lock_diff.yml | 428 +++++------------- 1 file changed, 122 insertions(+), 306 deletions(-) diff --git a/.github/workflows/reusable-pixi-lock_diff.yml b/.github/workflows/reusable-pixi-lock_diff.yml index 2dbd61fa..579a83a7 100644 --- a/.github/workflows/reusable-pixi-lock_diff.yml +++ b/.github/workflows/reusable-pixi-lock_diff.yml @@ -1,4 +1,4 @@ -# Report pixi.lock changes as Markdown in the job summary, with a short PR comment (updated on each run) linking to it. +# Report pull request changes to pixi.lock in the job summary, with a short PR comment (updated on each run) linking to it. name: Pixi lock diff @@ -15,16 +15,6 @@ on: required: false type: string default: pixi.toml - merge-dependencies: - description: pixi-diff-to-markdown table merging ('no', 'yes' or 'split-explicit') - required: false - type: string - default: split-explicit - environments: - description: Comma-separated environments to report on (all if empty) - required: false - type: string - default: '' comment-on-pr: description: Post the report as a PR comment required: false @@ -51,11 +41,12 @@ env: jobs: pixi-lock-diff: name: pixi.lock diff + if: github.event_name == 'pull_request' runs-on: ubuntu-latest timeout-minutes: ${{ inputs.timeout-minutes }} - # one run at a time per PR (or branch): avoids duplicate or outdated comments + # one run at a time per PR: avoids duplicate or outdated comments concurrency: - group: pixi-lock-diff-${{ github.event.pull_request.number || github.ref }}-${{ inputs.lockfile-path }} + group: pixi-lock-diff-${{ github.event.pull_request.number }}-${{ inputs.lockfile-path }} cancel-in-progress: true permissions: contents: read @@ -69,72 +60,22 @@ jobs: steps: - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 with: - # no sparse-checkout: its partial clone cannot fetch old lock files without credentials + # PR head rather than the merge commit, and the full history to find the merge base + ref: ${{ github.event.pull_request.head.sha }} fetch-depth: 0 persist-credentials: false - - name: Select commits to compare - id: commits + - name: Get base lock file + id: base env: - EVENT_NAME: ${{ github.event_name }} BASE_REF: ${{ github.base_ref }} - PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }} - PUSH_BEFORE: ${{ github.event.before }} - DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} run: | - if [[ "$EVENT_NAME" == "pull_request" ]]; then - # merge base: ignore base branch changes made after the PR branched off - after="$PR_HEAD_SHA" - before=$(git merge-base "origin/$BASE_REF" "$after") - elif [[ "$EVENT_NAME" == "push" ]]; then - after="$GITHUB_SHA" - if [[ -n "$PUSH_BEFORE" && ! "$PUSH_BEFORE" =~ ^0+$ ]] && git cat-file -e "${PUSH_BEFORE}^{commit}" 2>/dev/null; then - before="$PUSH_BEFORE" - else - # new branch, or previous head no longer available (force push) - echo "::notice::Previous commit of the push is not available: comparing to the merge base with '$DEFAULT_BRANCH'" - before=$(git merge-base "origin/$DEFAULT_BRANCH" "$after") - fi - else - echo "::error::Unsupported event '$EVENT_NAME': expected pull_request or push" - exit 1 - fi - echo "before: $before" - echo "after: $after" - echo "before=$before" >> "$GITHUB_OUTPUT" - echo "after=$after" >> "$GITHUB_OUTPUT" - - - name: Extract lock files - id: files - env: - BEFORE: ${{ steps.commits.outputs.before }} - AFTER: ${{ steps.commits.outputs.after }} - run: | - WORK_DIR="$RUNNER_TEMP/pixi-lock-diff" - echo "WORK_DIR=$WORK_DIR" >> "$GITHUB_ENV" - mkdir -p "$WORK_DIR/before" "$WORK_DIR/after" - if ! git show "$AFTER:$LOCKFILE_PATH" > "$WORK_DIR/after/pixi.lock"; then - echo "::error::Lock file '$LOCKFILE_PATH' not found at $AFTER" - exit 1 - fi - if [ -n "$(git ls-tree "$BEFORE" -- "$LOCKFILE_PATH")" ]; then - if ! git show "$BEFORE:$LOCKFILE_PATH" > "$WORK_DIR/before/pixi.lock"; then - echo "::error::Could not read lock file '$LOCKFILE_PATH' at $BEFORE" - exit 1 - fi - echo "new-lockfile=false" >> "$GITHUB_OUTPUT" - else - echo "::notice::Lock file '$LOCKFILE_PATH' does not exist at $BEFORE: reporting all packages as added" - printf 'version: 6\nenvironments: {}\npackages: []\n' > "$WORK_DIR/before/pixi.lock" - echo "new-lockfile=true" >> "$GITHUB_OUTPUT" - fi - # keep the file name: pixi.toml and pyproject.toml are parsed differently - manifest="$WORK_DIR/after/$(basename "$MANIFEST_PATH")" - if git show "$AFTER:$MANIFEST_PATH" > "$manifest" 2>/dev/null; then - echo "manifest=$manifest" >> "$GITHUB_OUTPUT" - else - echo "::warning::Manifest '$MANIFEST_PATH' not found at $AFTER: explicit dependencies will not be identified" - rm -f "$manifest" + # merge base: ignore base branch changes made after the PR branched off + base=$(git merge-base "origin/$BASE_REF" HEAD) + echo "sha=$base" >> "$GITHUB_OUTPUT" + if ! git show "$base:$LOCKFILE_PATH" > "$RUNNER_TEMP/base.lock" 2> /dev/null; then + echo "::notice::'$LOCKFILE_PATH' does not exist at $base: reporting all packages as added" + printf 'version: 6\nenvironments: {}\npackages: []\n' > "$RUNNER_TEMP/base.lock" fi - uses: prefix-dev/setup-pixi@5185adfbffb4bd703da3010310260805d89ebb11 # v0.9.6 @@ -144,38 +85,29 @@ jobs: cache: false - name: Compute diff - env: - MANIFEST: ${{ steps.files.outputs.manifest }} - ENVIRONMENTS: ${{ inputs.environments }} run: | manifest_args=() - if [ -n "$MANIFEST" ]; then - manifest_args=(--manifest-path "$MANIFEST") + if [ -f "$MANIFEST_PATH" ]; then + manifest_args=(--manifest-path "$MANIFEST_PATH") + else + echo "::warning::Manifest '$MANIFEST_PATH' not found: explicit dependencies will not be identified" fi pixi exec -s "pixi-diff==$PIXI_DIFF_VERSION" pixi-diff \ - --before "$WORK_DIR/before/pixi.lock" --after "$WORK_DIR/after/pixi.lock" \ - "${manifest_args[@]}" > "$WORK_DIR/diff.json" - - if [ -n "$ENVIRONMENTS" ]; then - echo "Keeping environments: $ENVIRONMENTS" - jq --arg envs "$ENVIRONMENTS" \ - '($envs | split(",") | map(gsub(" "; ""))) as $keep - | .environment |= with_entries(select(.key as $k | $keep | index($k)))' \ - "$WORK_DIR/diff.json" > "$WORK_DIR/diff-filtered.json" - mv "$WORK_DIR/diff-filtered.json" "$WORK_DIR/diff.json" - fi - - - name: Convert diff to Markdown - env: - MERGE_DEPENDENCIES: ${{ inputs.merge-dependencies }} - run: | + --before "$RUNNER_TEMP/base.lock" --after "$LOCKFILE_PATH" "${manifest_args[@]}" > "$RUNNER_TEMP/diff.json" pixi exec -s "pixi-diff-to-markdown==$PIXI_DIFF_TO_MARKDOWN_VERSION" pixi-diff-to-markdown \ - --merge-dependencies "$MERGE_DEPENDENCIES" < "$WORK_DIR/diff.json" > "$WORK_DIR/dependencies.md" + --merge-dependencies yes < "$RUNNER_TEMP/diff.json" > "$RUNNER_TEMP/dependencies.md" - - name: Summarize changes + - name: Build report shell: python + env: + BASE_SHA: ${{ steps.base.outputs.sha }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + REPO_URL: ${{ github.server_url }}/${{ github.repository }} + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + JOB_ID: ${{ job.check_run_id }} run: | - """Change counts, explicit dependency changes and git dependency commits (pixi-diff-to-markdown shows them as 'unknown').""" + """Job summary and PR comment: change counts and explicit dependency changes (with git commits, which + pixi-diff-to-markdown shows as 'unknown'), then all changes in the summary only.""" from __future__ import annotations import json @@ -184,239 +116,123 @@ jobs: from pathlib import Path from urllib.parse import parse_qs, urlsplit - work_dir = Path(os.environ["WORK_DIR"]) - diff = json.loads((work_dir / "diff.json").read_text(encoding="utf-8")) + COMMENT_MAX_CHARS = 60000 # GitHub limit: 65536 + PRIORITY = ["downgraded", "major", "minor", "patch", "updated", "rebuilt", "added", "removed"] + LABELS = {"downgraded": "⚠️ Downgrade", "major": "Major upgrade", "minor": "Minor upgrade", "patch": "Patch upgrade", + "updated": "Update", "rebuilt": "New commit", "added": "Added", "removed": "Removed"} - def parse(package: dict | None) -> dict | None: - """Parse 'git+[?rev=|branch=|tag=][&subdirectory=...]#'.""" - url = (package or {}).get("pypi", "") - if not url.startswith("git+"): - return None + env = os.environ + temp = Path(env["RUNNER_TEMP"]) + diff = json.loads((temp / "diff.json").read_text(encoding="utf-8")) + + def git_ref(package: dict) -> tuple[str, str, str] | None: + """Repo, ref and commit of 'git+[?rev=|branch=|tag=][&subdirectory=...]#'.""" + url = package.get("pypi", "") parts = urlsplit(url.removeprefix("git+")) - if not re.fullmatch(r"[0-9a-f]{7,40}", parts.fragment): + if not url.startswith("git+") or not re.fullmatch(r"[0-9a-f]{7,40}", parts.fragment): return None query = parse_qs(parts.query) rev = next((query[key][0] for key in ("rev", "branch", "tag") if key in query), "") - repo = f"{parts.scheme}://{parts.netloc}{parts.path}".removesuffix(".git") - return {"repo": repo, "rev": rev, "sha": parts.fragment, "version": package.get("version", "")} - - def describe(ref: dict | None) -> str: - if ref is None: - return "" - rev = f"`{ref['rev']}` @ " if ref["rev"] else "" - return f"{rev}[`{ref['sha'][:8]}`]({ref['repo']}/commit/{ref['sha']}) ({ref['version']})" - - changes = {} - for platforms in diff.get("environment", {}).values(): - for packages in platforms.values(): - for package in packages: - before, after = parse(package.get("before")), parse(package.get("after")) - if before or after: - # one row per distinct change: environments may resolve different commits - key = (package["name"], (before or {}).get("sha", ""), (after or {}).get("sha", "")) - changes.setdefault(key, (before, after)) + return f"{parts.scheme}://{parts.netloc}{parts.path}".removesuffix(".git"), rev, parts.fragment - rows = [] - for (name, _, _), (before, after) in sorted(changes.items()): - if before and after and before["repo"] == after["repo"] and "github.com" in after["repo"]: - compare = f"[compare]({after['repo']}/compare/{before['sha']}...{after['sha']})" - else: - compare = "" - rows.append(f"|{name}|{describe(before)}|{describe(after)}|{compare}|") - - markdown = "" - if rows: - markdown = "\n".join( - ["### Git dependencies", "", "|Dependency|Before|After|Changes|", "|-|-|-|-|", *rows, ""] - ) - (work_dir / "git-dependencies.md").write_text(markdown, encoding="utf-8") - print(markdown or "No git dependency changes") - - def version_build(package: dict) -> tuple[str, str]: - """Conda: from the file name '--.conda'. PyPI: version only.""" + def version(package: dict) -> str: + """Conda: from the file name '--.conda'. PyPI: version field.""" if "conda" in package: - filename = package["conda"].rsplit("/", 1)[-1].removesuffix(".conda").removesuffix(".tar.bz2") - parts = filename.rsplit("-", 2) + parts = package["conda"].rsplit("/", 1)[-1].removesuffix(".conda").removesuffix(".tar.bz2").rsplit("-", 2) if len(parts) == 3: - return parts[1], parts[2] - return package.get("version", ""), "" + return parts[1] + return package.get("version", "") - def release(version: str) -> tuple[int, ...] | None: - """Leading numeric part, without epoch: '1!2.10.0rc1' -> (2, 10, 0).""" - match = re.match(r"\d+(?:\.\d+)*", version.split("!")[-1]) - return tuple(int(part) for part in match.group().split(".")) if match else None + def release(text: str) -> tuple[int, ...] | None: + """Numeric release without epoch, padded for comparison: '1!2.10rc1' -> (2, 10, 0, ...).""" + match = re.match(r"\d+(?:\.\d+)*", text.split("!")[-1]) + return tuple(map(int, match.group().split("."))) + (0,) * 8 if match else None def classify(before: dict | None, after: dict | None) -> str: - if before is None: - return "added" - if after is None: - return "removed" - before_version, after_version = version_build(before)[0], version_build(after)[0] - if before_version == after_version: + if before is None or after is None: + return "added" if before is None else "removed" + if version(before) == version(after): return "rebuilt" - old, new = release(before_version), release(after_version) - if old is None or new is None: - return "updated" - size = max(len(old), len(new)) - old, new = old + (0,) * (size - len(old)), new + (0,) * (size - len(new)) - if new == old: - return "updated" # pre-release, post-release or local version change + old, new = release(version(before)), release(version(after)) + if old is None or new is None or old == new: + return "updated" # e.g. pre-release change if new < old: return "downgraded" - return "major" if new[0] != old[0] else "upgraded" + return "major" if new[0] != old[0] else "minor" if new[1] != old[1] else "patch" - # one status per package name, the most significant across environments and platforms - priority = ["downgraded", "major", "upgraded", "updated", "rebuilt", "added", "removed"] - status, explicit = {}, set() - explicit_diff = {key: value for key, value in diff.items() if key != "environment"} - explicit_diff["environment"] = {} - for environment, platforms in diff.get("environment", {}).items(): - for platform, packages in platforms.items(): + def cell(package: dict | None) -> str: + if package is None: + return "" + if ref := git_ref(package): + repo, rev, sha = ref + return f"{f'`{rev}` @ ' if rev else ''}[`{sha[:8]}`]({repo}/commit/{sha}) ({package.get('version', '')})" + return version(package) + + # one status per package name (the most significant across environments and platforms), + # one row per distinct explicit change (environments may resolve different versions) + status, rows = {}, {} + for platforms in diff["environment"].values(): + for packages in platforms.values(): for package in packages: - name, change = package["name"], classify(package.get("before"), package.get("after")) - if name not in status or priority.index(change) < priority.index(status[name]): + name, before, after = package["name"], package.get("before"), package.get("after") + change = classify(before, after) + if name not in status or PRIORITY.index(change) < PRIORITY.index(status[name]): status[name] = change - if package.get("explicit"): - explicit.add(name) - # version changes only: git dependencies have their own table - is_git = parse(package.get("before")) or parse(package.get("after")) - if change != "rebuilt" and not is_git: - explicit_diff["environment"].setdefault(environment, {}).setdefault(platform, []).append(package) - - counts = {change: sum(value == change for value in status.values()) for change in priority} - if status: - labels = [ - ("upgraded", counts["upgraded"] + counts["major"], f" ({counts['major']} major)" if counts["major"] else ""), - ("⚠️ downgraded", counts["downgraded"], ""), - ("other version changes", counts["updated"], ""), - ("rebuilt (same version)", counts["rebuilt"], ""), - ("added", counts["added"], ""), - ("removed", counts["removed"], ""), - ] - stats = f"**{len(status)}** packages changed: " + ", ".join( - f"{count} {label}{detail}" for label, count, detail in labels if count - ) + "." - else: - stats = "*No package changes.*" - (work_dir / "stats.md").write_text(stats + "\n", encoding="utf-8") - print(stats) - - names = ", ".join(f"`{name}`" for name in sorted(explicit)) - (work_dir / "explicit-names.md").write_text(f"Explicit dependencies: {names}.\n\n" if names else "", encoding="utf-8") - (work_dir / "explicit-diff.json").write_text(json.dumps(explicit_diff), encoding="utf-8") - - - name: Assemble report - env: - BEFORE: ${{ steps.commits.outputs.before }} - AFTER: ${{ steps.commits.outputs.after }} - NEW_LOCKFILE: ${{ steps.files.outputs.new-lockfile }} - RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} - REPO_URL: ${{ github.server_url }}/${{ github.repository }} - ENVIRONMENTS: ${{ inputs.environments }} - run: | - report="$WORK_DIR/report.md" - { - echo "$COMMENT_MARKER" - echo "## 📦 Changes in \`$LOCKFILE_PATH\`" - echo - echo "Comparing [\`${BEFORE:0:8}\`]($REPO_URL/commit/$BEFORE) to [\`${AFTER:0:8}\`]($REPO_URL/commit/$AFTER)${ENVIRONMENTS:+, environments: \`$ENVIRONMENTS\`} ([workflow run]($RUN_URL))." - echo - if [ "$NEW_LOCKFILE" == "true" ]; then - echo "> [!NOTE]" - echo "> \`$LOCKFILE_PATH\` is new: all packages are reported as added." - echo - fi - cat "$WORK_DIR/stats.md" - echo - cat "$WORK_DIR/git-dependencies.md" - echo - if [ "$(tr -d '[:space:]' < "$WORK_DIR/dependencies.md")" != "Nodiff" ]; then - # nest headings under the report title - sed -E 's/^# /### /' "$WORK_DIR/dependencies.md" - fi - } > "$report" - cat "$report" >> "$GITHUB_STEP_SUMMARY" - - - name: Assemble PR comment - id: comment - if: > - inputs.comment-on-pr && - github.event_name == 'pull_request' && - github.event.pull_request.head.repo.full_name == github.repository - env: - RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} - JOB_ID: ${{ job.check_run_id }} - # GitHub comment limit: 65536 characters - COMMENT_MAX_CHARS: 60000 - run: | - # short comment linking to the job summary: the implicit dependencies are not duplicated in the conversation - summary_url="$RUN_URL${JOB_ID:+#summary-$JOB_ID}" - - explicit="$WORK_DIR/explicit.md" - pixi exec -s "pixi-diff-to-markdown==$PIXI_DIFF_TO_MARKDOWN_VERSION" pixi-diff-to-markdown \ - --merge-dependencies yes --hide-tables no < "$WORK_DIR/explicit-diff.json" > "$explicit.tmp" - if [ "$(tr -d '[:space:]' < "$explicit.tmp")" == "Nodiff" ]; then - : > "$explicit" - else - rows=$(grep -c '^|\[\?\*\*' "$explicit.tmp" || true) - { + if package.get("explicit") and cell(before) != cell(after): + label = LABELS[change] + old_ref, new_ref = before and git_ref(before), after and git_ref(after) + if old_ref and new_ref and old_ref[0] == new_ref[0] and "github.com" in new_ref[0]: + label += f" ([compare]({new_ref[0]}/compare/{old_ref[2]}...{new_ref[2]}))" + rows.setdefault((name, cell(before), cell(after)), label) + + counts = {change: list(status.values()).count(change) for change in PRIORITY} + parts = [ + (counts["major"] + counts["minor"] + counts["patch"], "upgraded" + (f" ({counts['major']} major)" if counts["major"] else "")), + (counts["downgraded"], "⚠️ downgraded"), + (counts["updated"], "other version changes"), + (counts["rebuilt"], "rebuilt (same version)"), + (counts["added"], "added"), + (counts["removed"], "removed"), + ] + stats = f"**{len(status)}** packages changed: {', '.join(f'{n} {label}' for n, label in parts if n)}." if status else "*No package changes.*" + + table = [] + if rows: + lines = ["|Dependency|Before|After|Change|", "|-|-|-|-|"] + [f"|{n}|{b}|{a}|{c}|" for (n, b, a), c in sorted(rows.items())] # collapse long tables - if [ "$rows" -le 15 ]; then - echo "### Explicit dependencies" - echo - else - echo "
" - echo "Explicit dependencies ($rows changes)" - echo - fi - # all rows are explicit: drop the bold footnote, and the downgrade footnote when unused - unused_footnotes=(-e '/^\[^2\]:/d') - if grep -q '^|.*\[^2\]' "$explicit.tmp"; then - unused_footnotes=() - fi - sed -e 's/^|Dependency\[^1\]|/|Dependency|/' -e '/^\[^1\]:/d' "${unused_footnotes[@]}" "$explicit.tmp" - if [ "$rows" -gt 15 ]; then - echo "
" - echo - fi - } > "$explicit" - fi - - # drop details until the comment fits - body="$WORK_DIR/comment.md" - for level in full names minimal; do - { - sed -n '1,/^Comparing /p' "$WORK_DIR/report.md" - echo - cat "$WORK_DIR/stats.md" - echo - case "$level" in - full) cat "$explicit" "$WORK_DIR/git-dependencies.md" ;; - names) cat "$WORK_DIR/explicit-names.md" "$WORK_DIR/git-dependencies.md" ;; - esac - echo - if [ "$level" != "full" ]; then - echo "> [!NOTE]" - echo "> Some details are too large for a comment: see the full report." - echo - fi - echo "📋 **[Full report in the job summary]($summary_url)**" - } > "$body" - size=$(wc -m < "$body") - echo "Comment with '$level' details: $size characters" - if [ "$size" -le "$COMMENT_MAX_CHARS" ]; then - break - fi - echo "::notice::PR comment too large with '$level' details ($size characters): removing details" - done + table = ["### Explicit dependencies", "", *lines, ""] if len(rows) <= 15 else [ + "
", f"Explicit dependencies ({len(rows)} changes)", "", *lines, "", "
", ""] + + head = [ + env["COMMENT_MARKER"], + f"## 📦 Changes in `{env['LOCKFILE_PATH']}`", + "", + f"Comparing [`{env['BASE_SHA'][:8]}`]({env['REPO_URL']}/commit/{env['BASE_SHA']}) to " + f"[`{env['HEAD_SHA'][:8]}`]({env['REPO_URL']}/commit/{env['HEAD_SHA']}) ([workflow run]({env['RUN_URL']})).", + "", + stats, + "", + ] + everything = (temp / "dependencies.md").read_text(encoding="utf-8") + summary = head + table + (["### All changes", "", everything] if everything.strip() != "No diff" else []) + with open(env["GITHUB_STEP_SUMMARY"], "a", encoding="utf-8") as file: + file.write("\n".join(summary) + "\n") + + link = f"📋 **[Full report in the job summary]({env['RUN_URL']}{'#summary-' + env['JOB_ID'] if env.get('JOB_ID') else ''})**" + comment = "\n".join(head + table + [link]) + if len(comment) > COMMENT_MAX_CHARS: + print(f"::notice::PR comment too large ({len(comment)} characters): leaving out the explicit dependencies") + comment = "\n".join(head + ["> [!NOTE]", "> The explicit dependency changes are too large for a comment.", "", link]) + (temp / "comment.md").write_text(comment + "\n", encoding="utf-8") + print(comment) - name: Comment on pull request - if: steps.comment.outcome == 'success' + if: inputs.comment-on-pr && github.event.pull_request.head.repo.full_name == github.repository env: GH_TOKEN: ${{ github.token }} PR_NUMBER: ${{ github.event.pull_request.number }} run: | - body="$WORK_DIR/comment.md" + body="$RUNNER_TEMP/comment.md" existing=$(gh api --paginate "repos/$GITHUB_REPOSITORY/issues/$PR_NUMBER/comments" \ --jq ".[] | select(.user.login == \"github-actions[bot]\" and (.body | startswith(\"$COMMENT_MARKER\"))) | \"\(.id) \(.node_id)\"" \ | head -n 1) From b9e71093eb03a6d3dd851d9d991d25e41a4171c8 Mon Sep 17 00:00:00 2001 From: Romain Floreani Date: Mon, 5 Oct 2026 11:01:02 -0400 Subject: [PATCH 8/9] Simplify the pixi lock diff workflow - Pull requests only: the job summary on push was not used - Remove the unused environments and merge-dependencies inputs - Read the PR lock file and manifest from the checkout, only the base lock file from git - Build the job summary and the PR comment in a single Python step - One explicit dependency table, including git dependencies with compare links - Label upgrades as major, minor or patch --- .github/workflows/reusable-pixi-lock_diff.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/reusable-pixi-lock_diff.yml b/.github/workflows/reusable-pixi-lock_diff.yml index 579a83a7..f4ee8bfc 100644 --- a/.github/workflows/reusable-pixi-lock_diff.yml +++ b/.github/workflows/reusable-pixi-lock_diff.yml @@ -194,7 +194,7 @@ jobs: (counts["added"], "added"), (counts["removed"], "removed"), ] - stats = f"**{len(status)}** packages changed: {', '.join(f'{n} {label}' for n, label in parts if n)}." if status else "*No package changes.*" + stats = f"**{len(status)}** package{'s' if len(status) > 1 else ''} changed: {', '.join(f'{n} {label}' for n, label in parts if n)}." if status else "*No package changes.*" table = [] if rows: From 4bbfca570859e6c226b6682b1b6f05ac89db57e8 Mon Sep 17 00:00:00 2001 From: Romain Floreani Date: Fri, 16 Oct 2026 14:24:17 -0400 Subject: [PATCH 9/9] New commit based on PR feedback --- .github/actions/pixi-lock-diff/action.yml | 36 +++++ .../actions/pixi-lock-diff/pixi_lock_diff.py | 119 ++++++++++++++ .github/workflows/reusable-pixi-lock_diff.yml | 145 ++---------------- 3 files changed, 169 insertions(+), 131 deletions(-) create mode 100644 .github/actions/pixi-lock-diff/action.yml create mode 100644 .github/actions/pixi-lock-diff/pixi_lock_diff.py diff --git a/.github/actions/pixi-lock-diff/action.yml b/.github/actions/pixi-lock-diff/action.yml new file mode 100644 index 00000000..f4b9b973 --- /dev/null +++ b/.github/actions/pixi-lock-diff/action.yml @@ -0,0 +1,36 @@ +name: Pixi lock diff report +description: | + Builds the pixi.lock diff report from the pixi-diff output ($RUNNER_TEMP/diff.json) and its + pixi-diff-to-markdown rendering ($RUNNER_TEMP/dependencies.md). Appends it to the job summary + and writes the PR comment body to $RUNNER_TEMP/comment.md. +inputs: + lockfile-path: + description: Lock file path, shown in the report title + required: true + base-sha: + description: Commit compared from + required: true + head-sha: + description: Commit compared to + required: true + comment-marker: + description: Hidden marker starting the comment, to find it on later runs + required: true + job-id: + description: Check run ID of the calling job, to link the comment to its job summary + required: false + default: "" +runs: + using: composite + steps: + - name: Build report + shell: bash + env: + LOCKFILE_PATH: ${{ inputs.lockfile-path }} + BASE_SHA: ${{ inputs.base-sha }} + HEAD_SHA: ${{ inputs.head-sha }} + COMMENT_MARKER: ${{ inputs.comment-marker }} + JOB_ID: ${{ inputs.job-id }} + REPO_URL: ${{ github.server_url }}/${{ github.repository }} + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + run: python3 "$GITHUB_ACTION_PATH/pixi_lock_diff.py" diff --git a/.github/actions/pixi-lock-diff/pixi_lock_diff.py b/.github/actions/pixi-lock-diff/pixi_lock_diff.py new file mode 100644 index 00000000..b4a57dc8 --- /dev/null +++ b/.github/actions/pixi-lock-diff/pixi_lock_diff.py @@ -0,0 +1,119 @@ +"""Job summary and PR comment: change counts and explicit dependency changes (with git commits, which +pixi-diff-to-markdown shows as 'unknown'), then all changes in the summary only.""" +from __future__ import annotations + +import json +import os +import re +from pathlib import Path +from urllib.parse import parse_qs, urlsplit + +COMMENT_MAX_CHARS = 60000 # GitHub limit: 65536 +PRIORITY = ["downgraded", "major", "minor", "patch", "updated", "rebuilt", "added", "removed"] +LABELS = {"downgraded": "⚠️ Downgrade", "major": "Major upgrade", "minor": "Minor upgrade", "patch": "Patch upgrade", + "updated": "Update", "rebuilt": "New commit", "added": "Added", "removed": "Removed"} + +env = os.environ +temp = Path(env["RUNNER_TEMP"]) +diff = json.loads((temp / "diff.json").read_text(encoding="utf-8")) + +def git_ref(package: dict) -> tuple[str, str, str] | None: + """Repo, ref and commit of 'git+[?rev=|branch=|tag=][&subdirectory=...]#'.""" + url = package.get("pypi", "") + parts = urlsplit(url.removeprefix("git+")) + if not url.startswith("git+") or not re.fullmatch(r"[0-9a-f]{7,40}", parts.fragment): + return None + query = parse_qs(parts.query) + rev = next((query[key][0] for key in ("rev", "branch", "tag") if key in query), "") + return f"{parts.scheme}://{parts.netloc}{parts.path}".removesuffix(".git"), rev, parts.fragment + +def version(package: dict) -> str: + """Conda: from the file name '--.conda'. PyPI: version field.""" + if "conda" in package: + parts = package["conda"].rsplit("/", 1)[-1].removesuffix(".conda").removesuffix(".tar.bz2").rsplit("-", 2) + if len(parts) == 3: + return parts[1] + return package.get("version", "") + +def release(text: str) -> tuple[int, ...] | None: + """Numeric release without epoch, padded for comparison: '1!2.10rc1' -> (2, 10, 0, ...).""" + match = re.match(r"\d+(?:\.\d+)*", text.split("!")[-1]) + return tuple(map(int, match.group().split("."))) + (0,) * 8 if match else None + +def classify(before: dict | None, after: dict | None) -> str: + if before is None or after is None: + return "added" if before is None else "removed" + if version(before) == version(after): + return "rebuilt" + old, new = release(version(before)), release(version(after)) + if old is None or new is None or old == new: + return "updated" # e.g. pre-release change + if new < old: + return "downgraded" + return "major" if new[0] != old[0] else "minor" if new[1] != old[1] else "patch" + +def cell(package: dict | None) -> str: + if package is None: + return "" + if ref := git_ref(package): + repo, rev, sha = ref + return f"{f'`{rev}` @ ' if rev else ''}[`{sha[:8]}`]({repo}/commit/{sha}) ({package.get('version', '')})" + return version(package) + +# one status per package name (the most significant across environments and platforms), +# one row per distinct explicit change (environments may resolve different versions) +status, rows = {}, {} +for platforms in diff["environment"].values(): + for packages in platforms.values(): + for package in packages: + name, before, after = package["name"], package.get("before"), package.get("after") + change = classify(before, after) + if name not in status or PRIORITY.index(change) < PRIORITY.index(status[name]): + status[name] = change + if package.get("explicit") and cell(before) != cell(after): + label = LABELS[change] + old_ref, new_ref = before and git_ref(before), after and git_ref(after) + if old_ref and new_ref and old_ref[0] == new_ref[0] and "github.com" in new_ref[0]: + label += f" ([compare]({new_ref[0]}/compare/{old_ref[2]}...{new_ref[2]}))" + rows.setdefault((name, cell(before), cell(after)), label) + +counts = {change: list(status.values()).count(change) for change in PRIORITY} +parts = [ + (counts["major"] + counts["minor"] + counts["patch"], "upgraded" + (f" ({counts['major']} major)" if counts["major"] else "")), + (counts["downgraded"], "⚠️ downgraded"), + (counts["updated"], "other version changes"), + (counts["rebuilt"], "rebuilt (same version)"), + (counts["added"], "added"), + (counts["removed"], "removed"), +] +stats = f"**{len(status)}** package{'s' if len(status) > 1 else ''} changed: {', '.join(f'{n} {label}' for n, label in parts if n)}." if status else "*No package changes.*" + +table = [] +if rows: + lines = ["|Dependency|Before|After|Change|", "|-|-|-|-|"] + [f"|{n}|{b}|{a}|{c}|" for (n, b, a), c in sorted(rows.items())] + # collapse long tables + table = ["### Explicit dependencies", "", *lines, ""] if len(rows) <= 15 else [ + "
", f"Explicit dependencies ({len(rows)} changes)", "", *lines, "", "
", ""] + +head = [ + env["COMMENT_MARKER"], + f"## 📦 Changes in `{env['LOCKFILE_PATH']}`", + "", + f"Comparing [`{env['BASE_SHA'][:8]}`]({env['REPO_URL']}/commit/{env['BASE_SHA']}) to " + f"[`{env['HEAD_SHA'][:8]}`]({env['REPO_URL']}/commit/{env['HEAD_SHA']}) ([workflow run]({env['RUN_URL']})).", + "", + stats, + "", +] +everything = (temp / "dependencies.md").read_text(encoding="utf-8") +summary = head + table + (["### All changes", "", everything] if everything.strip() != "No diff" else []) +with open(env["GITHUB_STEP_SUMMARY"], "a", encoding="utf-8") as file: + file.write("\n".join(summary) + "\n") + +link = f"📋 **[Full report in the job summary]({env['RUN_URL']}{'#summary-' + env['JOB_ID'] if env.get('JOB_ID') else ''})**" +comment = "\n".join(head + table + [link]) +if len(comment) > COMMENT_MAX_CHARS: + print(f"::notice::PR comment too large ({len(comment)} characters): leaving out the explicit dependencies") + comment = "\n".join(head + ["> [!NOTE]", "> The explicit dependency changes are too large for a comment.", "", link]) +(temp / "comment.md").write_text(comment + "\n", encoding="utf-8") +print(comment) diff --git a/.github/workflows/reusable-pixi-lock_diff.yml b/.github/workflows/reusable-pixi-lock_diff.yml index f4ee8bfc..8d7d3af6 100644 --- a/.github/workflows/reusable-pixi-lock_diff.yml +++ b/.github/workflows/reusable-pixi-lock_diff.yml @@ -83,6 +83,11 @@ jobs: pixi-version: ${{ inputs.pixi-version }} run-install: false cache: false + # pinned: the global cache key hashes this list, so a version bump refreshes the cache + global-environments: | + pixi-diff==${{ env.PIXI_DIFF_VERSION }} + pixi-diff-to-markdown==${{ env.PIXI_DIFF_TO_MARKDOWN_VERSION }} + global-cache: true - name: Compute diff run: | @@ -92,139 +97,17 @@ jobs: else echo "::warning::Manifest '$MANIFEST_PATH' not found: explicit dependencies will not be identified" fi - pixi exec -s "pixi-diff==$PIXI_DIFF_VERSION" pixi-diff \ - --before "$RUNNER_TEMP/base.lock" --after "$LOCKFILE_PATH" "${manifest_args[@]}" > "$RUNNER_TEMP/diff.json" - pixi exec -s "pixi-diff-to-markdown==$PIXI_DIFF_TO_MARKDOWN_VERSION" pixi-diff-to-markdown \ - --merge-dependencies yes < "$RUNNER_TEMP/diff.json" > "$RUNNER_TEMP/dependencies.md" + pixi-diff --before "$RUNNER_TEMP/base.lock" --after "$LOCKFILE_PATH" "${manifest_args[@]}" > "$RUNNER_TEMP/diff.json" + pixi-diff-to-markdown --merge-dependencies yes < "$RUNNER_TEMP/diff.json" > "$RUNNER_TEMP/dependencies.md" - name: Build report - shell: python - env: - BASE_SHA: ${{ steps.base.outputs.sha }} - HEAD_SHA: ${{ github.event.pull_request.head.sha }} - REPO_URL: ${{ github.server_url }}/${{ github.repository }} - RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} - JOB_ID: ${{ job.check_run_id }} - run: | - """Job summary and PR comment: change counts and explicit dependency changes (with git commits, which - pixi-diff-to-markdown shows as 'unknown'), then all changes in the summary only.""" - from __future__ import annotations - - import json - import os - import re - from pathlib import Path - from urllib.parse import parse_qs, urlsplit - - COMMENT_MAX_CHARS = 60000 # GitHub limit: 65536 - PRIORITY = ["downgraded", "major", "minor", "patch", "updated", "rebuilt", "added", "removed"] - LABELS = {"downgraded": "⚠️ Downgrade", "major": "Major upgrade", "minor": "Minor upgrade", "patch": "Patch upgrade", - "updated": "Update", "rebuilt": "New commit", "added": "Added", "removed": "Removed"} - - env = os.environ - temp = Path(env["RUNNER_TEMP"]) - diff = json.loads((temp / "diff.json").read_text(encoding="utf-8")) - - def git_ref(package: dict) -> tuple[str, str, str] | None: - """Repo, ref and commit of 'git+[?rev=|branch=|tag=][&subdirectory=...]#'.""" - url = package.get("pypi", "") - parts = urlsplit(url.removeprefix("git+")) - if not url.startswith("git+") or not re.fullmatch(r"[0-9a-f]{7,40}", parts.fragment): - return None - query = parse_qs(parts.query) - rev = next((query[key][0] for key in ("rev", "branch", "tag") if key in query), "") - return f"{parts.scheme}://{parts.netloc}{parts.path}".removesuffix(".git"), rev, parts.fragment - - def version(package: dict) -> str: - """Conda: from the file name '--.conda'. PyPI: version field.""" - if "conda" in package: - parts = package["conda"].rsplit("/", 1)[-1].removesuffix(".conda").removesuffix(".tar.bz2").rsplit("-", 2) - if len(parts) == 3: - return parts[1] - return package.get("version", "") - - def release(text: str) -> tuple[int, ...] | None: - """Numeric release without epoch, padded for comparison: '1!2.10rc1' -> (2, 10, 0, ...).""" - match = re.match(r"\d+(?:\.\d+)*", text.split("!")[-1]) - return tuple(map(int, match.group().split("."))) + (0,) * 8 if match else None - - def classify(before: dict | None, after: dict | None) -> str: - if before is None or after is None: - return "added" if before is None else "removed" - if version(before) == version(after): - return "rebuilt" - old, new = release(version(before)), release(version(after)) - if old is None or new is None or old == new: - return "updated" # e.g. pre-release change - if new < old: - return "downgraded" - return "major" if new[0] != old[0] else "minor" if new[1] != old[1] else "patch" - - def cell(package: dict | None) -> str: - if package is None: - return "" - if ref := git_ref(package): - repo, rev, sha = ref - return f"{f'`{rev}` @ ' if rev else ''}[`{sha[:8]}`]({repo}/commit/{sha}) ({package.get('version', '')})" - return version(package) - - # one status per package name (the most significant across environments and platforms), - # one row per distinct explicit change (environments may resolve different versions) - status, rows = {}, {} - for platforms in diff["environment"].values(): - for packages in platforms.values(): - for package in packages: - name, before, after = package["name"], package.get("before"), package.get("after") - change = classify(before, after) - if name not in status or PRIORITY.index(change) < PRIORITY.index(status[name]): - status[name] = change - if package.get("explicit") and cell(before) != cell(after): - label = LABELS[change] - old_ref, new_ref = before and git_ref(before), after and git_ref(after) - if old_ref and new_ref and old_ref[0] == new_ref[0] and "github.com" in new_ref[0]: - label += f" ([compare]({new_ref[0]}/compare/{old_ref[2]}...{new_ref[2]}))" - rows.setdefault((name, cell(before), cell(after)), label) - - counts = {change: list(status.values()).count(change) for change in PRIORITY} - parts = [ - (counts["major"] + counts["minor"] + counts["patch"], "upgraded" + (f" ({counts['major']} major)" if counts["major"] else "")), - (counts["downgraded"], "⚠️ downgraded"), - (counts["updated"], "other version changes"), - (counts["rebuilt"], "rebuilt (same version)"), - (counts["added"], "added"), - (counts["removed"], "removed"), - ] - stats = f"**{len(status)}** package{'s' if len(status) > 1 else ''} changed: {', '.join(f'{n} {label}' for n, label in parts if n)}." if status else "*No package changes.*" - - table = [] - if rows: - lines = ["|Dependency|Before|After|Change|", "|-|-|-|-|"] + [f"|{n}|{b}|{a}|{c}|" for (n, b, a), c in sorted(rows.items())] - # collapse long tables - table = ["### Explicit dependencies", "", *lines, ""] if len(rows) <= 15 else [ - "
", f"Explicit dependencies ({len(rows)} changes)", "", *lines, "", "
", ""] - - head = [ - env["COMMENT_MARKER"], - f"## 📦 Changes in `{env['LOCKFILE_PATH']}`", - "", - f"Comparing [`{env['BASE_SHA'][:8]}`]({env['REPO_URL']}/commit/{env['BASE_SHA']}) to " - f"[`{env['HEAD_SHA'][:8]}`]({env['REPO_URL']}/commit/{env['HEAD_SHA']}) ([workflow run]({env['RUN_URL']})).", - "", - stats, - "", - ] - everything = (temp / "dependencies.md").read_text(encoding="utf-8") - summary = head + table + (["### All changes", "", everything] if everything.strip() != "No diff" else []) - with open(env["GITHUB_STEP_SUMMARY"], "a", encoding="utf-8") as file: - file.write("\n".join(summary) + "\n") - - link = f"📋 **[Full report in the job summary]({env['RUN_URL']}{'#summary-' + env['JOB_ID'] if env.get('JOB_ID') else ''})**" - comment = "\n".join(head + table + [link]) - if len(comment) > COMMENT_MAX_CHARS: - print(f"::notice::PR comment too large ({len(comment)} characters): leaving out the explicit dependencies") - comment = "\n".join(head + ["> [!NOTE]", "> The explicit dependency changes are too large for a comment.", "", link]) - (temp / "comment.md").write_text(comment + "\n", encoding="utf-8") - print(comment) + uses: $/.github/actions/pixi-lock-diff + with: + lockfile-path: ${{ inputs.lockfile-path }} + base-sha: ${{ steps.base.outputs.sha }} + head-sha: ${{ github.event.pull_request.head.sha }} + comment-marker: ${{ env.COMMENT_MARKER }} + job-id: ${{ job.check_run_id }} - name: Comment on pull request if: inputs.comment-on-pr && github.event.pull_request.head.repo.full_name == github.repository