diff --git a/.github/workflows/benchmark.yml b/.github/workflows/benchmark.yml index 4c35cd7ae087..c803546c78e7 100644 --- a/.github/workflows/benchmark.yml +++ b/.github/workflows/benchmark.yml @@ -17,8 +17,10 @@ jobs: with: repository: django/django-asv path: "." + persist-credentials: false - name: Setup Miniforge - uses: conda-incubator/setup-miniconda@v3 + # Pinned to v3.2.0. + uses: conda-incubator/setup-miniconda@835234971496cad1653abb28a638a281cf32541f with: miniforge-version: "24.1.2-0" activate-environment: asv-bench diff --git a/.github/workflows/check_commit_messages.yml b/.github/workflows/check_commit_messages.yml index 1a6d6d1958ca..ece0bcac4ee1 100644 --- a/.github/workflows/check_commit_messages.yml +++ b/.github/workflows/check_commit_messages.yml @@ -8,17 +8,23 @@ concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true +permissions: + contents: read + jobs: check-commit-prefix: if: startsWith(github.event.pull_request.base.ref, 'stable/') runs-on: ubuntu-latest steps: - uses: actions/checkout@v5 + with: + persist-credentials: false - name: Calculate commit prefix id: vars + env: + BASE: ${{ github.event.pull_request.base.ref }} run: | - BASE="${{ github.event.pull_request.base.ref }}" echo "BASE=$BASE" >> $GITHUB_ENV VERSION="${BASE#stable/}" echo "prefix=[$VERSION]" >> $GITHUB_OUTPUT @@ -26,8 +32,8 @@ jobs: - name: Check PR title prefix env: TITLE: ${{ github.event.pull_request.title }} + PREFIX: ${{ steps.vars.outputs.prefix }} run: | - PREFIX="${{ steps.vars.outputs.prefix }}" if [[ "$TITLE" != "$PREFIX"* ]]; then echo "❌ PR title must start with the required prefix: $PREFIX" exit 1 @@ -40,8 +46,9 @@ jobs: git fetch origin pull/${{ github.event.pull_request.number }}/head:pr - name: Check commit messages prefix + env: + PREFIX: ${{ steps.vars.outputs.prefix }} run: | - PREFIX="${{ steps.vars.outputs.prefix }}" COMMITS=$(git rev-list base..pr) echo "Checking commit messages for required prefix: $PREFIX" FAIL=0 diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml new file mode 100644 index 000000000000..848710369f17 --- /dev/null +++ b/.github/workflows/coverage.yml @@ -0,0 +1,104 @@ +name: Coverage + +on: + pull_request_target: + paths: + - 'django/**/*.py' + - 'tests/**/*.py' + branches: + - main + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + pull-requests: write + +jobs: + diff-coverage: + if: github.repository == 'django/django' + name: Diff Coverage (Windows) + runs-on: windows-latest + steps: + - name: Checkout + uses: actions/checkout@v5 + with: + fetch-depth: 0 + persist-credentials: false + + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: '3.14' + cache: 'pip' + cache-dependency-path: 'tests/requirements/py3.txt' + + - name: Install dependencies + run: | + python -m pip install --upgrade pip wheel + python -m pip install -r tests/requirements/py3.txt -e . + python -m pip install 'coverage[toml]' diff-cover + + - name: Run tests with coverage + env: + PYTHONPATH: ${{ github.workspace }}/tests + COVERAGE_PROCESS_START: ${{ github.workspace }}/tests/.coveragerc + RUNTESTS_DIR: ${{ github.workspace }}/tests + run: | + python -Wall tests/runtests.py -v2 + + - name: Generate coverage report + if: success() + env: + COVERAGE_RCFILE: ${{ github.workspace }}/tests/.coveragerc + RUNTESTS_DIR: ${{ github.workspace }}/tests + run: | + python -m coverage combine + python -m coverage report --show-missing + python -m coverage xml -o tests/coverage.xml + + - name: Run diff-cover + if: success() + run: | + if (Test-Path 'tests/coverage.xml') { + diff-cover tests/coverage.xml --compare-branch=origin/main --fail-under=0 > tests/diff-cover-report.md + } else { + Set-Content -Path tests/diff-cover-report.md -Value 'No coverage.xml found; skipping diff-cover.' + } + + - name: Post/update PR comment + if: success() + uses: actions/github-script@v8 + with: + script: | + const fs = require('fs'); + const reportPath = 'tests/diff-cover-report.md'; + let body = 'No coverage data available.'; + if (fs.existsSync(reportPath)) { + body = fs.readFileSync(reportPath, 'utf8'); + } + const commentBody = '### πŸ“Š Coverage Report for Changed Files\n\n```\n' + body + '\n```\n\n**Note:** Missing lines are warnings only. Some lines may not be covered by SQLite tests as they are database-specific.\n\nFor more information about code coverage on pull requests, see the [contributing documentation](https://docs.djangoproject.com/en/dev/internals/contributing/writing-code/unit-tests/#code-coverage-on-pull-requests).'; + + const { data: comments } = await github.rest.issues.listComments({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + }); + for (const c of comments) { + if ((c.body || '').includes('πŸ“Š Coverage Report for Changed Files')) { + await github.rest.issues.deleteComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: c.id, + }); + } + } + + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + body: commentBody, + }); diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 6e4a9cdd1bbf..4100c9ea2133 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -26,6 +26,8 @@ jobs: steps: - name: Checkout uses: actions/checkout@v5 + with: + persist-credentials: false - name: Set up Python uses: actions/setup-python@v6 with: @@ -44,6 +46,8 @@ jobs: steps: - name: Checkout uses: actions/checkout@v5 + with: + persist-credentials: false - name: Set up Python uses: actions/setup-python@v6 with: @@ -65,6 +69,8 @@ jobs: steps: - name: Checkout uses: actions/checkout@v5 + with: + persist-credentials: false - name: Set up Python uses: actions/setup-python@v6 with: diff --git a/.github/workflows/labels.yml b/.github/workflows/labels.yml index 91579d82c29a..79ee8af59d42 100644 --- a/.github/workflows/labels.yml +++ b/.github/workflows/labels.yml @@ -3,6 +3,8 @@ name: Labels on: pull_request_target: types: [ edited, opened, reopened, ready_for_review ] + branches: + - main concurrency: group: ${{ github.workflow }}-${{ github.ref }} @@ -19,6 +21,8 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v5 + with: + persist-credentials: false - name: "Check title and manage labels" uses: actions/github-script@v8 diff --git a/.github/workflows/linters.yml b/.github/workflows/linters.yml index b5359efc3d24..7b58bacacf40 100644 --- a/.github/workflows/linters.yml +++ b/.github/workflows/linters.yml @@ -24,6 +24,8 @@ jobs: steps: - name: Checkout uses: actions/checkout@v5 + with: + persist-credentials: false - name: Set up Python uses: actions/setup-python@v6 with: @@ -41,6 +43,8 @@ jobs: steps: - name: Checkout uses: actions/checkout@v5 + with: + persist-credentials: false - name: Set up Python uses: actions/setup-python@v6 with: @@ -58,5 +62,20 @@ jobs: steps: - name: Checkout uses: actions/checkout@v5 + with: + persist-credentials: false - name: black uses: psf/black@stable + + zizmor: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v5 + with: + persist-credentials: false + - name: Run zizmor + uses: zizmorcore/zizmor-action@e673c3917a1aef3c65c972347ed84ccd013ecda4 # v0.2.0 + with: + advanced-security: false + annotations: true diff --git a/.github/workflows/new_contributor_pr.yml b/.github/workflows/new_contributor_pr.yml index 958ebc64d435..194adb3bb860 100644 --- a/.github/workflows/new_contributor_pr.yml +++ b/.github/workflows/new_contributor_pr.yml @@ -3,12 +3,16 @@ name: New contributor message on: pull_request_target: types: [opened] + branches: + - main permissions: pull-requests: write jobs: build: + # Only trigger on the main Django repository + if: github.repository == 'django/django' name: Hello new contributor runs-on: ubuntu-latest steps: diff --git a/.github/workflows/postgis.yml b/.github/workflows/postgis.yml index e20735233bc4..4ea462ee446c 100644 --- a/.github/workflows/postgis.yml +++ b/.github/workflows/postgis.yml @@ -39,6 +39,8 @@ jobs: steps: - name: Checkout uses: actions/checkout@v5 + with: + persist-credentials: false - name: Set up Python uses: actions/setup-python@v6 with: diff --git a/.github/workflows/python_matrix.yml b/.github/workflows/python_matrix.yml index bbdb4458b4b6..b3b28665113c 100644 --- a/.github/workflows/python_matrix.yml +++ b/.github/workflows/python_matrix.yml @@ -23,6 +23,8 @@ jobs: steps: - name: Checkout code uses: actions/checkout@v5 + with: + persist-credentials: false - id: set-matrix run: | python_versions=$(sed -n "s/^.*Programming Language :: Python :: \([[:digit:]]\+\.[[:digit:]]\+\).*$/'\1', /p" pyproject.toml | tr -d '\n' | sed 's/, $//g') @@ -37,6 +39,8 @@ jobs: steps: - name: Checkout uses: actions/checkout@v5 + with: + persist-credentials: false - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v6 with: diff --git a/.github/workflows/schedule_tests.yml b/.github/workflows/schedule_tests.yml index e2d3e555907d..5679fbd77de2 100644 --- a/.github/workflows/schedule_tests.yml +++ b/.github/workflows/schedule_tests.yml @@ -25,6 +25,8 @@ jobs: steps: - name: Checkout uses: actions/checkout@v5 + with: + persist-credentials: false - name: Set up Python uses: actions/setup-python@v6 with: @@ -43,6 +45,8 @@ jobs: steps: - name: Checkout uses: actions/checkout@v5 + with: + persist-credentials: false - name: Set up Python uses: actions/setup-python@v6 with: @@ -69,6 +73,8 @@ jobs: steps: - name: Checkout uses: actions/checkout@v5 + with: + persist-credentials: false - name: Set up Node.js uses: actions/setup-node@v5 with: @@ -84,6 +90,8 @@ jobs: steps: - name: Checkout uses: actions/checkout@v5 + with: + persist-credentials: false - name: Set up Python uses: actions/setup-python@v6 with: @@ -120,6 +128,8 @@ jobs: steps: - name: Checkout uses: actions/checkout@v5 + with: + persist-credentials: false - name: Set up Python uses: actions/setup-python@v6 with: @@ -165,6 +175,8 @@ jobs: steps: - name: Checkout uses: actions/checkout@v5 + with: + persist-credentials: false - name: Set up Python uses: actions/setup-python@v6 with: diff --git a/.github/workflows/screenshots.yml b/.github/workflows/screenshots.yml index 1a835fe1a660..82d99013715a 100644 --- a/.github/workflows/screenshots.yml +++ b/.github/workflows/screenshots.yml @@ -21,6 +21,8 @@ jobs: steps: - name: Checkout uses: actions/checkout@v5 + with: + persist-credentials: false - name: Set up Python uses: actions/setup-python@v6 with: diff --git a/.github/workflows/selenium.yml b/.github/workflows/selenium.yml index d62268658f1c..a07eb692a185 100644 --- a/.github/workflows/selenium.yml +++ b/.github/workflows/selenium.yml @@ -21,6 +21,8 @@ jobs: steps: - name: Checkout uses: actions/checkout@v5 + with: + persist-credentials: false - name: Set up Python uses: actions/setup-python@v6 with: @@ -58,6 +60,8 @@ jobs: steps: - name: Checkout uses: actions/checkout@v5 + with: + persist-credentials: false - name: Set up Python uses: actions/setup-python@v6 with: diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 9428a9de0c2e..c6321b1415f2 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -28,6 +28,8 @@ jobs: steps: - name: Checkout uses: actions/checkout@v5 + with: + persist-credentials: false - name: Set up Python uses: actions/setup-python@v6 with: @@ -46,6 +48,8 @@ jobs: steps: - name: Checkout uses: actions/checkout@v5 + with: + persist-credentials: false - name: Set up Node.js uses: actions/setup-node@v5 with: diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index f2a9217d6cc8..c7a51b961ecc 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -24,3 +24,7 @@ repos: rev: v9.36.0 hooks: - id: eslint + - repo: https://github.com/zizmorcore/zizmor-pre-commit + rev: v1.16.3 + hooks: + - id: zizmor diff --git a/MANIFEST.in b/MANIFEST.in index 63c16094314a..1174cdb3dc3b 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -11,6 +11,6 @@ graft django graft docs graft extras graft js_tests -graft scripts graft tests global-exclude *.py[co] +prune scripts diff --git a/django/contrib/admin/templates/admin/actions.html b/django/contrib/admin/templates/admin/actions.html index f0419d983705..db7345dc6020 100644 --- a/django/contrib/admin/templates/admin/actions.html +++ b/django/contrib/admin/templates/admin/actions.html @@ -13,7 +13,7 @@ {% if cl.result_count != cl.result_list|length %} {% endif %} diff --git a/django/contrib/admin/templates/admin/app_list.html b/django/contrib/admin/templates/admin/app_list.html index 60d874b2b699..28148c456efe 100644 --- a/django/contrib/admin/templates/admin/app_list.html +++ b/django/contrib/admin/templates/admin/app_list.html @@ -5,7 +5,7 @@
diff --git a/docs/internals/contributing/writing-code/submitting-patches.txt b/docs/internals/contributing/writing-code/submitting-patches.txt index 035eb815cbde..428acffba1cb 100644 --- a/docs/internals/contributing/writing-code/submitting-patches.txt +++ b/docs/internals/contributing/writing-code/submitting-patches.txt @@ -432,12 +432,15 @@ All code changes * Does the :doc:`coding style ` conform to our - guidelines? Are there any ``black``, ``blacken-docs``, ``flake8``, or - ``isort`` errors? You can install the :ref:`pre-commit + guidelines? Are there any ``black``, ``blacken-docs``, ``flake8``, + ``isort``, or ``zizmor`` errors? You can install the :ref:`pre-commit ` hooks to automatically catch these errors. * If the change is backwards incompatible in any way, is there a note in the release notes (``docs/releases/A.B.txt``)? * Is Django's test suite passing? +* If there is a :ref:`code coverage report ` + comment on the pull request, have you reviewed the missing coverage in + context (considering database/platform-specific limitations)? * If the change affects the Django admin or rendered HTML output, has :ref:`accessibility testing ` been done? diff --git a/docs/internals/contributing/writing-code/unit-tests.txt b/docs/internals/contributing/writing-code/unit-tests.txt index 22938c1ceac5..f304970a290a 100644 --- a/docs/internals/contributing/writing-code/unit-tests.txt +++ b/docs/internals/contributing/writing-code/unit-tests.txt @@ -69,11 +69,11 @@ command from any place in the Django source tree: $ tox By default, ``tox`` runs the test suite with the bundled test settings file for -SQLite, ``black``, ``blacken-docs``, ``flake8``, ``isort``, ``lint-docs`` and -the documentation spelling checker. In addition to the system dependencies -noted elsewhere in this documentation, the command ``python3`` must be on your -path and linked to the appropriate version of Python. A list of default -environments can be seen as follows: +SQLite, ``black``, ``blacken-docs``, ``flake8``, ``isort``, ``lint-docs``, +``zizmor``, and the documentation spelling checker. In addition to the system +dependencies noted elsewhere in this documentation, the command ``python3`` +must be on your path and linked to the appropriate version of Python. A list of +default environments can be seen as follows: .. console:: @@ -85,6 +85,7 @@ environments can be seen as follows: docs isort>=7.0.0 lint-docs + zizmor>=1.16.3 Testing other Python versions and database backends ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ @@ -393,6 +394,49 @@ settings file defines ``coverage_html`` as the output directory for the report and also excludes several directories not relevant to the results (test code or external code included in Django). +.. _code-coverage-on-pull-requests: + +Code coverage on pull requests +------------------------------ + +Django's continuous integration (CI) system automatically runs code coverage +analysis on pull requests and posts a comment with a diff coverage report. This +helps reviewers see which lines in the changed code are covered by tests. + +**What the coverage report shows:** + +The coverage report posted on pull requests uses `diff-cover`_ to analyze only +the lines that were changed or added in the PR. It shows: + +* Lines that are covered by tests (βœ“) +* Lines that are not covered by tests (βœ—) +* Lines that cannot be covered (e.g., comments, blank lines) + +.. _diff-cover: https://github.com/Bachmann1234/diff_cover + +**Important limitations:** + +When reviewing coverage reports on pull requests, keep these limitations in +mind: + +* **Database-specific code:** The CI coverage job runs tests using SQLite on + Windows. Code paths specific to other databases (PostgreSQL, MySQL, Oracle) + will appear as "not covered" even if database-specific tests exist. This is + expected and acceptable. + +* **Platform-specific code:** Similarly, code that only runs on certain + operating systems (Linux, macOS) will appear as not covered when run on + Windows. + +* **Coverage doesn't equal quality:** A line being "covered" only means it was + executed during tests. It doesn't guarantee the line is well-tested or that + all edge cases are handled. During review, assess test quality beyond just + coverage numbers. + + +Missing coverage should be considered a warning rather than a blocker and +should be evaluated in context. + .. _contrib-apps: Contrib apps diff --git a/docs/ref/models/querysets.txt b/docs/ref/models/querysets.txt index 9bfaea025d5e..1c92ea552b16 100644 --- a/docs/ref/models/querysets.txt +++ b/docs/ref/models/querysets.txt @@ -2432,7 +2432,8 @@ are), and returns created objects as a list, in the same order as provided: This has a number of caveats though: -* The model's ``save()`` method will not be called, and the ``pre_save`` and +* The model's ``save()`` method :ref:`will not be called + `, and the ``pre_save`` and ``post_save`` signals will not be sent. * It does not work with child models in a multi-table inheritance scenario. * If the model's primary key is an :class:`~django.db.models.AutoField` or has @@ -2513,7 +2514,8 @@ than iterating through the list of models and calling ``save()`` on each of them, but it has a few caveats: * You cannot update the model's primary key. -* Each model's ``save()`` method isn't called, and the +* Each model's ``save()`` method :ref:`isn't called + `, and the :attr:`~django.db.models.signals.pre_save` and :attr:`~django.db.models.signals.post_save` signals aren't sent. * If updating a large number of columns in a large number of rows, the SQL @@ -2999,7 +3001,8 @@ and calling ``save()``. such filter conditions on MySQL. Finally, realize that ``update()`` does an update at the SQL level and, thus, -does not call any ``save()`` methods on your models, nor does it emit the +method :ref:`does not call` any +``save()`` methods on your models, nor does it emit the :attr:`~django.db.models.signals.pre_save` or :attr:`~django.db.models.signals.post_save` signals (which are a consequence of calling :meth:`Model.save() `). If you want to @@ -3072,13 +3075,13 @@ This cascade behavior is customizable via the :attr:`~django.db.models.ForeignKey.on_delete` argument to the :class:`~django.db.models.ForeignKey`. -The ``delete()`` method does a bulk delete and does not call any ``delete()`` -methods on your models. It does, however, emit the -:data:`~django.db.models.signals.pre_delete` and -:data:`~django.db.models.signals.post_delete` signals for all deleted objects -(including cascaded deletions). Signals won't be sent when ``DB_CASCADE`` is -used. Also, ``delete()`` doesn't return information about objects deleted from -database variants (``DB_*``) of the +The ``delete()`` method does a bulk delete and :ref:`does not call +` any ``delete()`` methods on your +models. It does, however, emit the :data:`~django.db.models.signals.pre_delete` +and :data:`~django.db.models.signals.post_delete` signals for all deleted +objects (including cascaded deletions). Signals won't be sent when +``DB_CASCADE`` is used. Also, ``delete()`` doesn't return information about +objects deleted from database variants (``DB_*``) of the :attr:`~django.db.models.ForeignKey.on_delete` argument, e.g. ``DB_CASCADE``. Django won’t need to fetch objects into memory when deleting them in the diff --git a/docs/topics/db/models.txt b/docs/topics/db/models.txt index 8c53aa70581b..45156d8a16ef 100644 --- a/docs/topics/db/models.txt +++ b/docs/topics/db/models.txt @@ -964,6 +964,8 @@ example:: See :ref:`ref-models-update-fields` for more details. +.. _methods-not-called-on-bulk-operations: + .. admonition:: Overridden model methods are not called on bulk operations Note that the :meth:`~Model.delete` method for an object is not diff --git a/js_tests/tests.html b/js_tests/tests.html index 87e347cc0cc7..b3b53925f4f5 100644 --- a/js_tests/tests.html +++ b/js_tests/tests.html @@ -91,8 +91,7 @@
- {{ app.name }} + {{ app.name }}
diff --git a/scripts/backport.sh b/scripts/backport.sh new file mode 100755 index 000000000000..6c98697564ec --- /dev/null +++ b/scripts/backport.sh @@ -0,0 +1,35 @@ +#!/bin/bash + +# Backport helper for Django stable branches. + +set -xue + +if [ -z $1 ]; then + echo "Full hash of commit to backport is required." + exit +fi + +BRANCH_NAME=`git branch | sed -n '/\* stable\//s///p'` +echo $BRANCH_NAME + +# Ensure clean working directory +git reset --hard + +REV=$1 + +TMPFILE=tmplog.tmp + +# Cherry-pick the commit +git cherry-pick ${REV} + +# Create new log message by modifying the old one +git log --pretty=format:"[${BRANCH_NAME}] %s%n%n%b%nBackport of ${REV} from main." HEAD^..HEAD \ + | grep -v '^BP$' > ${TMPFILE} + +# Commit new log message +git commit --amend -F ${TMPFILE} + +# Clean up temporary files +rm -f ${TMPFILE} + +git show diff --git a/scripts/confirm_release.sh b/scripts/confirm_release.sh new file mode 100755 index 000000000000..920f2061aff5 --- /dev/null +++ b/scripts/confirm_release.sh @@ -0,0 +1,57 @@ +#! /bin/bash + +set -xue + +CHECKSUM_FILE="Django-${VERSION}.checksum.txt" +MEDIA_URL_PREFIX="https://media.djangoproject.com" +RELEASE_URL_PREFIX="https://www.djangoproject.com/m/releases/" +DOWNLOAD_PREFIX="https://www.djangoproject.com/download" + +if [[ ! "${VERSION}" =~ ^[0-9]+\.[0-9]+(\.[0-9]+|a[0-9]+|b[0-9]+|rc[0-9]+)?$ ]] ; then + echo "Not a valid version" +fi + +rm -rf "${VERSION}" +mkdir "${VERSION}" +cd "${VERSION}" + +function cleanup { + cd .. + rm -rf "${VERSION}" +} +trap cleanup EXIT + +echo "Download checksum file ..." +curl --fail --output "$CHECKSUM_FILE" "${MEDIA_URL_PREFIX}/pgp/${CHECKSUM_FILE}" + +echo "Verify checksum file ..." +if [ -n "${GPG_KEY}" ] ; then + gpg --recv-keys "${GPG_KEY}" +fi +gpg --verify "${CHECKSUM_FILE}" + +echo "Finding release artifacts ..." +mapfile -t RELEASE_ARTIFACTS < <(grep "${DOWNLOAD_PREFIX}" "${CHECKSUM_FILE}") + +echo "Found these release artifacts: " +for ARTIFACT_URL in "${RELEASE_ARTIFACTS[@]}" ; do + echo "- $ARTIFACT_URL" +done + +echo "Downloading artifacts ..." +for ARTIFACT_URL in "${RELEASE_ARTIFACTS[@]}" ; do + ARTIFACT_ACTUAL_URL=$(curl --head --write-out '%{redirect_url}' --output /dev/null --silent "${ARTIFACT_URL}") + curl --location --fail --output "$(basename "${ARTIFACT_ACTUAL_URL}")" "${ARTIFACT_ACTUAL_URL}" + +done + +echo "Verifying artifact hashes ..." +# The `2> /dev/null` moves notes like "sha256sum: WARNING: 60 lines are improperly formatted" +# to /dev/null. That's fine because the return code of the script is still set on error and a +# wrong checksum will still show up as `FAILED` +echo "- MD5 checksums" +md5sum --check "${CHECKSUM_FILE}" 2> /dev/null +echo "- SHA1 checksums" +sha1sum --check "${CHECKSUM_FILE}" 2> /dev/null +echo "- SHA256 checksums" +sha256sum --check "${CHECKSUM_FILE}" 2> /dev/null diff --git a/scripts/do_django_release.py b/scripts/do_django_release.py new file mode 100755 index 000000000000..b3cc0248ac23 --- /dev/null +++ b/scripts/do_django_release.py @@ -0,0 +1,226 @@ +#! /usr/bin/env python + +"""Helper to build and publish Django artifacts. + +Original author: Tim Graham. +Other authors: Mariusz Felisiak, Natalia Bidart. + +""" + +import hashlib +import os +import re +import subprocess +from datetime import date + +PGP_KEY_ID = os.getenv("PGP_KEY_ID") +PGP_KEY_URL = os.getenv("PGP_KEY_URL") +PGP_EMAIL = os.getenv("PGP_EMAIL") +DEST_FOLDER = os.path.expanduser(os.getenv("DEST_FOLDER")) + +assert ( + PGP_KEY_ID +), "Missing PGP_KEY_ID: Set this env var to your PGP key ID (used for signing)." +assert ( + PGP_KEY_URL +), "Missing PGP_KEY_URL: Set this env var to your PGP public key URL (for fetching)." +assert DEST_FOLDER and os.path.exists( + DEST_FOLDER +), "Missing DEST_FOLDER: Set this env var to the local path to place the artifacts." + + +checksum_file_text = """This file contains MD5, SHA1, and SHA256 checksums for the +source-code tarball and wheel files of Django {django_version}, released {release_date}. + +To use this file, you will need a working install of PGP or other +compatible public-key encryption software. You will also need to have +the Django release manager's public key in your keyring. This key has +the ID ``{pgp_key_id}`` and can be imported from the MIT +keyserver, for example, if using the open-source GNU Privacy Guard +implementation of PGP: + + gpg --keyserver pgp.mit.edu --recv-key {pgp_key_id} + +or via the GitHub API: + + curl {pgp_key_url} | gpg --import - + +Once the key is imported, verify this file: + + gpg --verify {checksum_file_name} + +Once you have verified this file, you can use normal MD5, SHA1, or SHA256 +checksumming applications to generate the checksums of the Django +package and compare them to the checksums listed below. + +Release packages +================ + +https://www.djangoproject.com/download/{django_version}/tarball/ +https://www.djangoproject.com/download/{django_version}/wheel/ + +MD5 checksums +============= + +{md5_tarball} {tarball_name} +{md5_wheel} {wheel_name} + +SHA1 checksums +============== + +{sha1_tarball} {tarball_name} +{sha1_wheel} {wheel_name} + +SHA256 checksums +================ + +{sha256_tarball} {tarball_name} +{sha256_wheel} {wheel_name} + +""" + + +def build_artifacts(): + from build.__main__ import main as build_main + + build_main([]) + + +def do_checksum(checksum_algo, release_file): + with open(os.path.join(dist_path, release_file), "rb") as f: + return checksum_algo(f.read()).hexdigest() + + +# Ensure the working directory is clean. +subprocess.call(["git", "clean", "-fdx"]) + +django_repo_path = os.path.abspath(os.path.curdir) +dist_path = os.path.join(django_repo_path, "dist") + +# Build release files. +build_artifacts() +release_files = os.listdir(dist_path) +wheel_name = None +tarball_name = None +for f in release_files: + if f.endswith(".whl"): + wheel_name = f + if f.endswith(".tar.gz"): + tarball_name = f + +assert wheel_name is not None +assert tarball_name is not None + +django_version = wheel_name.split("-")[1] +django_major_version = ".".join(django_version.split(".")[:2]) + +artifacts_path = os.path.join(os.path.expanduser(DEST_FOLDER), django_version) +os.makedirs(artifacts_path, exist_ok=True) + +# Chop alpha/beta/rc suffix +match = re.search("[abrc]", django_major_version) +if match: + django_major_version = django_major_version[: match.start()] + +release_date = date.today().strftime("%B %-d, %Y") +checksum_file_name = f"Django-{django_version}.checksum.txt" +checksum_file_kwargs = dict( + release_date=release_date, + pgp_key_id=PGP_KEY_ID, + django_version=django_version, + pgp_key_url=PGP_KEY_URL, + checksum_file_name=checksum_file_name, + wheel_name=wheel_name, + tarball_name=tarball_name, +) +checksums = ( + ("md5", hashlib.md5), + ("sha1", hashlib.sha1), + ("sha256", hashlib.sha256), +) +for checksum_name, checksum_algo in checksums: + checksum_file_kwargs[f"{checksum_name}_tarball"] = do_checksum( + checksum_algo, tarball_name + ) + checksum_file_kwargs[f"{checksum_name}_wheel"] = do_checksum( + checksum_algo, wheel_name + ) + +# Create the checksum file +checksum_file_text = checksum_file_text.format(**checksum_file_kwargs) +checksum_file_path = os.path.join(artifacts_path, checksum_file_name) +with open(checksum_file_path, "wb") as f: + f.write(checksum_file_text.encode("ascii")) + +print("\n\nDiffing release with checkout for sanity check.") + +# Unzip and diff... +unzip_command = [ + "unzip", + "-q", + os.path.join(dist_path, wheel_name), + "-d", + os.path.join(dist_path, django_major_version), +] +subprocess.run(unzip_command) +diff_command = [ + "diff", + "-qr", + "./django/", + os.path.join(dist_path, django_major_version, "django"), +] +subprocess.run(diff_command) +subprocess.run( + [ + "rm", + "-rf", + os.path.join(dist_path, django_major_version), + ] +) + +print("\n\n=> Commands to run NOW:") + +# Sign the checksum file, this may prompt for a passphrase. +pgp_email = f"-u {PGP_EMAIL} " if PGP_EMAIL else "" +print(f"gpg --clearsign {pgp_email}--digest-algo SHA256 {checksum_file_path}") +# Create, verify and push tag +print(f'git tag --sign --message="Tag {django_version}" {django_version}') +print(f"git tag --verify {django_version}") + +# Copy binaries outside the current repo tree to avoid lossing them. +subprocess.run(["cp", "-r", dist_path, artifacts_path]) + +# Make the binaries available to the world +print( + "\n\n=> These ONLY 15 MINUTES BEFORE RELEASE TIME (consider new terminal " + "session with isolated venv)!" +) + +# Upload the checksum file and release artifacts to the djangoproject admin. +print( + "\n==> ACTION Add tarball, wheel, and checksum files to the Release entry at:" + f"https://www.djangoproject.com/admin/releases/release/{django_version}" +) +print( + f"* Tarball and wheel from {artifacts_path}\n" + f"* Signed checksum {checksum_file_path}.asc" +) + +# Test the new version and confirm the signature using Jenkins. +print("\n==> ACTION Test the release artifacts:") +print(f"VERSION={django_version} test_new_version.sh") + +print("\n==> ACTION Run confirm-release job:") +print(f"VERSION={django_version} confirm_release.sh") + +# Upload to PyPI. +print("\n==> ACTION Upload to PyPI, ensure your release venv is activated:") +print(f"cd {artifacts_path}") +print("pip install -U pip twine") +print("twine upload --repository django dist/*") + +# Push the tags. +print("\n==> ACTION Push the tags:") +print("git push --tags") + +print("\n\nDONE!!!") diff --git a/scripts/test_new_version.sh b/scripts/test_new_version.sh new file mode 100755 index 000000000000..50fff186c4cd --- /dev/null +++ b/scripts/test_new_version.sh @@ -0,0 +1,48 @@ +#! /bin/bash + +# Original author: Tim Graham. + +set -xue + +cd /tmp + +RELEASE_VERSION="${VERSION}" +if [[ -z "$RELEASE_VERSION" ]]; then + echo "Please set VERSION as env var" + exit 1 +fi + +PKG_TAR=$(curl -Ls -o /dev/null -w '%{url_effective}' https://www.djangoproject.com/download/$RELEASE_VERSION/tarball/) +echo $PKG_TAR + +PKG_WHL=$(curl -Ls -o /dev/null -w '%{url_effective}' https://www.djangoproject.com/download/$RELEASE_VERSION/wheel/) +echo $PKG_WHL + +python3 -m venv django-pip +. django-pip/bin/activate +python -m pip install --no-cache-dir $PKG_TAR +django-admin startproject test_one +cd test_one +./manage.py --help # Ensure executable bits +python manage.py migrate +python manage.py runserver + +deactivate +cd .. +rm -rf test_one +rm -rf django-pip + + +python3 -m venv django-pip-wheel +. django-pip-wheel/bin/activate +python -m pip install --no-cache-dir $PKG_WHL +django-admin startproject test_one +cd test_one +./manage.py --help # Ensure executable bits +python manage.py migrate +python manage.py runserver + +deactivate +cd .. +rm -rf test_one +rm -rf django-pip-wheel diff --git a/tox.ini b/tox.ini index 8d4698f08445..9888bff0b805 100644 --- a/tox.ini +++ b/tox.ini @@ -14,6 +14,7 @@ envlist = docs isort lint-docs + zizmor # Add environment to use the default python3 installation [testenv:py3] @@ -98,3 +99,11 @@ deps = sphinx-lint changedir = docs commands = make lint + +[testenv:zizmor] +basepython = python3 +usedevelop = false +deps = zizmor >= 1.16.3 +changedir = {toxinidir} +commands = + zizmor . diff --git a/zizmor.yml b/zizmor.yml new file mode 100644 index 000000000000..e0401fa7162f --- /dev/null +++ b/zizmor.yml @@ -0,0 +1,11 @@ +rules: + dangerous-triggers: + ignore: + - coverage.yml + - labels.yml + - new_contributor_pr.yml + unpinned-uses: + config: + policies: + actions/*: ref-pin + psf/*: ref-pin
- + Authentication and Authorization