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
173 changes: 173 additions & 0 deletions .github/workflows/upstream-release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
name: CatBoost upstream release watcher

# Weekly (and on-demand) watch on catboost/catboost. When a release newer than the
# version pinned in build.rs appears, Claude reviews the upstream C API header diff and
# edits the crate; a dedicated action then opens ONE pull request. Nothing here can push
# to the base branch or publish a release — the only remote write is the PR itself.
#
# Auth: workload identity federation (no stored API key). Requires repo secrets:
# ANTHROPIC_FEDERATION_RULE_ID, ANTHROPIC_ORGANIZATION_ID, ANTHROPIC_SERVICE_ACCOUNT_ID
# (ANTHROPIC_WORKSPACE_ID only if the federation rule spans multiple workspaces)
# Recommended: a branch-protection rule on the default branch requiring PRs.

on:
schedule:
- cron: '20 6 * * 1' # Mondays 06:20 UTC (staggered)
workflow_dispatch:

permissions:
id-token: write # fetch the GitHub OIDC token for Anthropic WIF
contents: write # minimum GitHub allows for creating a PR branch
pull-requests: write # open/update the PR

jobs:
propose-update:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
persist-credentials: false # the edit step gets NO git push credentials

- name: Determine current and latest versions
id: versions
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
CURRENT=$(grep 'unwrap_or_else' build.rs | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -1)
[ -n "$CURRENT" ] || { echo "::error::Could not find pinned version in build.rs"; exit 1; }
LATEST=$(gh api "repos/catboost/catboost/releases/latest" --jq '.tag_name' | sed 's/^v//')
[ -n "$LATEST" ] || { echo "::error::Could not fetch latest release"; exit 1; }

echo "current=$CURRENT" >> "$GITHUB_OUTPUT"
echo "latest=$LATEST" >> "$GITHUB_OUTPUT"
Comment on lines +40 to +44

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Validate version format to prevent shell template injection.

LATEST is derived from the upstream catboost/catboost release tag name via gh api ... --jq '.tag_name' | sed 's/^v//'. The sed only strips a leading v — it does not sanitize other characters. This value is then injected directly into a shell script at line 63 (BRANCH="chore/catboost-${{ steps.versions.outputs.latest }}"), where an attacker-controlled tag containing shell metacharacters (e.g., $(...) or backticks) would execute arbitrary commands with contents: write and pull-requests: write permissions.

Add a strict version-format validation before setting the output:

🔒️ Proposed fix: validate version format
          LATEST=$(gh api "repos/catboost/catboost/releases/latest" --jq '.tag_name' | sed 's/^v//')
          [ -n "$LATEST" ] || { echo "::error::Could not fetch latest release"; exit 1; }
+
+         # Reject anything that isn't a clean semver — prevents template injection downstream
+         echo "$LATEST" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+$' || {
+           echo "::error::Latest upstream tag is not a clean semver: '$LATEST'"
+           exit 1
+         }
+         echo "$CURRENT" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+$' || {
+           echo "::error::Pinned version is not a clean semver: '$CURRENT'"
+           exit 1
+         }

This protects all downstream uses of steps.versions.outputs.latest (lines 63, 115, 120, 138, 147, 168–170).

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
LATEST=$(gh api "repos/catboost/catboost/releases/latest" --jq '.tag_name' | sed 's/^v//')
[ -n "$LATEST" ] || { echo "::error::Could not fetch latest release"; exit 1; }
echo "current=$CURRENT" >> "$GITHUB_OUTPUT"
echo "latest=$LATEST" >> "$GITHUB_OUTPUT"
LATEST=$(gh api "repos/catboost/catboost/releases/latest" --jq '.tag_name' | sed 's/^v//')
[ -n "$LATEST" ] || { echo "::error::Could not fetch latest release"; exit 1; }
# Reject anything that isn't a clean semver — prevents template injection downstream
echo "$LATEST" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+$' || {
echo "::error::Latest upstream tag is not a clean semver: '$LATEST'"
exit 1
}
echo "$CURRENT" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+$' || {
echo "::error::Pinned version is not a clean semver: '$CURRENT'"
exit 1
}
echo "current=$CURRENT" >> "$GITHUB_OUTPUT"
echo "latest=$LATEST" >> "$GITHUB_OUTPUT"
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.github/workflows/upstream-release.yml around lines 40 - 44, Validate LATEST
in the upstream release-version step before writing the GitHub output, allowing
only the expected CatBoost version format after removing the leading v; reject
empty or any value containing shell/template metacharacters with an error and
nonzero exit. Keep the existing current/latest outputs unchanged for valid
versions so downstream uses of steps.versions.outputs.latest remain safe.

