diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..7d1cd21 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,33 @@ +version: 2 + +updates: + - package-ecosystem: github-actions + directory: "/" + schedule: + interval: weekly + day: monday + commit-message: + prefix: "ci" + groups: + actions: + patterns: ["*"] + + - package-ecosystem: pip + directory: "/" + schedule: + interval: weekly + day: monday + commit-message: + prefix: "deps" + # torch, numpy and scipy are pinned by a lower bound on purpose: it says + # which versions a user may already have, not which version CI runs. pip + # installs the newest match either way, so raising the floor buys nothing + # and locks users out. + ignore: + - dependency-name: torch + - dependency-name: numpy + - dependency-name: scipy + groups: + dev-tooling: + patterns: ["ruff", "mypy", "pytest*"] + open-pull-requests-limit: 5 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 83256db..4931fc7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -85,3 +85,22 @@ jobs: stadion run "$task" --agent "$agent" --instances 10 --episodes 8 done done + + ci: + name: CI + runs-on: ubuntu-latest + if: always() + needs: [lint-and-test, verify-optima, reference-controls] + steps: + # One aggregate check to require in branch protection. The matrix jobs + # carry their operating system and Python version in the name, so every + # one of them is a separate context that changes the moment the matrix + # does - and a required check that silently stops matching is a required + # check that stopped requiring anything. + - name: Fail if any job did not succeed + if: contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled') + run: | + echo "One or more jobs failed:" + echo '${{ toJSON(needs) }}' + exit 1 + - run: echo "All checks passed." diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 0000000..14c5ab2 --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,41 @@ +name: CodeQL + +on: + push: + branches: [main] + pull_request: + branches: [main] + schedule: + # Weekly, because advisories land between pushes: a rule added after the + # last commit would otherwise never run against this code. + - cron: "17 4 * * 1" + workflow_dispatch: + +permissions: + contents: read + +jobs: + analyze: + name: Analyze Python + runs-on: ubuntu-latest + # Code scanning on a private repository needs GitHub Advanced Security. + # Skipped rather than left permanently red, and it starts running by itself + # if the repository ever goes private and comes back. + if: github.event.repository.visibility == 'public' + permissions: + security-events: write + actions: read + contents: read + steps: + - uses: actions/checkout@v7 + + - uses: github/codeql-action/init@v4 + with: + languages: python + # security-and-quality over the default: this service parses untrusted + # documents and serves HTTP, so the extra checks earn their noise. + queries: security-and-quality + + - uses: github/codeql-action/analyze@v4 + with: + category: "/language:python" diff --git a/.github/workflows/dependabot-auto-merge.yml b/.github/workflows/dependabot-auto-merge.yml new file mode 100644 index 0000000..813d67d --- /dev/null +++ b/.github/workflows/dependabot-auto-merge.yml @@ -0,0 +1,115 @@ +name: Dependabot auto-merge + +# What this does and does not do: +# +# auto-merged - GitHub Actions bumps, and patch bumps of anything else +# left for you - anything with a minor or major in it +# +# A patch release and an action bump are the updates that pile up unread until +# the queue is too long to review honestly. A minor bump can change behaviour, +# so it keeps a human. Auto-merge is queued, not immediate: GitHub still waits +# for the required checks to pass, and a red build leaves the PR open. +# +# Dependabot watches actions and pip here. The file is kept identical to the +# other repositories so they stay comparable. + +on: pull_request_target + +permissions: + contents: read + +jobs: + auto-merge: + # Who opened the pull request, not who triggered the event. `github.actor` + # is whoever caused this run, so the moment a person touches a Dependabot + # pull request - reopening it, or nudging it after a base change - the job + # skips and the update sits there looking merged-ready and never merging. + # The author never changes, which is the thing actually being asserted. + if: github.event.pull_request.user.login == 'dependabot[bot]' + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write + steps: + # Reads the update metadata from the PR that Dependabot opened. Nothing + # from the branch is checked out or executed, which is what makes + # pull_request_target safe to use here. + - id: metadata + uses: dependabot/fetch-metadata@v3 + + - id: verdict + name: Decide whether this one can merge itself + env: + ECOSYSTEM: ${{ steps.metadata.outputs.package-ecosystem }} + UPDATE_TYPE: ${{ steps.metadata.outputs.update-type }} + UPDATED: ${{ steps.metadata.outputs.updated-dependencies-json }} + # A grouped pull request has no single update type: fetch-metadata + # leaves `update-type` empty and puts one entry per dependency in + # `updated-dependencies-json`. Reading only `update-type` sent every + # grouped bump to a human, including a group where all seven were + # patches - which is exactly the group worth merging unattended, and the + # reason the groups exist at all. + run: | + PATCH="version-update:semver-patch" + + if [ "$ECOSYSTEM" = "github_actions" ]; then + echo "auto=true" >> "$GITHUB_OUTPUT" + echo "reason=an actions bump" >> "$GITHUB_OUTPUT" + exit 0 + fi + + if [ -n "$UPDATE_TYPE" ] && [ "$UPDATE_TYPE" != "null" ]; then + if [ "$UPDATE_TYPE" = "$PATCH" ]; then + echo "auto=true" >> "$GITHUB_OUTPUT" + echo "reason=a patch bump" >> "$GITHUB_OUTPUT" + else + echo "auto=false" >> "$GITHUB_OUTPUT" + echo "reason=${UPDATE_TYPE#version-update:semver-} is not a patch" >> "$GITHUB_OUTPUT" + fi + exit 0 + fi + + total=$(jq 'length' <<<"$UPDATED") + if [ "$total" -eq 0 ]; then + # No metadata to read. Refusing is the only safe reading of silence. + echo "auto=false" >> "$GITHUB_OUTPUT" + echo "reason=no update metadata to read" >> "$GITHUB_OUTPUT" + exit 0 + fi + + patches=$(jq --arg p "$PATCH" '[.[] | select(.updateType == $p)] | length' <<<"$UPDATED") + if [ "$total" -eq "$patches" ]; then + echo "auto=true" >> "$GITHUB_OUTPUT" + echo "reason=a group of $total, every one a patch" >> "$GITHUB_OUTPUT" + else + echo "auto=false" >> "$GITHUB_OUTPUT" + echo "reason=a group of $total, $((total - patches)) beyond patch" >> "$GITHUB_OUTPUT" + fi + + - name: Queue the merge + if: steps.verdict.outputs.auto == 'true' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PR: ${{ github.event.pull_request.html_url }} + REASON: ${{ steps.verdict.outputs.reason }} + # No `gh pr review --approve`. This organisation does not permit Actions + # to approve pull requests, so that call fails with + # + # GitHub Actions is not permitted to approve pull requests + # + # and under `bash -e` it took the whole step down before the merge was + # ever queued - which is how auto-merge came to be broken in every + # repository at once. + # + # The approval was never needed: branch protection here requires the CI + # check and no reviews. If a review requirement is ever added, this needs + # a token that is not GITHUB_TOKEN, not a retry. + run: | + echo "auto-merging: $REASON" + gh pr merge --auto --squash "$PR" + + - name: Explain why this one was left alone + if: steps.verdict.outputs.auto != 'true' + env: + REASON: ${{ steps.verdict.outputs.reason }} + run: echo "not auto-merged ($REASON); this pull request needs a human."