diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 452301c..5085408 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -11,3 +11,16 @@ updates: - "*" labels: - "github-actions" + - "chore" + + # Maintain the pinned dependencies of the Python tools + - package-ecosystem: "pip" + directory: "/" + schedule: + interval: "monthly" + groups: + python: + patterns: + - "*" + labels: + - "chore" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e4e739b..c676099 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,10 +15,32 @@ concurrency: cancel-in-progress: true jobs: + test-stale-wheels: + name: "test stale wheel check" + # No secrets needed, so this one can also run on forks + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Install the latest version of uv + uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 + with: + python-version: '3.14' + + - name: Run the tests + run: uv run --with-requirements requirements.txt tests/test_check_stale_wheels.py + test: name: "test upload via action" runs-on: ubuntu-latest if: github.repository == 'scientific-python/upload-nightly-action' + # Pull requests from forks and from Dependabot are not given secrets, so there + # they check that the action refuses to run rather than really uploading + env: + HAS_TOKEN: ${{ secrets.UPLOAD_TOKEN != '' }} + outputs: + uploaded: ${{ steps.record.outputs.uploaded }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -47,19 +69,41 @@ jobs: - name: List contents of wheel run: python -m zipfile --list dist/test_package-*.whl + - name: Test that an upload without a token is refused + id: no_token + if: env.HAS_TOKEN != 'true' + continue-on-error: true + uses: ./_action_path/ + with: + artifacts_path: dist + anaconda_nightly_upload_token: '' + + - name: Check that the upload was refused + if: env.HAS_TOKEN != 'true' + env: + OUTCOME: ${{ steps.no_token.outcome }} + run: | + if [ "${OUTCOME}" != "failure" ]; then + echo "::error::the action did not refuse to upload without a token" + exit 1 + fi + - name: Test upload + if: env.HAS_TOKEN == 'true' uses: ./_action_path/ with: artifacts_path: dist anaconda_nightly_upload_token: ${{ secrets.UPLOAD_TOKEN }} - name: Test upload that forces removal first + if: env.HAS_TOKEN == 'true' uses: ./_action_path/ with: artifacts_path: dist anaconda_nightly_upload_token: ${{ secrets.UPLOAD_TOKEN }} - name: Build v0.0.2 wheel and sdist + if: env.HAS_TOKEN == 'true' run: | # Bump version to avoid wheel name conflicts sed -i 's/0.0.1/0.0.2/g' _action_path/tests/test_package/pyproject.toml @@ -67,6 +111,7 @@ jobs: uv tool run --from build pyproject-build --installer=uv --outdir ./dist _action_path/tests/test_package - name: Test upload with non-main label + if: env.HAS_TOKEN == 'true' uses: ./_action_path/ with: artifacts_path: dist @@ -74,6 +119,7 @@ jobs: anaconda_nightly_upload_labels: test - name: Build v0.0.3 wheel and sdist + if: env.HAS_TOKEN == 'true' run: | # Bump version to avoid wheel name conflicts sed -i 's/0.0.2/0.0.3/g' _action_path/tests/test_package/pyproject.toml @@ -81,15 +127,22 @@ jobs: uv tool run --from build pyproject-build --installer=uv --outdir ./dist _action_path/tests/test_package - name: Test upload with multiple labels + if: env.HAS_TOKEN == 'true' uses: ./_action_path/ with: artifacts_path: dist anaconda_nightly_upload_token: ${{ secrets.UPLOAD_TOKEN }} anaconda_nightly_upload_labels: dev,test + - name: Record whether anything was uploaded + id: record + run: echo "uploaded=${HAS_TOKEN}" | tee -a "${GITHUB_OUTPUT}" + cleanup: runs-on: ubuntu-latest needs: [test] + # Nothing to clean up when the uploads were skipped for lack of a token + if: needs.test.outputs.uploaded == 'true' # Set required workflow secrets in the environment for additional security # https://github.com/scientific-python/upload-nightly-action/settings/environments environment: @@ -107,3 +160,12 @@ jobs: anaconda --token ${{ secrets.ANACONDA_TOKEN }} remove \ --force \ "scientific-python-nightly-wheels/test-package" + + report-failure: + needs: [test-stale-wheels, test, cleanup] + if: failure() && github.event_name == 'schedule' + permissions: + issues: write + uses: ./.github/workflows/report-failure.yml + with: + title: 'The scheduled CI run is failing' diff --git a/.github/workflows/keep-alive.yml b/.github/workflows/keep-alive.yml index 9704f6e..e30d7e6 100644 --- a/.github/workflows/keep-alive.yml +++ b/.github/workflows/keep-alive.yml @@ -36,3 +36,12 @@ jobs: user_name: 'github-actions[bot]' user_email: 'github-actions[bot]@users.noreply.github.com' commit_message: Keep repo GitHub Actions alive + + report-failure: + needs: [deploy] + if: failure() && github.event_name == 'schedule' + permissions: + issues: write + uses: ./.github/workflows/report-failure.yml + with: + title: 'The workflow keep-alive is failing' diff --git a/.github/workflows/remove-wheels.yml b/.github/workflows/remove-wheels.yml index 00af0f7..be6fb6f 100644 --- a/.github/workflows/remove-wheels.yml +++ b/.github/workflows/remove-wheels.yml @@ -109,3 +109,12 @@ jobs: done fi + + report-failure: + needs: [remove] + if: failure() && github.event_name == 'schedule' + permissions: + issues: write + uses: ./.github/workflows/report-failure.yml + with: + title: 'The old wheel cleanup is failing' diff --git a/.github/workflows/report-failure.yml b/.github/workflows/report-failure.yml new file mode 100644 index 0000000..a977e24 --- /dev/null +++ b/.github/workflows/report-failure.yml @@ -0,0 +1,46 @@ +name: Report a failing workflow + +# Called by the scheduled workflows in this repository when they fail. A cron job +# that quietly stops working can go unnoticed for months, which is the very thing +# this repository nags other projects about. + +on: + workflow_call: + inputs: + title: + description: 'Title of the issue to open, and to reuse while it stays open' + required: true + type: string + +jobs: + report: + + runs-on: ubuntu-latest + permissions: + issues: write + + steps: + - name: Open or update an issue + env: + GH_TOKEN: ${{ github.token }} + TITLE: ${{ inputs.title }} + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + run: | + body="\`${GITHUB_WORKFLOW}\` failed, most recently in [this run](${RUN_URL}). + + Until it is fixed, whatever that workflow keeps an eye on is unattended." + + # Only ever match our own issues, so the default page of 30 cannot hide the + # one we are looking for. Without --search this is the REST creator filter, + # which wants the literal login: "app/github-actions" matches nothing here. + number=$(gh issue list --repo "${GITHUB_REPOSITORY}" --state open \ + --author "github-actions[bot]" --json number,title \ + --jq 'map(select(.title == env.TITLE)) | .[0].number // ""') + + # Rewrite rather than comment, so a workflow that fails every day does not + # collect a comment a day + if [ -n "${number}" ]; then + gh issue edit "${number}" --repo "${GITHUB_REPOSITORY}" --body "${body}" + else + gh issue create --repo "${GITHUB_REPOSITORY}" --title "${TITLE}" --body "${body}" + fi diff --git a/.github/workflows/stale-wheels.yml b/.github/workflows/stale-wheels.yml new file mode 100644 index 0000000..4e6bfa4 --- /dev/null +++ b/.github/workflows/stale-wheels.yml @@ -0,0 +1,49 @@ +name: Report stale wheels + +on: + # Run daily at 0:23 UTC, an hour before "Remove old wheels" + schedule: + - cron: '23 0 * * *' + workflow_dispatch: + inputs: + dry_run: + description: 'Report findings without opening or closing any issues' + type: boolean + default: true + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + report: + + runs-on: ubuntu-latest + if: github.repository_owner == 'scientific-python' + # Needed to open an issue here if we cannot reach a project's own tracker + permissions: + issues: write + + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Install the latest version of uv + uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 + with: + python-version: '3.14' + + - name: Check for stale wheels + env: + ISSUE_OPENER_TOKEN: ${{ secrets.ISSUE_OPENER_TOKEN }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + uv run --with-requirements requirements.txt tools/check_stale_wheels.py ${{ inputs.dry_run && '--dry-run' || '' }} + + report-failure: + needs: [report] + if: failure() && github.event_name == 'schedule' + permissions: + issues: write + uses: ./.github/workflows/report-failure.yml + with: + title: 'The stale nightly wheel check is failing' diff --git a/.gitignore b/.gitignore index 096b5eb..3cf3cb8 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ # pixi environments .pixi *.egg-info +__pycache__ +.pytest_cache diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..ffb298c --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,61 @@ +# Notes for agents + +Guidance for automated contributors working in this repository. +Written for someone who can read the code but has no way to know the conventions or the reasoning behind them. + +## What is here + +`action.yml` and `upload_wheels.sh` are the published action itself, which projects call from their own workflows to upload nightly wheels. +Everything else exists to look after the [`scientific-python-nightly-wheels`](https://anaconda.org/scientific-python-nightly-wheels) channel those uploads land on. + +`.github/workflows/remove-wheels.yml` deletes wheels beyond the retention policy, daily at 01:23 UTC. +`tools/check_stale_wheels.py` warns projects before that deletion strands them, daily at 00:23 UTC. +The hour between the two is deliberate: a package must be flagged before it can be deleted, and a fully deleted package disappears from the channel listing where we would no longer see it at all. + +`.github/workflows/report-failure.yml` is a reusable workflow every scheduled workflow calls when it fails. +This repository nags other projects about silently broken automation, so its own cron jobs must not fail quietly. +It is called as a job rather than used as a composite action because `ci.yml` checks out to `_action_path` and `remove-wheels.yml` does not check out at all. + +## Conventions + +Pin third-party actions to a full commit SHA with a `# vX.Y.Z` comment; Dependabot updates them monthly as a single group. + +Run the Python tools with `uv run --with-requirements requirements.txt