Source: Linters/SAST tools

echo "Current pinned: $CURRENT | Latest upstream: $LATEST"

if [ "$CURRENT" = "$LATEST" ]; then
echo "update=false" >> "$GITHUB_OUTPUT"
elif [ "$(printf '%s\n%s\n' "$CURRENT" "$LATEST" | sort -V | tail -1)" = "$LATEST" ]; then
echo "update=true" >> "$GITHUB_OUTPUT"
else
echo "Pinned version is newer than upstream latest; nothing to do."
echo "update=false" >> "$GITHUB_OUTPUT"
fi

- name: Skip if an update PR is already open
id: existing
if: steps.versions.outputs.update == 'true'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
BRANCH="chore/catboost-${{ steps.versions.outputs.latest }}"
if gh pr list --state open --head "$BRANCH" --json number --jq '.[0].number' | grep -q '[0-9]'; then
echo "skip=true" >> "$GITHUB_OUTPUT"
else
echo "skip=false" >> "$GITHUB_OUTPUT"
fi

- name: Build upstream C API header diff
id: diff
if: steps.versions.outputs.update == 'true' && steps.existing.outputs.skip == 'false'
env:
CURRENT: ${{ steps.versions.outputs.current }}
LATEST: ${{ steps.versions.outputs.latest }}
run: |
set -uo pipefail

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Add set -e for consistency with other steps.

This step uses set -uo pipefail while all other run blocks use set -euo pipefail. Without -e, unexpected failures (e.g., printf at line 88) are silently ignored, potentially producing an incomplete diff or PR body. The existing || true guards on curl and diff already handle expected non-zero exits, so adding -e is safe.

✏️ Proposed fix
-          set -uo pipefail
+          set -euo pipefail
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
set -uo pipefail
set -euo pipefail
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.github/workflows/upstream-release.yml at line 77, Update the shell options
in the affected run block of the upstream release workflow from set -uo pipefail
to set -euo pipefail. Preserve the existing || true guards for expected curl and
diff failures while ensuring unexpected command failures stop the step.

OUT="$RUNNER_TEMP/header-diff.txt"
: > "$OUT"
for h in catboost/libs/model_interface/c_api.h; do
old=$(curl -fsSL "https://raw.githubusercontent.com/catboost/catboost/v${CURRENT}/${h}" 2>/dev/null || true)
new=$(curl -fsSL "https://raw.githubusercontent.com/catboost/catboost/v${LATEST}/${h}" 2>/dev/null || true)
[ -z "$old" ] && [ -z "$new" ] && continue
echo "===== ${h} (v${CURRENT} -> v${LATEST}) =====" >> "$OUT"
diff -u <(printf '%s' "$old") <(printf '%s' "$new") >> "$OUT" || true
echo >> "$OUT"
done
printf '## CatBoost v%s\n\nBump from v%s. Release notes: https://github.com/catboost/catboost/releases/tag/v%s\n' \
"$LATEST" "$CURRENT" "$LATEST" > "$RUNNER_TEMP/pr-body.md"
echo "path=$OUT" >> "$GITHUB_OUTPUT"
echo "Wrote header diff to $OUT ($(wc -l < "$OUT") lines)"

