# Ultralytics 🚀 AGPL-3.0 License - https://ultralytics.com/license name: "Ultralytics Actions" author: "Ultralytics" description: "Optimize code and docs with official Ultralytics Actions for syntax, spelling, and link checks." branding: icon: "code" color: "blue" inputs: token: description: "GitHub token" required: true labels: description: "Run issue and PR auto-labeling with first responses" required: false default: "false" python: description: "Run Python formatting" required: false default: "false" python-version: description: "Python version to set up before running Python tooling (empty = runner default)" required: false default: "" python_docstrings: description: "Run Python docstring formatting" required: false default: "true" markdown: description: "Run Markdown formatting (deprecated in favor of prettier)" required: false default: "false" biome: description: "Run Biome formatting" required: false default: "false" prettier: description: "Run Prettier formatting for JavaScript, JSX, Angular, Vue, Flow, TypeScript, CSS, HTML, JSON, GraphQL, Markdown, YAML" required: false default: "false" swift: description: "Run Swift formatting" required: false default: "false" dart: description: "Run Dart formatting" required: false default: "false" spelling: description: "Run Spelling checks" required: false default: "false" links: description: "Run Broken Links checks" required: false default: "false" summary: description: "Run PR Summary" required: false default: "false" review: description: "Run PR Review" required: false default: "true" header: description: "File header" required: false default: "true" brave_api_key: description: "Brave API Key" required: false openai_api_key: description: "OpenAI API Key" required: false anthropic_api_key: description: "Anthropic API Key" required: false model: description: "AI Model - defaults to gpt-5.6-luna or claude-sonnet-5 based on API key" required: false review_model: description: "AI Model for PR reviews (optional, defaults to gpt-5.6-luna or claude-opus-5 based on API key)" required: false first_issue_response: description: "Example response to a new issue" required: false bun_update: description: "Run bun update to update bun.lock if bun project detected" required: false default: "false" github_username: description: "GitHub username for commits" required: false default: "UltralyticsAssistant" # default: "github-actions[bot]" github_email: description: "GitHub email for commits" required: false default: "web@ultralytics.com" # default: "41898282+github-actions[bot]@users.noreply.github.com" runs: using: "composite" steps: - name: Setup Python if: inputs.python == 'true' && inputs.python-version != '' uses: actions/setup-python@v7 with: python-version: ${{ inputs.python-version }} - uses: ultralytics/actions/setup-uv@main with: ignore-empty-workdir: true - name: Install Dependencies # Note tomli required for codespell with pyproject.toml env: INPUTS_SPELLING: ${{ inputs.spelling }} ACTION_PATH: ${{ github.action_path }} run: | echo "::group::Install Dependencies" trap 'echo "::endgroup::"' EXIT package_dir=$(mktemp -d "$RUNNER_TEMP/ultralytics-actions.XXXXXX") uv build --wheel "$ACTION_PATH" --out-dir "$package_dir" packages=("$package_dir"/*.whl) if [ "$INPUTS_SPELLING" = "true" ]; then packages+=(codespell tomli) fi # On macOS, don't use sudo as it can cause environment issues if [ "$(uname)" = "Darwin" ]; then uv pip install --system --break-system-packages "${packages[@]}" else sudo env "PATH=$PATH" uv pip install --system --break-system-packages "${packages[@]}" fi ultralytics-actions-info shell: bash # Checkout Repository ---------------------------------------------------------------------------------------------- - name: Checkout Repository if: github.event_name == 'pull_request' && (github.event.action == 'opened' || github.event.action == 'synchronize' || github.event.action == 'review_requested') uses: actions/checkout@v7 with: repository: ${{ github.event.pull_request.head.repo.full_name }} token: ${{ inputs.token }} ref: ${{ github.event.pull_request.head.sha }} fetch-depth: 2 # File header ------------------------------------------------------------------------------------------------------ - name: File Header if: inputs.header != 'false' && github.event_name == 'pull_request' && (github.event.action == 'opened' || github.event.action == 'synchronize') env: HEADER: ${{ inputs.header }} run: | echo "::group::File Header" trap 'echo "::endgroup::"' EXIT ultralytics-actions-headers shell: bash continue-on-error: false # Python formatting ------------------------------------------------------------------------------------------------ # Ignores the following Docs rules to match Google-style docstrings: # D100: Missing docstring in public module # D104: Missing docstring in public package # D203: 1 blank line required before class docstring # D205: 1 blank line required between summary line and description # D212: Multi-line docstring summary should start at the first line # D213: Multi-line docstring summary should start at the second line # D401: First line of docstring should be in imperative mood # D406: Section name should end with a newline # D407: Missing dashed underline after section # D413: Missing blank line after last section # --target-version is Python 3.8 for --extend-select UP (pyupgrade) - name: Run Python if: github.event_name == 'pull_request' && inputs.python == 'true' && (github.event.action == 'opened' || github.event.action == 'synchronize') run: | echo "::group::Run Python" trap 'echo "::endgroup::"' EXIT ruff check \ --fix \ --unsafe-fixes \ --extend-select F,I,D,UP,RUF,FA \ --target-version py38 \ --ignore BLE001,D100,D104,D203,D205,D212,D213,D401,D406,D407,D413,RUF001,RUF002,RUF012,S110 \ . || true ruff format \ --line-length 120 \ . || true if [ "${{ inputs.python_docstrings }}" = "true" ]; then ultralytics-actions-format-python-docstrings . || true fi shell: bash continue-on-error: true # Biome (JavaScript, TypeScript, JSON, JSX, TSX) ------------------------------------------------------------------ - name: Run Biome if: github.event_name == 'pull_request' && inputs.biome == 'true' && (github.event.action == 'opened' || github.event.action == 'synchronize') run: | echo "::group::Run Biome" trap 'echo "::endgroup::"' EXIT if [ -f "biome.json" ] || [ -f "biome.jsonc" ]; then npm install -g @biomejs/biome@latest npx biome --version npx biome check --write --line-width=120 --max-diagnostics=1000 --diagnostic-level=error --unsafe . else echo "⚠️ No biome.json found - skipping Biome formatting. Add biome.json to enable." fi shell: bash continue-on-error: true # Prettier (JavaScript, JSX, Angular, Vue, Flow, TypeScript, CSS, HTML, JSON, GraphQL, Markdown, YAML) ------------- - name: Run Prettier if: github.event_name == 'pull_request' && (inputs.prettier == 'true' || inputs.markdown == 'true') && (github.event.action == 'opened' || github.event.action == 'synchronize') env: INPUTS_BIOME: ${{ inputs.biome }} run: | echo "::group::Run Prettier" trap 'echo "::endgroup::"' EXIT npm install -g prettier@3.8.5 curl -fsSL "https://github.com/mvdan/sh/releases/download/v3.13.1/shfmt_v3.13.1_$(uname -s | tr '[:upper:]' '[:lower:]')_$(uname -m | sed 's/x86_64/amd64/;s/aarch64/arm64/')" -o "$(npm prefix -g)/bin/shfmt" chmod +x "$(npm prefix -g)/bin/shfmt" ultralytics-actions-update-markdown-code-blocks extensions="css,less,scss,yml,yaml,html,vue,svelte" [ "${INPUTS_BIOME:-false}" = "true" ] && { [ -f "biome.json" ] || [ -f "biome.jsonc" ]; } || extensions="js,jsx,ts,tsx,json,$extensions" npx prettier --write --list-different --print-width 120 "**/*.{$extensions}" '!**/*lock.{json,yaml,yml}' '!**/*.lock' '!**/model.json' '!**/*.min.js' '!**/*.min.css' # Handle Markdown separately find . -name "*.md" -type f ! -path "*/docs/*" -exec npx prettier --write --list-different --print-width 120 {} + if [ -d "./docs" ]; then find ./docs -name "*.md" -type f ! -path "*/reference/*" -exec npx prettier --tab-width 4 --print-width 120 --write --list-different {} + fi # Bash last: shfmt exits non-zero when a script fails to parse, which would otherwise skip the formatting above find . -name "*.sh" -type f -not -path "*/node_modules/*" -not -path "*/.git/*" -exec shfmt -i 2 -sr -bn -ci -l -w {} + shell: bash continue-on-error: true # Swift formatting ------------------------------------------------------------------------------------------------- - name: Run Swift Formatter if: github.event_name == 'pull_request' && inputs.swift == 'true' && (github.event.action == 'opened' || github.event.action == 'synchronize') run: | echo "::group::Run Swift Formatter" trap 'echo "::endgroup::"' EXIT brew install swift-format swift-format --in-place --recursive . shell: bash continue-on-error: true # Dart formatting -------------------------------------------------------------------------------------------------- # Flutter bundles Dart and resolves Flutter packages; without `pub get`, `dart format` cannot read the package # language version and silently falls back to the pre-3.7 short style. - name: Setup Flutter SDK if: github.event_name == 'pull_request' && inputs.dart == 'true' && (github.event.action == 'opened' || github.event.action == 'synchronize') uses: ultralytics/actions/retry@main with: retries: 2 timeout_minutes: 15 run: | echo "::group::Setup Flutter SDK" trap 'echo "::endgroup::"' EXIT rm -rf "$RUNNER_TEMP/flutter" git clone --depth 1 --branch stable https://github.com/flutter/flutter.git "$RUNNER_TEMP/flutter" echo "$RUNNER_TEMP/flutter/bin" >> "$GITHUB_PATH" - name: Run Dart Formatter if: github.event_name == 'pull_request' && inputs.dart == 'true' && (github.event.action == 'opened' || github.event.action == 'synchronize') run: | echo "::group::Run Dart Formatter" trap 'echo "::endgroup::"' EXIT ok=true while IFS= read -r -d '' pubspec; do (cd "$(dirname "$pubspec")" && flutter pub get) || ok=false done < <(find . -name pubspec.yaml -not -path '*/.*' -print0) $ok && dart format . || ok=false # never format with an unresolved language version git clean -fdq # formatting never creates files, so drop generated pub and Flutter metadata git ls-files -z '*pubspec.lock' | xargs -0 -r git checkout -- $ok shell: bash continue-on-error: true # Important so that commit step can pick up changes # Spelling --------------------------------------------------------------------------------------------------------- # --ignore-regex '\b[a-z]+[A-Z][a-zA-Z]*\b' ignores camelCase words (e.g. repoRes, dateA) to prevent false positives - name: Run Codespell if: github.event_name == 'pull_request' && inputs.spelling == 'true' && (github.event.action == 'opened' || github.event.action == 'synchronize') run: | echo "::group::Run Codespell" trap 'echo "::endgroup::"' EXIT codespell \ --builtin clear,informal,en-GB_to_en-US \ --write-changes \ --uri-ignore-words-list "*" \ --ignore-regex '\b[a-z]+[A-Z][a-zA-Z]*\b' \ --ignore-words-list "nin,cancelled,MapPin,couldn,grey,writeable,RepResNet,Idenfy,Smoot,EHR,ALS,Carmel,FPR,Hach,Calle,crate,nd,ned,strack,dota,ane,segway,fo,gool,winn,nam,afterall,skelton,goin,cann,CANN,tbe,dfine" \ --skip "*.pt,*.pth,*.torchscript,*.onnx,*.tflite,*.pb,*.bin,*.param,*.mlmodel,*.engine,*.npy,*.data*,*.csv,*pnnx*,*venv*,*translat*,*lock*,__pycache__*,*.ico,*.jpg,*.png,*.webp,*.avif,*.mp4,*.mov,/runs,/.git,./docs/??/*.md,./docs/mkdocs_??.yml,action.yml" shell: bash continue-on-error: true # PR Summary ------------------------------------------------------------------------------------------------------- - name: PR Summary if: (github.event_name == 'pull_request' || github.event_name == 'pull_request_target') && inputs.summary == 'true' && github.event.action == 'closed' env: GITHUB_TOKEN: ${{ inputs.token }} BRAVE_API_KEY: ${{ inputs.brave_api_key }} OPENAI_API_KEY: ${{ inputs.openai_api_key }} ANTHROPIC_API_KEY: ${{ inputs.anthropic_api_key }} MODEL: ${{ inputs.model }} run: | echo "::group::PR Summary" trap 'echo "::endgroup::"' EXIT ultralytics-actions-summarize-pr shell: bash continue-on-error: true # Bun Update --------------------------------------------------------------------------------------------------------- - name: Bun Update if: github.event_name == 'pull_request' && inputs.bun_update == 'true' && github.event.action == 'opened' run: | echo "::group::Bun Update" trap 'echo "::endgroup::"' EXIT if [ -f "bun.lock" ] || [ -f "bun.lockb" ]; then npm install -g bun echo "Updating root..." bun update --latest for pkg in apps/*/package.json packages/*/package.json; do [ -f "$pkg" ] || continue dir=$(dirname "$pkg") echo "Updating $dir..." (cd "$dir" && bun update --latest) done echo "Resolving bun.lock versions..." bun update else echo "No bun.lock found - skipping Bun update" fi shell: bash continue-on-error: true # Commit Changes --------------------------------------------------------------------------------------------------- - name: Commit and Push Changes # Generated PRs may be opened by github_username (for example analytics data updates) and still need one # Auto-format commit on the opened event. The recursion risk starts after that commit pushes a synchronize # event as github_username, so keep the bot guard only for synchronize events. if: | github.event_name == 'pull_request' && (github.event.action == 'opened' || github.event.action == 'synchronize') && (github.event.action == 'opened' || github.actor != inputs.github_username) && github.event.pull_request.head.repo.full_name == github.repository env: GITHUB_USERNAME: ${{ inputs.github_username }} GITHUB_EMAIL: ${{ inputs.github_email }} PR_HEAD_REF: ${{ github.event.pull_request.head.ref }} run: | echo "::group::Commit and Push Changes" trap 'echo "::endgroup::"' EXIT COMMIT_MSG="Auto-format by https://ultralytics.com/actions" git config user.name "$GITHUB_USERNAME" git config user.email "$GITHUB_EMAIL" git add . git reset HEAD -- .github/workflows/ # workflow changes are not permitted with default token if ! git diff --staged --quiet; then git commit -m "$COMMIT_MSG" git push origin "HEAD:refs/heads/${PR_HEAD_REF}" else echo "No changes to commit" fi shell: bash continue-on-error: true # PR automation and first interaction ------------------------------------------------------------------------------ - name: PR Automation and First Interaction if: | (inputs.labels == 'true' && (github.event.action == 'opened' || github.event.action == 'created')) || ((github.event_name == 'pull_request' || github.event_name == 'pull_request_target') && github.event.action == 'opened' && (inputs.summary == 'true' || inputs.review == 'true')) env: GITHUB_TOKEN: ${{ inputs.token }} FIRST_ISSUE_RESPONSE: ${{ inputs.first_issue_response }} BRAVE_API_KEY: ${{ inputs.brave_api_key }} OPENAI_API_KEY: ${{ inputs.openai_api_key }} ANTHROPIC_API_KEY: ${{ inputs.anthropic_api_key }} MODEL: ${{ inputs.model }} REVIEW_MODEL: ${{ inputs.review_model }} LABELS: ${{ inputs.labels }} SUMMARY: ${{ inputs.summary }} REVIEW: ${{ inputs.review }} run: | echo "::group::PR Automation and First Interaction" trap 'echo "::endgroup::"' EXIT ultralytics-actions-first-interaction shell: bash continue-on-error: true # PR Review -------------------------------------------------------------------------------------------------------- - name: PR Review if: | (github.event_name == 'pull_request' || github.event_name == 'pull_request_target') && github.event.action == 'review_requested' && github.event.requested_reviewer.login == inputs.github_username env: GITHUB_TOKEN: ${{ inputs.token }} OPENAI_API_KEY: ${{ inputs.openai_api_key }} ANTHROPIC_API_KEY: ${{ inputs.anthropic_api_key }} MODEL: ${{ inputs.model }} REVIEW_MODEL: ${{ inputs.review_model }} run: | echo "::group::PR Review" trap 'echo "::endgroup::"' EXIT ultralytics-actions-review-pr shell: bash continue-on-error: true # Broken links ----------------------------------------------------------------------------------------------------- # lychee-action only ships Linux binaries, so install lychee directly to support macOS runners (Swift repos) too. - name: Broken Link Checker if: inputs.links == 'true' && github.event_name == 'pull_request' && github.event.action != 'closed' env: GITHUB_TOKEN: ${{ inputs.token }} # Check all markdown and html files in repo. Ignores the following status codes to reduce false positives: # - 401(generic, "unauthorized") # - 403(OpenVINO, "forbidden") # - 429(Instagram, "too many requests") # - 500(Zenodo, "cached") # - 502(Zenodo, "bad gateway") # - 999(LinkedIn, "unknown status code") run: | echo "::group::Broken Link Checker" trap 'echo "::endgroup::"' EXIT case "$(uname -s)-$(uname -m)" in Linux-x86_64) target=x86_64-unknown-linux-gnu ;; Linux-aarch64) target=aarch64-unknown-linux-gnu ;; Darwin-arm64) target=aarch64-apple-darwin ;; Darwin-x86_64) target=x86_64-apple-darwin ;; esac mkdir -p "$RUNNER_TEMP/lychee" archive="lychee-${target}.tar.gz" url="https://github.com/lycheeverse/lychee/releases/latest/download/$archive" curl --retry 3 --retry-all-errors --retry-max-time 180 --connect-timeout 10 --max-time 60 -fsSL "$url" -o "$RUNNER_TEMP/lychee/$archive" curl --retry 3 --retry-all-errors --retry-max-time 180 --connect-timeout 10 --max-time 60 -fsSL "$url.sha256" -o "$RUNNER_TEMP/lychee/$archive.sha256" if command -v sha256sum >/dev/null; then (cd "$RUNNER_TEMP/lychee" && sha256sum -c "$archive.sha256") else (cd "$RUNNER_TEMP/lychee" && shasum -a 256 -c "$archive.sha256") fi tar -xzf "$RUNNER_TEMP/lychee/$archive" -C "$RUNNER_TEMP/lychee" "$(find "$RUNNER_TEMP/lychee" -type f -name lychee)" \ --scheme https \ --timeout 60 \ --insecure \ --accept 100..=103,200..=299,401,403,429,500,502,999 \ --exclude-all-private \ --exclude "https?://(www\.)?(github\.com|linkedin\.com|twitter\.com|instagram\.com|kaggle\.com|fonts\.gstatic\.com|url\.com)" \ "./**/*.md" \ "./**/*.html" shell: bash