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 %}
{{ selection_note_all }}
- {% blocktranslate with cl.result_count as total_count %}Select all {{ total_count }} {{ module_name }}{% endblocktranslate %}
+ {% blocktranslate with cl.result_count as total_count %}Select all {{ total_count }} {{ module_name }}{% endblocktranslate %}
{% translate "Clear selection" %}
{% 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 @@
- {{ app.name }}
+ {{ app.name }}
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 @@
-
+
Authentication and Authorization
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