- name: Claude — review diff and edit the crate (no push access)
if: steps.versions.outputs.update == 'true' && steps.existing.outputs.skip == 'false'
uses: anthropics/claude-code-action@v1
with:
# Workload identity federation — exchanges the GitHub OIDC token for a
# short-lived Anthropic credential. Do NOT also set anthropic_api_key.
anthropic_federation_rule_id: ${{ secrets.ANTHROPIC_FEDERATION_RULE_ID }}
anthropic_organization_id: ${{ secrets.ANTHROPIC_ORGANIZATION_ID }}
anthropic_service_account_id: ${{ secrets.ANTHROPIC_SERVICE_ACCOUNT_ID }}
anthropic_workspace_id: ${{ secrets.ANTHROPIC_WORKSPACE_ID }}
claude_args: |
--allowedTools Bash,Edit,Write,Read,Glob,Grep
--max-turns 40
--model claude-opus-4-8
prompt: |
You are preparing a dependency update for the Rust crate `catboost-rust`,
which wraps the CatBoost model-interface C API. You can ONLY edit files. Do NOT
run git, do NOT commit, push, or open a PR — a later workflow step does that
from your changes. There are no push credentials available to you.

Upstream catboost/catboost has a newer release:
current pinned version: v${{ steps.versions.outputs.current }}
new version: v${{ steps.versions.outputs.latest }}

Do the following:

1. In build.rs, bump the default version returned by `get_catboost_version()`
from ${{ steps.versions.outputs.current }} to ${{ steps.versions.outputs.latest }}.
Update stale references to the old version in README.md where clearly appropriate.

2. Read the upstream C API header diff: `cat "${{ steps.diff.outputs.path }}"`.
Analyze it for changes affecting this crate: changed/removed function
signatures the safe wrappers call, valuable new functions, changed
structs/enums/constants.

IMPORTANT: the raw FFI in src/sys.rs is generated by bindgen at build time
(`include!(concat!(env!("OUT_DIR"), "/bindings.rs"))`) — do NOT hand-write or
edit FFI declarations. Only modify the SAFE WRAPPER layer (src/model.rs,
src/lib.rs, src/error.rs, src/features.rs, src/polars_ext.rs) where the diff
actually requires it. Be conservative and minimal.

3. CatBoost has changed its release-asset file naming between releases (e.g.
`libcatboostmodel-linux-x86_64-<ver>.so` vs the older `libcatboostmodel.so`).
The `download_compiled_library` logic in build.rs branches on this. Note in
your review that a maintainer should confirm the asset names for
v${{ steps.versions.outputs.latest }} still match that logic — and if the diff
or your knowledge shows the naming changed, update build.rs accordingly.

4. If feasible run `cargo build` (then `cargo test`). The build downloads
platform libraries and may fail for environment reasons — if so, note it and
continue.

5. Write the pull-request body to the file "${{ runner.temp }}/pr-body.md"
(overwrite it). It must contain:
- the bump (v${{ steps.versions.outputs.current }} -> v${{ steps.versions.outputs.latest }})
and the release-notes link.
- a "## Code review" section: an honest review of the C API diff and the
release-asset naming — what changed, what it means for this crate, and
exactly what you changed and why (or that no changes were required).
- a "## Needs human verification" checklist (release assets present for every
platform, build/test on real targets, etc.).

- name: Ensure PR labels exist
if: steps.versions.outputs.update == 'true' && steps.existing.outputs.skip == 'false'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
gh label create dependencies --force >/dev/null 2>&1 || true
gh label create upstream-release --force >/dev/null 2>&1 || true

- name: Create pull request
if: steps.versions.outputs.update == 'true' && steps.existing.outputs.skip == 'false'
uses: peter-evans/create-pull-request@v6
with:
token: ${{ secrets.GITHUB_TOKEN }}
branch: chore/catboost-${{ steps.versions.outputs.latest }}
commit-message: "chore: update CatBoost to v${{ steps.versions.outputs.latest }}"
title: "chore: update CatBoost to v${{ steps.versions.outputs.latest }}"
body-path: ${{ runner.temp }}/pr-body.md
labels: dependencies, upstream-release
delete-branch: true
4 changes: 4 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@ categories = ["science"]
readme = "README.md"
rust-version = "1.85"

[dependencies]
polars = { version = "0.45", optional = true, default-features = false, features = ["dtype-full"] }

[build-dependencies]
bindgen = "0.72.0"
ureq = "2.0"
Expand All @@ -19,6 +22,7 @@ zip = "0.6"

[features]
gpu = []
polars = ["dep:polars"]

[[example]]
name = "basic_usage"
Expand Down
Loading