diff --git a/.github/workflows/_job_uv_audit.yaml b/.github/workflows/_job_uv_audit.yaml new file mode 100644 index 000000000..9c1cd4b00 --- /dev/null +++ b/.github/workflows/_job_uv_audit.yaml @@ -0,0 +1,175 @@ +# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# +# SPDX-License-Identifier: MPL-2.0 + +name: uv Audit and Targeted Upgrade +on: + workflow_call: + inputs: + force: + description: "Skip the manual-edit guard and overwrite the automated branch/PR." + type: boolean + required: false + default: false + rebase: + description: "Skip the manual-edit guard and replay manual commits on top of a freshly regenerated fix, instead of discarding them." + type: boolean + required: false + default: false + secrets: + token: + description: "Token used to push the fix branch and open the PR. Defaults to GITHUB_TOKEN." + required: false + +jobs: + uv-audit: + name: uv Audit + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write + steps: + - name: Checkout # Must be done before using composite actions + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + show-progress: false # very verbose for not much + token: ${{ secrets.token || secrets.GITHUB_TOKEN }} + + - name: Install uv + uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0 + with: + activate-environment: true + enable-cache: true + + - name: Run uv audit + id: audit + shell: bash + run: | + uv audit --preview-features audit,json-output --output-format json > uv_audit_report.json || true + + UPGRADE=$(jq -r '[.vulnerabilities[]? | select(.fix_versions | length > 0) | .dependency.name] | unique | .[]' uv_audit_report.json) + NO_FIX=$(jq -r '[.vulnerabilities[]? | select(.fix_versions | length == 0) | .dependency.name] | unique | .[]' uv_audit_report.json) + + echo "Upgrade: $UPGRADE" + echo "No fix available: $NO_FIX" + + if [ -z "$UPGRADE" ]; then + echo "No packages to upgrade." + echo "changed=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + + ARGS=() + for pkg in $UPGRADE; do + ARGS+=(--upgrade-package "$pkg") + done + + uv lock "${ARGS[@]}" 2>&1 | tee uv_audit_upgrade.txt + + if git diff --quiet -- uv.lock; then + echo "changed=false" >> "$GITHUB_OUTPUT" + else + echo "changed=true" >> "$GITHUB_OUTPUT" + fi + + - name: Check for manual edits on automated branch + id: guard + if: steps.audit.outputs.changed == 'true' + run: | + # Fixed branch name: reused/force-pushed each run so we update one PR instead of piling up new ones. + BRANCH="automated/uv-audit-fix" + echo "BRANCH=$BRANCH" >> "$GITHUB_ENV" + + if [ "${{ inputs.force }}" = "true" ] || [ "${{ inputs.rebase }}" = "true" ]; then + echo "Force/rebase requested; skipping the manual-edit check." + echo "skip=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + + git fetch origin "$BRANCH" 2>/dev/null || true + + if git rev-parse --verify "origin/$BRANCH" >/dev/null 2>&1; then + LAST_AUTHOR=$(git log -1 --format='%ae' "origin/$BRANCH") + if [ "$LAST_AUTHOR" != "github-actions[bot]@users.noreply.github.com" ]; then + echo "::warning::$BRANCH was manually edited (last commit by $LAST_AUTHOR); skipping automated update so the manual changes aren't overwritten. Merge or close the PR to resume automation." + echo "skip=true" >> "$GITHUB_OUTPUT" + exit 0 + fi + fi + + echo "skip=false" >> "$GITHUB_OUTPUT" + + - name: Build PR body + if: steps.audit.outputs.changed == 'true' && steps.guard.outputs.skip != 'true' + run: | + { + echo "## uv audit — targeted dependency fixes" + if [ -f uv_audit_upgrade.txt ]; then + echo "### uv lock output" + echo '```' + cat uv_audit_upgrade.txt + echo '```' + fi + echo "### Commands" + echo "This PR is regenerated automatically and safe to edit — if you push a commit here, automation pauses so your changes aren't overwritten. Comment \`/uv-audit rebase\` to replay your edits on top of a refreshed fix, or \`/uv-audit recreate\` to discard your edits and let automation take over again." + } > pr_body.md + + - name: Commit and push branch + if: steps.audit.outputs.changed == 'true' && steps.guard.outputs.skip != 'true' && inputs.rebase != true + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git checkout -B "$BRANCH" + git add uv.lock + git commit -m "deps(security): patch vulnerable packages found by uv audit" + git push --force origin "$BRANCH" + + - name: Rebase manual edits onto refreshed fix + if: steps.audit.outputs.changed == 'true' && steps.guard.outputs.skip != 'true' && inputs.rebase == true + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + + # The default checkout is shallow; rebase needs the branch's full history. + git fetch --unshallow origin 2>/dev/null || git fetch origin + git fetch origin "$BRANCH" + + git checkout -b uv-audit-fix-new + git add uv.lock + git commit -m "deps(security): patch vulnerable packages found by uv audit" + NEW_COMMIT=$(git rev-parse HEAD) + + OLD_COMMIT=$(git log "origin/$BRANCH" --author='github-actions[bot]' -1 --format=%H) + if [ -z "$OLD_COMMIT" ]; then + echo "::error::No prior automated commit found on $BRANCH to rebase onto. Comment /uv-audit recreate instead." + exit 1 + fi + + git checkout -B "$BRANCH" "origin/$BRANCH" + if ! git rebase --onto "$NEW_COMMIT" "$OLD_COMMIT" "$BRANCH"; then + git rebase --abort + echo "::error::Rebase failed: your edits conflict with the refreshed fix. Resolve manually, or comment /uv-audit recreate to discard your edits." + exit 1 + fi + + git push --force origin "$BRANCH" + + - name: Create pull request + if: steps.audit.outputs.changed == 'true' && steps.guard.outputs.skip != 'true' + env: + GH_TOKEN: ${{ secrets.token || secrets.GITHUB_TOKEN }} + run: | + PR_NUMBER=$(gh pr list --head "$BRANCH" --state open --json number --jq '.[0].number // empty') + + if [ -n "$PR_NUMBER" ]; then + gh pr edit "$PR_NUMBER" \ + --title "deps(security): uv audit targeted dependency fixes" \ + --body-file pr_body.md + else + gh pr create \ + --title "deps(security): uv audit targeted dependency fixes" \ + --body-file pr_body.md \ + --base "${{ github.ref_name }}" \ + --head "$BRANCH" \ + --label dependencies + fi diff --git a/.github/workflows/uv-audit-rebase.yaml b/.github/workflows/uv-audit-rebase.yaml new file mode 100644 index 000000000..173fdd258 --- /dev/null +++ b/.github/workflows/uv-audit-rebase.yaml @@ -0,0 +1,51 @@ +# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# +# SPDX-License-Identifier: MPL-2.0 + +name: uv Audit Rebase + +on: + issue_comment: + types: [created] + +permissions: + contents: write + pull-requests: write + +jobs: + rebase: + name: Resume uv audit automation + # Only maintainers can trigger this, since it force-pushes and re-runs a job with write access. + if: > + github.event.issue.pull_request != null && + contains(github.event.comment.body, '/uv-audit rebase') && + contains(fromJSON('["OWNER", "MEMBER", "COLLABORATOR"]'), github.event.comment.author_association) + runs-on: ubuntu-latest + permissions: + pull-requests: read + issues: write + steps: + - name: Verify comment targets the automated branch + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + HEAD_REF=$(gh pr view "${{ github.event.issue.number }}" --repo "${{ github.repository }}" --json headRefName --jq .headRefName) + if [ "$HEAD_REF" != "automated/uv-audit-fix" ]; then + echo "::error::/uv-audit rebase only works on the automated uv-audit PR (head ref was '$HEAD_REF')." + exit 1 + fi + + - name: Acknowledge + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + gh api "repos/${{ github.repository }}/issues/comments/${{ github.event.comment.id }}/reactions" \ + -f content='+1' --silent + + uv-audit: + name: uv Audit + needs: rebase + uses: ./.github/workflows/_job_uv_audit.yaml + with: + rebase: true + secrets: inherit diff --git a/.github/workflows/uv-audit-recreate.yaml b/.github/workflows/uv-audit-recreate.yaml new file mode 100644 index 000000000..9a4dbc009 --- /dev/null +++ b/.github/workflows/uv-audit-recreate.yaml @@ -0,0 +1,51 @@ +# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# +# SPDX-License-Identifier: MPL-2.0 + +name: uv Audit Recreate + +on: + issue_comment: + types: [created] + +permissions: + contents: write + pull-requests: write + +jobs: + recreate: + name: Resume uv audit automation + # Only maintainers can trigger this, since it force-pushes and re-runs a job with write access. + if: > + github.event.issue.pull_request != null && + contains(github.event.comment.body, '/uv-audit recreate') && + contains(fromJSON('["OWNER", "MEMBER", "COLLABORATOR"]'), github.event.comment.author_association) + runs-on: ubuntu-latest + permissions: + pull-requests: read + issues: write + steps: + - name: Verify comment targets the automated branch + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + HEAD_REF=$(gh pr view "${{ github.event.issue.number }}" --repo "${{ github.repository }}" --json headRefName --jq .headRefName) + if [ "$HEAD_REF" != "automated/uv-audit-fix" ]; then + echo "::error::/uv-audit recreate only works on the automated uv-audit PR (head ref was '$HEAD_REF')." + exit 1 + fi + + - name: Acknowledge + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + gh api "repos/${{ github.repository }}/issues/comments/${{ github.event.comment.id }}/reactions" \ + -f content='+1' --silent + + uv-audit: + name: uv Audit + needs: recreate + uses: ./.github/workflows/_job_uv_audit.yaml + with: + force: true + secrets: inherit diff --git a/.github/workflows/uv-audit.yaml b/.github/workflows/uv-audit.yaml new file mode 100644 index 000000000..fed351025 --- /dev/null +++ b/.github/workflows/uv-audit.yaml @@ -0,0 +1,23 @@ +# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project +# +# SPDX-License-Identifier: MPL-2.0 + +name: uv Audit + +on: + schedule: + - cron: '0 6 * * *' # Every day at 06:00 UTC + workflow_dispatch: + push: # TEMPORARY: lets us trigger real runs from this PR branch, since workflow_dispatch only works once merged to the default branch. Remove before merging. + branches: + - ci/uv-audit-workflow + +permissions: + contents: write + pull-requests: write + +jobs: + uv-audit: + name: uv Audit + uses: ./.github/workflows/_job_uv_audit.yaml + secrets: inherit diff --git a/uv.lock b/uv.lock index 9e610753c..d4309d6ec 100644 --- a/uv.lock +++ b/uv.lock @@ -2154,14 +2154,14 @@ wheels = [ [[package]] name = "holidays" -version = "0.104" +version = "0.105" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "python-dateutil" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/71/23/9d0f6c31dda49b50efecdac7b76d979fa37b6267e55bab4907a58f02e8ac/holidays-0.104.tar.gz", hash = "sha256:7e327b255dee3de3c8f7fef58f835ccb7685881ea72176697ff60269c57743b2", size = 1021650, upload-time = "2026-09-07T17:49:30.506Z" } +sdist = { url = "https://files.pythonhosted.org/packages/5b/b7/b0563bf091ff8089a8a4299068236d277c299e13b5809afeaa99b0c11280/holidays-0.105.tar.gz", hash = "sha256:fc9abc0c187b62e955f92aa12dbe7ed1998cc94712f295ec9244db788594d662", size = 1039659, upload-time = "2026-09-21T21:32:01.423Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/63/63/e6d7af3e7cd7ce095aee210204fce64b2ce5164c0c3c79d70e335837886c/holidays-0.104-py3-none-any.whl", hash = "sha256:e0db9ff1d5588e3349bf2de6a597421e39cb7299b64a015170a0a0886940c384", size = 1619592, upload-time = "2026-09-07T17:49:28.243Z" }, + { url = "https://files.pythonhosted.org/packages/9e/d0/dbc6c50f190ed0c32e4ebca037bff252280b0707049ee95825f4e8c3cc19/holidays-0.105-py3-none-any.whl", hash = "sha256:7fbd87770f1128eb24514fbb4c3ec6afa1c72d65c346713dbc13c2d0297df481", size = 1637790, upload-time = "2026-09-21T21:31:59.345Z" }, ] [[package]] @@ -2687,7 +2687,7 @@ wheels = [ [[package]] name = "jupyter-server" -version = "2.20.0" +version = "2.21.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, @@ -2709,9 +2709,9 @@ dependencies = [ { name = "traitlets" }, { name = "websocket-client" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/6b/dc/db3a582633170186f8c8b31298d7eb26ad0eb031a1f53476c258b64eed05/jupyter_server-2.20.0.tar.gz", hash = "sha256:b5778ba337d8015a3dc2b80803ecdd5ac18d3797fddf61a50ea5fb472b4ebe14", size = 756523, upload-time = "2026-06-17T12:09:09.435Z" } +sdist = { url = "https://files.pythonhosted.org/packages/65/3d/3c9f8bce5d5448107bd285cd80185e76c9c871d076b580dfd90997e52904/jupyter_server-2.21.1.tar.gz", hash = "sha256:a8960aa29263f6041283e97d4756b099fb49b767baab6371891ecb1bd40a63df", size = 761651, upload-time = "2026-09-15T16:00:07.853Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f3/71/8c002223e873a870f5c41dc69b0a7c922301123e4a31d5d01ecb700aef77/jupyter_server-2.20.0-py3-none-any.whl", hash = "sha256:c3b67c93c471e947c18b5026f04f21614218adb706df8f48227d3ee8e0a7cdcc", size = 393143, upload-time = "2026-06-17T12:09:07.234Z" }, + { url = "https://files.pythonhosted.org/packages/1d/39/91bc08650cc8e3efeb7a83c25e20ebddcc9b70d28b4eb6f51a4fb55da7a4/jupyter_server-2.21.1-py3-none-any.whl", hash = "sha256:2a6467606af7dbae2e7e31640030025969e15db6a649eae334af90415dc71dca", size = 394641, upload-time = "2026-09-15T16:00:05.526Z" }, ] [[package]]