mirror of
https://gitcode.com/gh_mirrors/gh/gh-action-pypi-publish.git
synced 2026-07-04 03:07:50 +00:00
Compare commits
175 Commits
v1.8.6
...
unstable/v1
| Author | SHA1 | Date | |
|---|---|---|---|
| e0449d218c | |||
| cebc64f283 | |||
| da900af963 | |||
| 8cafb5c2bf | |||
| 916e57631f | |||
| daa899706d | |||
| 72d1032bb0 | |||
| 88a4d039d1 | |||
| 674c7c85f0 | |||
| 03e1883a77 | |||
| 97583d9694 | |||
| fe7e9df44b | |||
| f14df0bb20 | |||
| 67339c736f | |||
| cbd6d01d85 | |||
| 7252a9a09c | |||
| a536fa9505 | |||
| 43caae4bb1 | |||
| f371c3d566 | |||
| 138a1215a3 | |||
| ff2b051b0a | |||
| 0a0a6ae824 | |||
| e7723a410e | |||
| 0e10725395 | |||
| 218af422c0 | |||
| 7c5c585c36 | |||
| 93e87954aa | |||
| f81cd95ad9 | |||
| 15c56dba36 | |||
| fe8d1484ba | |||
| 1f5d4ec244 | |||
| fec2f0c0ce | |||
| a8b73a6d88 | |||
| 9b4dfb0c84 | |||
| 0a87186d5f | |||
| dfcfeca43e | |||
| 0d02f372c3 | |||
| 61da13deb5 | |||
| 36965cb24a | |||
| da554410b0 | |||
| 80b1d50e0d | |||
| 1b9f21a741 | |||
| cfb9d93a26 | |||
| 153ccde9bc | |||
| d03addb8e6 | |||
| bacb62682c | |||
| 7ea8313fc2 | |||
| f51682fb52 | |||
| a360fcb184 | |||
| a869dd36b2 | |||
| 5ded5310e7 | |||
| cf5ce177da | |||
| f1f014b445 | |||
| aed6c4b1b0 | |||
| 0d8d5059c8 | |||
| e453f8c630 | |||
| 783267be69 | |||
| fb13cb3069 | |||
| 72ead1a85a | |||
| 0126dcac8e | |||
| 335e8b00ae | |||
| 1545e96dcb | |||
| f7600683ef | |||
| 6edc294485 | |||
| 85a5a80b22 | |||
| 954318b48e | |||
| 24791c7774 | |||
| d8c894824b | |||
| a1ce3844ac | |||
| 00b87c80e8 | |||
| a571f1e128 | |||
| 897895f1e1 | |||
| ce32325c61 | |||
| 36978192ca | |||
| 4f8925cefa | |||
| a58e550ac2 | |||
| 0ab0b79471 | |||
| 8a08d61689 | |||
| fb9fc6a4e6 | |||
| 4d020ff0a9 | |||
| ec4db0b4dd | |||
| e7908444c6 | |||
| 87b624f871 | |||
| da2f9bb91e | |||
| abbea2dd5c | |||
| 2734d07314 | |||
| a54b9b8952 | |||
| 699cd6103f | |||
| 8414fc2457 | |||
| 67a07ebbed | |||
| 771d60f44b | |||
| 04f4e64de3 | |||
| 3fbcf7ccf4 | |||
| 576aae3934 | |||
| 81e9d935c8 | |||
| 91527c4583 | |||
| 3a817c6dce | |||
| 741947b9ca | |||
| d7af439579 | |||
| e90ddca975 | |||
| dae7fa3e8d | |||
| 0fe04ae7d9 | |||
| 444e17980b | |||
| 820be4e5e3 | |||
| aec4e82833 | |||
| b065889f7f | |||
| 00a7cd17a2 | |||
| 2972d54cda | |||
| f6a1bcf881 | |||
| e6ed2a4dfb | |||
| e53eb8b103 | |||
| edfa8f355b | |||
| aeff019ac8 | |||
| 24c5d5ca4a | |||
| c13b4aa8c5 | |||
| 72a79c870c | |||
| 751e5b80a4 | |||
| 0580fcbb84 | |||
| a524841e7b | |||
| 3f824c73d9 | |||
| 013c017b41 | |||
| a0620a4177 | |||
| e82f99a47c | |||
| e080e0073c | |||
| cd96453c9d | |||
| 415d7a6bec | |||
| dea1d707f3 | |||
| a1a49954d3 | |||
| c12cc61414 | |||
| 674fb78567 | |||
| 2f6f737ca5 | |||
| 2fa448ab0c | |||
| 824ad31786 | |||
| 41f3f53c75 | |||
| 2319287e0a | |||
| 254a0d4ec4 | |||
| 70a33caeb9 | |||
| 102f507b75 | |||
| 79739dc2f2 | |||
| 9a3f9ad5bc | |||
| 75ca4c1f12 | |||
| a712d989cc | |||
| bbf06d8ae3 | |||
| 8cdc2ab67c | |||
| 41c10ee223 | |||
| b7f401de30 | |||
| ba3ecc9355 | |||
| ade57f54dc | |||
| 637917e5f2 | |||
| 4864f13c38 | |||
| 326f9ad1e1 | |||
| e5f0690e91 | |||
| 8bdd0cc2a0 | |||
| 71a0032909 | |||
| adef75a5a6 | |||
| 413a8d5d62 | |||
| c185b8ee4e | |||
| 2a939dd49b | |||
| f8c70e705f | |||
| 68276eb3e4 | |||
| a5d57af63c | |||
| e90e853e89 | |||
| be695966b0 | |||
| 54d67ed3c5 | |||
| d32e2fab32 | |||
| a8d92e9876 | |||
| f5622bde02 | |||
| 3be882c473 | |||
| 775be49481 | |||
| 5684530096 | |||
| 135d0d5353 | |||
| 110f54a387 | |||
| c803c91ef0 | |||
| f9ed8ba9ad | |||
| 30639668ca |
@@ -13,4 +13,10 @@ ko_fi: webknjaz
|
||||
|
||||
liberapay: webknjaz
|
||||
|
||||
open_collective: webknjaz
|
||||
|
||||
# patreon: webknjaz # not in use because of the ties with ruscism
|
||||
|
||||
thanks_dev: u/gh/webknjaz
|
||||
|
||||
...
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
# Security Policy
|
||||
|
||||
**⚠️ Please do not file public GitHub issues for security
|
||||
vulnerabilities as they are open for everyone to see! ⚠️**
|
||||
|
||||
We encourage responsible disclosure practices for security
|
||||
vulnerabilities.
|
||||
|
||||
|
||||
## Supported Versions
|
||||
|
||||
Always update to the latest version of
|
||||
this Action to keep up with security patches.
|
||||
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
If you believe you've found a security-related bug, we
|
||||
prefer that you fill out a [vulnerability report on GitHub]
|
||||
directly.
|
||||
|
||||
[vulnerability report on GitHub]:
|
||||
//github.com/pypa/gh-action-pypi-publish/security/advisories/new
|
||||
|
||||
|
||||
## Don't have a GitHub account?
|
||||
|
||||
Alternatively, drop an email to
|
||||
``wk+gh-action-pypi-publish-security`` at ``sydorenko`` dot
|
||||
``org`` dot ``ua`` instead of filing a ticket or posting to
|
||||
_any_ public groups. We will try to assess the problem in
|
||||
timely manner and disclose it in a responsible way.
|
||||
@@ -1,14 +0,0 @@
|
||||
Security Policy
|
||||
---------------
|
||||
|
||||
Supported Versions
|
||||
==================
|
||||
|
||||
Always update to the latest version of
|
||||
this Action to keep up with security patches.
|
||||
|
||||
Reporting a Vulnerability
|
||||
=========================
|
||||
|
||||
Email to ``wk+gh-action-pypi-publish-security``
|
||||
at ``sydorenko`` dot ``org`` dot ``ua``.
|
||||
@@ -0,0 +1,75 @@
|
||||
---
|
||||
|
||||
name: 🏗️
|
||||
|
||||
on: # yamllint disable-line rule:truthy
|
||||
pull_request:
|
||||
push:
|
||||
branches: ["release/*", "unstable/*"]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: Docker image tag
|
||||
required: true
|
||||
type: string
|
||||
|
||||
jobs:
|
||||
smoke-test:
|
||||
uses: ./.github/workflows/reusable-smoke-test.yml
|
||||
|
||||
check: # This job does nothing and is only used for the branch protection
|
||||
if: always()
|
||||
|
||||
needs:
|
||||
- smoke-test
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
timeout-minutes: 1
|
||||
|
||||
steps:
|
||||
- name: Decide whether the needed jobs succeeded or failed
|
||||
uses: re-actors/alls-green@release/v1
|
||||
with:
|
||||
jobs: ${{ toJSON(needs) }}
|
||||
|
||||
build-and-push:
|
||||
if: github.event_name != 'pull_request'
|
||||
runs-on: ubuntu-latest
|
||||
needs:
|
||||
- check
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Build Docker image
|
||||
run: |
|
||||
DOCKER_TAG="${DOCKER_TAG/'/'/'-'}"
|
||||
DOCKER_TAG_MAJOR=$(echo "$DOCKER_TAG" | cut -d '.' -f 1)
|
||||
DOCKER_TAG_MAJOR_MINOR=$(echo "$DOCKER_TAG" | cut -d '.' -f 1-2)
|
||||
IMAGE="ghcr.io/$GITHUB_REPOSITORY:${DOCKER_TAG}"
|
||||
IMAGE_MAJOR="ghcr.io/$GITHUB_REPOSITORY:${DOCKER_TAG_MAJOR}"
|
||||
IMAGE_MAJOR_MINOR="ghcr.io/$GITHUB_REPOSITORY:${DOCKER_TAG_MAJOR_MINOR}"
|
||||
IMAGE_SHA="ghcr.io/$GITHUB_REPOSITORY:${GITHUB_SHA}"
|
||||
echo "IMAGE=$IMAGE" >>"$GITHUB_ENV"
|
||||
echo "IMAGE_MAJOR=$IMAGE_MAJOR" >>"$GITHUB_ENV"
|
||||
echo "IMAGE_MAJOR_MINOR=$IMAGE_MAJOR_MINOR" >>"$GITHUB_ENV"
|
||||
echo "IMAGE_SHA=$IMAGE_SHA" >>"$GITHUB_ENV"
|
||||
docker build . \
|
||||
--build-arg BUILDKIT_INLINE_CACHE=1 \
|
||||
--cache-from $IMAGE \
|
||||
--tag $IMAGE
|
||||
docker tag $IMAGE $IMAGE_MAJOR
|
||||
docker tag $IMAGE $IMAGE_MAJOR_MINOR
|
||||
docker tag $IMAGE $IMAGE_SHA
|
||||
env:
|
||||
DOCKER_TAG: ${{ inputs.tag || github.ref_name }}
|
||||
- name: Log in to GHCR
|
||||
run: >-
|
||||
echo ${{ secrets.GITHUB_TOKEN }} |
|
||||
docker login ghcr.io -u $GITHUB_ACTOR --password-stdin
|
||||
- name: Push Docker image to GHCR
|
||||
run: |
|
||||
docker push $IMAGE
|
||||
docker push $IMAGE_MAJOR
|
||||
docker push $IMAGE_MAJOR_MINOR
|
||||
docker push $IMAGE_SHA
|
||||
+61
-7
@@ -1,10 +1,9 @@
|
||||
---
|
||||
|
||||
name: 🧪
|
||||
name: ♻️ 🧪
|
||||
|
||||
on: # yamllint disable-line rule:truthy
|
||||
push:
|
||||
pull_request:
|
||||
workflow_call:
|
||||
|
||||
env:
|
||||
devpi-password: abcd1234
|
||||
@@ -27,8 +26,40 @@ env:
|
||||
PYTEST_THEME_MODE
|
||||
|
||||
jobs:
|
||||
fail-fast:
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
os: [macos-latest, windows-latest]
|
||||
|
||||
runs-on: ${{ matrix.os }}
|
||||
|
||||
timeout-minutes: 2
|
||||
|
||||
steps:
|
||||
- name: Check out the action locally
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
path: test
|
||||
- name: Fail-fast in unsupported environments
|
||||
continue-on-error: true
|
||||
id: fail-fast
|
||||
uses: ./test
|
||||
- name: Error if action did not fail-fast in unsupported environments
|
||||
if: steps.fail-fast.outcome == 'success'
|
||||
run: |
|
||||
>&2 echo This action should fail-fast in unsupported environments.
|
||||
exit 1
|
||||
|
||||
smoke-test:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
os:
|
||||
- ubuntu-24.04
|
||||
- ubuntu-22.04
|
||||
|
||||
runs-on: ${{ matrix.os }}
|
||||
|
||||
services:
|
||||
devpi:
|
||||
@@ -42,7 +73,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Check out the action locally
|
||||
uses: actions/checkout@v3
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
path: test
|
||||
- name: Install the packaging-related tools
|
||||
@@ -61,7 +92,7 @@ jobs:
|
||||
CONTENTS: |
|
||||
[build-system]
|
||||
requires = [
|
||||
"setuptools == 65.6.3",
|
||||
"setuptools == 75.8.0",
|
||||
]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
@@ -71,8 +102,31 @@ jobs:
|
||||
readme = "README.md"
|
||||
- name: Build the stub package sdist and wheel distributions
|
||||
run: python3 -m build
|
||||
- name: Create the Rust package directory
|
||||
run: mkdir -pv rust-example
|
||||
- name: Initialize a Rust project
|
||||
run: cargo init
|
||||
working-directory: rust-example
|
||||
- name: Populate the Rust package `pyproject.toml`
|
||||
run: echo "$CONTENTS" > pyproject.toml
|
||||
env:
|
||||
CONTENTS: |
|
||||
[build-system]
|
||||
requires = [
|
||||
"maturin ~=1.0",
|
||||
]
|
||||
build-backend = "maturin"
|
||||
working-directory: rust-example
|
||||
- name: Build the stub package sdist and wheel distributions
|
||||
run: python3 -m build -o ../dist/
|
||||
working-directory: rust-example
|
||||
- name: Register the stub package in devpi
|
||||
run: twine register dist/*.tar.gz
|
||||
run: |
|
||||
for dist in dist/*.tar.gz
|
||||
do
|
||||
echo "Registering ${dist}..."
|
||||
twine register "${dist}"
|
||||
done
|
||||
env:
|
||||
TWINE_USERNAME: ${{ env.devpi-username }}
|
||||
TWINE_PASSWORD: ${{ env.devpi-password }}
|
||||
@@ -0,0 +1,4 @@
|
||||
[tool.pip-tools]
|
||||
allow-unsafe = true
|
||||
resolver = "backtracking"
|
||||
strip-extras = true
|
||||
+17
-62
@@ -5,24 +5,24 @@ ci:
|
||||
|
||||
repos:
|
||||
- repo: https://github.com/asottile/add-trailing-comma.git
|
||||
rev: v2.4.0
|
||||
rev: v3.1.0
|
||||
hooks:
|
||||
- id: add-trailing-comma
|
||||
|
||||
- repo: https://github.com/PyCQA/isort.git
|
||||
rev: 5.12.0
|
||||
rev: 5.13.2
|
||||
hooks:
|
||||
- id: isort
|
||||
args:
|
||||
- --honor-noqa
|
||||
|
||||
- repo: https://github.com/Lucas-C/pre-commit-hooks.git
|
||||
rev: v1.5.1
|
||||
rev: v1.5.5
|
||||
hooks:
|
||||
- id: remove-tabs
|
||||
|
||||
- repo: https://github.com/python-jsonschema/check-jsonschema.git
|
||||
rev: 0.22.0
|
||||
rev: 0.29.2
|
||||
hooks:
|
||||
- id: check-github-actions
|
||||
- id: check-github-workflows
|
||||
@@ -37,7 +37,7 @@ repos:
|
||||
- id: check-readthedocs
|
||||
|
||||
- repo: https://github.com/pre-commit/pre-commit-hooks.git
|
||||
rev: v4.4.0
|
||||
rev: v4.6.0
|
||||
hooks:
|
||||
# Side-effects:
|
||||
- id: end-of-file-fixer
|
||||
@@ -62,12 +62,12 @@ repos:
|
||||
language_version: python3
|
||||
|
||||
- repo: https://github.com/codespell-project/codespell
|
||||
rev: v2.2.4
|
||||
rev: v2.2.6
|
||||
hooks:
|
||||
- id: codespell
|
||||
|
||||
- repo: https://github.com/adrienverge/yamllint.git
|
||||
rev: v1.30.0
|
||||
rev: v1.35.1
|
||||
hooks:
|
||||
- id: yamllint
|
||||
files: \.(yaml|yml)$
|
||||
@@ -78,13 +78,13 @@ repos:
|
||||
- --strict
|
||||
|
||||
- repo: https://github.com/PyCQA/flake8.git
|
||||
rev: 6.0.0
|
||||
rev: 7.0.0
|
||||
hooks:
|
||||
- id: flake8
|
||||
alias: flake8-no-wps
|
||||
name: flake8 WPS-excluded
|
||||
args:
|
||||
- --ignore
|
||||
# NOTE: WPS326: Found implicit string concatenation
|
||||
# NOTE: WPS332: Found walrus operator
|
||||
- >-
|
||||
D100,
|
||||
D101,
|
||||
@@ -92,26 +92,11 @@ repos:
|
||||
D107,
|
||||
E402,
|
||||
E501,
|
||||
additional_dependencies:
|
||||
- flake8-2020 ~= 1.7.0
|
||||
- flake8-pytest-style ~= 1.6.0
|
||||
|
||||
- repo: https://github.com/PyCQA/flake8.git
|
||||
# NOTE: This is kept at v4 for until WPS starts supporting flake v5.
|
||||
rev: 4.0.1 # enforce-version: 4.0.1
|
||||
hooks:
|
||||
- id: flake8
|
||||
alias: flake8-only-wps
|
||||
name: flake8 WPS-only
|
||||
args:
|
||||
- --ignore
|
||||
# NOTE: WPS326: Found implicit string concatenation
|
||||
# NOTE: WPS332: Found walrus operator
|
||||
- >-
|
||||
WPS102,
|
||||
WPS110,
|
||||
WPS111,
|
||||
WPS305,
|
||||
WPS318,
|
||||
WPS326,
|
||||
WPS332,
|
||||
WPS347,
|
||||
@@ -124,13 +109,15 @@ repos:
|
||||
WPS440,
|
||||
WPS441,
|
||||
WPS453,
|
||||
- --select
|
||||
- WPS
|
||||
- --max-module-members=8 # WPS202
|
||||
additional_dependencies:
|
||||
- wemake-python-styleguide ~= 0.17.0
|
||||
- flake8-2020 ~= 1.7.0
|
||||
- flake8-pytest-style ~= 1.6.0
|
||||
- wemake-python-styleguide ~= 0.19.0
|
||||
language_version: python3.11 # flake8-commas doesn't work w/ Python 3.12
|
||||
|
||||
- repo: https://github.com/PyCQA/pylint.git
|
||||
rev: v3.0.0a6
|
||||
rev: v3.3.0
|
||||
hooks:
|
||||
- id: pylint
|
||||
args:
|
||||
@@ -149,36 +136,4 @@ repos:
|
||||
- --output-format
|
||||
- colorized
|
||||
|
||||
- repo: local
|
||||
hooks:
|
||||
- id: enforced-flake8-version
|
||||
name: Verify that enforced flake8 version stays unchanged
|
||||
description: >-
|
||||
This is a sanity check and fixer that makes sure that
|
||||
the `flake8` version in this file remains matching the
|
||||
corresponding request in the `# enforce-version` comment.
|
||||
# Using Python here because using
|
||||
# shell test does not always work in CIs:
|
||||
entry: >-
|
||||
python -c 'import pathlib, re, sys;
|
||||
pre_commit_config = pathlib.Path(sys.argv[1]);
|
||||
cfg_txt = pre_commit_config.read_text();
|
||||
new_cfg_txt = re.sub(
|
||||
r"(?P<spaces>\s+)rev:\s(?:\d+\.\d+\.\d+)\s{0,2}"
|
||||
r"#\senforce-version:\s(?P<enforced_version>\d+\.\d+\.\d+)"
|
||||
r"[ \t\f\v]*",
|
||||
r"\g<spaces>rev: \g<enforced_version> "
|
||||
r"# enforce-version: \g<enforced_version>",
|
||||
cfg_txt,
|
||||
);
|
||||
cfg_txt != new_cfg_txt and
|
||||
pre_commit_config.write_text(new_cfg_txt)
|
||||
'
|
||||
pass_filenames: true
|
||||
language: system
|
||||
files: >-
|
||||
^\.pre-commit-config\.ya?ml$
|
||||
types:
|
||||
- yaml
|
||||
|
||||
...
|
||||
|
||||
+7
-3
@@ -1,16 +1,18 @@
|
||||
FROM python:3.11-slim
|
||||
FROM python:3.12-slim
|
||||
|
||||
LABEL "maintainer" "Sviatoslav Sydorenko <wk+pypa@sydorenko.org.ua>"
|
||||
LABEL "repository" "https://github.com/pypa/gh-action-pypi-publish"
|
||||
LABEL "homepage" "https://github.com/pypa/gh-action-pypi-publish"
|
||||
LABEL "homepage" "https://github.com/marketplace/actions/pypi-publish"
|
||||
LABEL "org.opencontainers.image.source" "https://github.com/pypa/gh-action-pypi-publish"
|
||||
|
||||
ENV PYTHONDONTWRITEBYTECODE 1
|
||||
ENV PYTHONUNBUFFERED 1
|
||||
|
||||
ENV PIP_NO_CACHE_DIR 1
|
||||
ENV PIP_ROOT_USER_ACTION ignore
|
||||
|
||||
ENV PATH "/root/.local/bin:${PATH}"
|
||||
ENV PYTHONPATH "/root/.local/lib/python3.11/site-packages"
|
||||
ENV PYTHONPATH "/root/.local/lib/python3.12/site-packages"
|
||||
|
||||
COPY requirements requirements
|
||||
RUN \
|
||||
@@ -25,7 +27,9 @@ WORKDIR /app
|
||||
COPY LICENSE.md .
|
||||
COPY twine-upload.sh .
|
||||
COPY print-hash.py .
|
||||
COPY print-pkg-names.py .
|
||||
COPY oidc-exchange.py .
|
||||
COPY attestations.py .
|
||||
|
||||
RUN chmod +x twine-upload.sh
|
||||
ENTRYPOINT ["/app/twine-upload.sh"]
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
[![SWUbanner]][SWUdocs]
|
||||
|
||||
![PyPA badge]
|
||||
[![🧪 GitHub Actions CI/CD workflow tests badge]][GHA workflow runs list]
|
||||
[![pre-commit.ci status badge]][pre-commit.ci results page]
|
||||
[![GH Sponsors badge]][GH Sponsors URL]
|
||||
|
||||
# PyPI publish GitHub Action
|
||||
|
||||
@@ -13,19 +15,31 @@ walkthrough check out the [PyPA guide].
|
||||
If you have any feedback regarding specific action versions, please leave
|
||||
comments in the corresponding [per-release announcement discussions].
|
||||
|
||||
> [!TIP]
|
||||
> A limited number of usage scenarios is supported, including the
|
||||
> [PyPA guide] example. See the [non-goals] for more detail.
|
||||
|
||||
|
||||
## 🌇 `master` branch sunset ❗
|
||||
|
||||
The `master` branch version has been sunset. Please, change the GitHub
|
||||
Action version you use from `master` to `release/v1` or use an exact
|
||||
tag, or a full Git commit SHA.
|
||||
tag, or opt-in to [use a full Git commit SHA] and Dependabot.
|
||||
|
||||
|
||||
## Usage
|
||||
|
||||
### Trusted publishing
|
||||
|
||||
> **NOTE**: Trusted publishing is sometimes referred to by its
|
||||
> [!NOTE]
|
||||
> Trusted publishing cannot be used from within a reusable workflow at this
|
||||
> time. It is recommended to instead create a non-reusable workflow that contains a
|
||||
> job calling your reusable workflow, and then do the trusted publishing step from
|
||||
> a separate job within that non-reusable workflow. Alternatively, you can still
|
||||
> use a username/token inside the reusable workflow.
|
||||
|
||||
> [!NOTE]
|
||||
> Trusted publishing is sometimes referred to by its
|
||||
> underlying technology -- OpenID Connect, or OIDC for short.
|
||||
> If you see references to "OIDC publishing" in the context of PyPI,
|
||||
> this is what they're referring to.
|
||||
@@ -38,7 +52,7 @@ This action supports PyPI's [trusted publishing]
|
||||
implementation, which allows authentication to PyPI without a manually
|
||||
configured API token or username/password combination. To perform
|
||||
[trusted publishing] with this action, your project's
|
||||
publisher must already be configured on PyPI.
|
||||
publisher must already be [configured on PyPI].
|
||||
|
||||
To enter the trusted publishing flow, configure this action's job with the
|
||||
`id-token: write` permission and **without** an explicit username or password:
|
||||
@@ -61,10 +75,11 @@ jobs:
|
||||
uses: pypa/gh-action-pypi-publish@release/v1
|
||||
```
|
||||
|
||||
> **Pro tip**: instead of using branch pointers, like `unstable/v1`, pin
|
||||
versions of Actions that you use to tagged versions or sha1 commit identifiers.
|
||||
This will make your workflows more secure and better reproducible, saving you
|
||||
from sudden and unpleasant surprises.
|
||||
> [!NOTE]
|
||||
> Pro tip: instead of using branch pointers, like `unstable/v1`, pin versions of
|
||||
> Actions that you use to tagged versions or sha1 commit identifiers.
|
||||
> This will make your workflows more secure and better reproducible, saving you
|
||||
> from sudden and unpleasant surprises.
|
||||
|
||||
Other indices that support trusted publishing can also be used, like TestPyPI:
|
||||
|
||||
@@ -76,7 +91,8 @@ Other indices that support trusted publishing can also be used, like TestPyPI:
|
||||
```
|
||||
_(don't forget to update the environment name to `testpypi` or similar!)_
|
||||
|
||||
> **Pro tip**: only set the `id-token: write` permission in the job that does
|
||||
> [!NOTE]
|
||||
> Pro tip: only set the `id-token: write` permission in the job that does
|
||||
> publishing, not globally. Also, try to separate building from publishing
|
||||
> — this makes sure that any scripts maliciously injected into the build
|
||||
> or test environment won't be able to elevate privileges while flying under
|
||||
@@ -89,14 +105,44 @@ filter to the job:
|
||||
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags')
|
||||
```
|
||||
|
||||
### Generating and uploading attestations
|
||||
|
||||
> [!IMPORTANT]
|
||||
> Support for generating and uploading [digital attestations] is currently
|
||||
> experimental and limited only to Trusted Publishing flows using PyPI or TestPyPI.
|
||||
> Support for this feature is not yet stable; the settings and behavior described
|
||||
> below may change without prior notice.
|
||||
|
||||
> [!NOTE]
|
||||
> Generating and uploading digital attestations currently requires
|
||||
> authentication with a [trusted publisher].
|
||||
|
||||
Generating signed [digital attestations] for all the distribution files
|
||||
and uploading them all together is now on by default for all projects
|
||||
using Trusted Publishing. To disable it, set `attestations` as follows:
|
||||
|
||||
```yml
|
||||
with:
|
||||
attestations: false
|
||||
```
|
||||
|
||||
The attestation objects are created using [Sigstore] for each
|
||||
distribution package, signing them with the identity provided
|
||||
by the GitHub's OIDC token associated with the current workflow. This means
|
||||
both the trusted publishing authentication and the attestations are tied to the
|
||||
same identity.
|
||||
|
||||
## Non-goals
|
||||
|
||||
This GitHub Action [has nothing to do with _building package
|
||||
distributions_]. Users are responsible for preparing dists for upload
|
||||
by putting them into the `dist/` folder prior to running this Action.
|
||||
They are typically expected to do this in a _separate GitHub Actions
|
||||
CI/CD job_ running before the one where they call this action and having
|
||||
restricted privileges.
|
||||
|
||||
> **IMPORTANT**: Since this GitHub Action is docker-based, it can only
|
||||
> [!IMPORTANT]
|
||||
> Since this GitHub Action is docker-based, it can only
|
||||
> be used from within GNU/Linux based jobs in GitHub Actions CI/CD
|
||||
> workflows. This is by design and is unlikely to change due to a number
|
||||
> of considerations we rely on.
|
||||
@@ -118,6 +164,72 @@ by putting them into the `dist/` folder prior to running this Action.
|
||||
> sharing the built dists across stages and jobs. Then, use the `needs`
|
||||
> setting to order the build, test and publish stages.
|
||||
|
||||
The expected environment for running `pypi-publish` is the
|
||||
GitHub-provided Ubuntu VM. We are running a smoke-test against
|
||||
`ubuntu-latest` in CI but any currently available numbered versions
|
||||
should do. We'll consider them supported for as long as GitHub itself
|
||||
supports them.
|
||||
|
||||
Running the action in a job that has a `container:` set is not
|
||||
supported. It might work for you but you're on your own when it breaks.
|
||||
If you feel the need to use it, it's likely that you're not following
|
||||
the recommendation of invoking the build automation in a separate job,
|
||||
which is considered a security issue (especially, when using [Trusted
|
||||
Publishing][trusted publisher] that may cause privilege escalation and
|
||||
would enable the attackers to impersonate the GitHub-backed identity of
|
||||
the repository through transitive build dependency poisoning). The
|
||||
solution is to have one job (or multiple, in case of projects with
|
||||
C-extensions) for building the distribution packages, followed by
|
||||
another that publishes them.
|
||||
|
||||
Self-hosted runners are best effort, provided no other unsupported
|
||||
things influence them. We are unable to test this in CI and they may
|
||||
break. This is often the case when using custom runtimes and not the
|
||||
official GitHub-provided VMs. In general, if you follow the
|
||||
recommendation of building in a separate job, you shouldn't need to run
|
||||
this action within a self-hosted runner — it should be possible to
|
||||
build your dists in a self-hosted runner, save them as a GitHub Actions
|
||||
artifact in that job, and then invoke the publishing job that would run
|
||||
within GitHub-provided runners, downloading the artifact with the dists
|
||||
and publishing them. Such separation is the _recommended_/**supported**
|
||||
way of handling this scenario.
|
||||
Our understandng is that Trusted publishing is expected to work on
|
||||
self-hosted runners. It is backed by OIDC. If it doesn't work, you
|
||||
should probably ask GitHub if you missed something. We wouldn't be able
|
||||
to assist here.
|
||||
|
||||
Trusted Publishing cannot be tested in CI at the moment, sadly. It is
|
||||
supported and bugs should be reported but it may take time to sort out
|
||||
as it often requires cross-project collaboration to debug (sometimes,
|
||||
problems occur due to changes in PyPI and not in the action).
|
||||
|
||||
The only case that is explicitly unsupported at the moment is [Trusted
|
||||
Publishing][trusted publisher] in reusable workflows. This requires
|
||||
support on the PyPI side and is being worked on. Please, do not report
|
||||
bugs related to this case. The current recommendation is to put
|
||||
everything else you want into a reusable workflow but keep the job
|
||||
calling `pypi-publish` in a top-level one.
|
||||
|
||||
Invoking `pypi-publish` from composite actions is unsupported. It is not
|
||||
tested. GitHub Runners have limitations and bugs in this case. But more
|
||||
importantly, this is usually an indication of using it insecurely. When
|
||||
using [Trusted Publishing][trusted publisher], it is imperative to keep
|
||||
build machinery invocation in a separate job with restrictive privileges
|
||||
as [Trusted Publishing][trusted publisher] itself requires elevated
|
||||
permissions to make use of OIDC. Our observation is that the users
|
||||
sometimes create in-project composite actions that invoke building and
|
||||
publishing in the same job. As such, we don't seek to support such a
|
||||
dangerous configuration in the first place. The solution is pretty much
|
||||
the same as with the previous problem — use a separate job with
|
||||
dedicated and scoped privileges just for publishing; and invoke that
|
||||
in-project composite action from a different job.
|
||||
|
||||
And finally, invoking `pypi-publish` more than once in the same job is
|
||||
not considered supported. It may work in a limited number of scenarios
|
||||
but please, don't do this. If you want to publish to several indexes,
|
||||
build the dists in one job and add several publishing jobs, one per
|
||||
upload.
|
||||
|
||||
|
||||
## Advanced release management
|
||||
|
||||
@@ -187,9 +299,10 @@ default) setting as follows:
|
||||
skip-existing: true
|
||||
```
|
||||
|
||||
> **Pro tip**: try to avoid enabling this setting where possible. If you
|
||||
have steps for publishing to both PyPI and TestPyPI, consider only using
|
||||
it for the latter, having the former fail loudly on duplicates.
|
||||
> [!NOTE]
|
||||
> Pro tip: try to avoid enabling this setting where possible. If you
|
||||
> have steps for publishing to both PyPI and TestPyPI, consider only using
|
||||
> it for the latter, having the former fail loudly on duplicates.
|
||||
|
||||
### For Debugging
|
||||
|
||||
@@ -239,9 +352,11 @@ on supported platforms (like GitHub).
|
||||
The Dockerfile and associated scripts and documentation in this project
|
||||
are released under the [BSD 3-clause license](LICENSE.md).
|
||||
|
||||
[PyPA badge]:
|
||||
https://img.shields.io/badge/project-yellow?label=PyPA&labelColor=ffd242&color=3775a9
|
||||
|
||||
[🧪 GitHub Actions CI/CD workflow tests badge]:
|
||||
https://github.com/pypa/gh-action-pypi-publish/actions/workflows/self-smoke-test-action.yml/badge.svg?branch=unstable%2Fv1&event=push
|
||||
https://github.com/pypa/gh-action-pypi-publish/actions/workflows/build-and-push-docker-image.yml/badge.svg?branch=unstable%2Fv1&event=push
|
||||
[GHA workflow runs list]:
|
||||
https://github.com/pypa/gh-action-pypi-publish/actions/workflows/self-smoke-test-action.yml?query=branch%3Aunstable%2Fv1
|
||||
|
||||
@@ -250,9 +365,24 @@ https://results.pre-commit.ci/latest/github/pypa/gh-action-pypi-publish/unstable
|
||||
[pre-commit.ci status badge]:
|
||||
https://results.pre-commit.ci/badge/github/pypa/gh-action-pypi-publish/unstable/v1.svg
|
||||
|
||||
[docs badge]:
|
||||
https://img.shields.io/badge/guide-gray?logo=readthedocs&label=PyPUG&color=white
|
||||
[PyPUG guide]:
|
||||
https://packaging.python.org/en/latest/guides/publishing-package-distribution-releases-using-github-actions-ci-cd-workflows/
|
||||
|
||||
[GH Sponsors badge]:
|
||||
https://img.shields.io/badge/%40webknjaz-transparent?logo=githubsponsors&logoColor=%23EA4AAA&label=Sponsor&color=2a313c
|
||||
[GH Sponsors URL]:
|
||||
https://github.com/sponsors/webknjaz
|
||||
|
||||
[use a full Git commit SHA]:
|
||||
https://julienrenaux.fr/2019/12/20/github-actions-security-risk/
|
||||
|
||||
[per-release announcement discussions]:
|
||||
https://github.com/pypa/gh-action-pypi-publish/discussions/categories/announcements
|
||||
|
||||
[non-goals]: #Non-goals
|
||||
|
||||
[Creating & using secrets]:
|
||||
https://help.github.com/en/actions/automating-your-workflow-with-github-actions/creating-and-using-encrypted-secrets
|
||||
[has nothing to do with _building package distributions_]:
|
||||
@@ -269,5 +399,10 @@ https://github.com/vshymanskyy/StandWithUkraine/blob/main/docs/README.md
|
||||
|
||||
[warehouse#12965]: https://github.com/pypi/warehouse/issues/12965
|
||||
[trusted publishing]: https://docs.pypi.org/trusted-publishers/
|
||||
[configured on PyPI]: https://docs.pypi.org/trusted-publishers/adding-a-publisher/
|
||||
|
||||
[how to specify username and password]: #specifying-a-different-username
|
||||
|
||||
[digital attestations]: https://peps.python.org/pep-0740/
|
||||
[Sigstore]: https://www.sigstore.dev/
|
||||
[trusted publisher]: #trusted-publishing
|
||||
|
||||
+94
-11
@@ -80,18 +80,101 @@ inputs:
|
||||
Use `print-hash` instead.
|
||||
required: false
|
||||
default: 'false'
|
||||
attestations:
|
||||
description: >-
|
||||
[EXPERIMENTAL]
|
||||
Enable experimental support for PEP 740 attestations.
|
||||
Only works with PyPI and TestPyPI via Trusted Publishing.
|
||||
required: false
|
||||
default: 'true'
|
||||
branding:
|
||||
color: yellow
|
||||
icon: upload-cloud
|
||||
runs:
|
||||
using: docker
|
||||
image: Dockerfile
|
||||
args:
|
||||
- ${{ inputs.user }}
|
||||
- ${{ inputs.password }}
|
||||
- ${{ inputs.repository-url }}
|
||||
- ${{ inputs.packages-dir }}
|
||||
- ${{ inputs.verify-metadata }}
|
||||
- ${{ inputs.skip-existing }}
|
||||
- ${{ inputs.verbose }}
|
||||
- ${{ inputs.print-hash }}
|
||||
using: composite
|
||||
steps:
|
||||
- name: Fail-fast in unsupported environments
|
||||
if: runner.os != 'Linux'
|
||||
run: |
|
||||
>&2 echo This action is only able to run under GNU/Linux environments
|
||||
exit 1
|
||||
shell: bash -eEuo pipefail {0}
|
||||
- name: Reset path if needed
|
||||
run: |
|
||||
# Reset path if needed
|
||||
# https://github.com/pypa/gh-action-pypi-publish/issues/112
|
||||
if [[ $PATH != *"/usr/bin"* ]]; then
|
||||
echo "\$PATH=$PATH. Resetting \$PATH for GitHub Actions."
|
||||
PATH="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
|
||||
echo "PATH=$PATH" >>"$GITHUB_ENV"
|
||||
echo "$PATH" >>"$GITHUB_PATH"
|
||||
echo "\$PATH reset. \$PATH=$PATH"
|
||||
fi
|
||||
shell: bash
|
||||
- name: Set repo and ref from which to run Docker container action
|
||||
id: set-repo-and-ref
|
||||
run: |
|
||||
# Set repo and ref from which to run Docker container action
|
||||
# to handle cases in which `github.action_` context is not set
|
||||
# https://github.com/actions/runner/issues/2473
|
||||
REF=${{ env.ACTION_REF || env.PR_REF || github.ref_name }}
|
||||
REPO=${{ env.ACTION_REPO || env.PR_REPO || github.repository }}
|
||||
REPO_ID=${{ env.PR_REPO_ID || github.repository_id }}
|
||||
echo "ref=$REF" >>"$GITHUB_OUTPUT"
|
||||
echo "repo=$REPO" >>"$GITHUB_OUTPUT"
|
||||
echo "repo-id=$REPO_ID" >>"$GITHUB_OUTPUT"
|
||||
shell: bash
|
||||
env:
|
||||
ACTION_REF: ${{ github.action_ref }}
|
||||
ACTION_REPO: ${{ github.action_repository }}
|
||||
PR_REF: ${{ github.event.pull_request.head.ref }}
|
||||
PR_REPO: ${{ github.event.pull_request.head.repo.full_name }}
|
||||
PR_REPO_ID: ${{ github.event.pull_request.base.repo.id }}
|
||||
- name: Discover pre-installed Python
|
||||
id: pre-installed-python
|
||||
run: |
|
||||
# 🔎 Discover pre-installed Python
|
||||
echo "python-path=$(command -v python3 || :)" | tee -a "${GITHUB_OUTPUT}"
|
||||
shell: bash
|
||||
- name: Install Python 3
|
||||
if: steps.pre-installed-python.outputs.python-path == ''
|
||||
id: new-python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: 3.x
|
||||
- name: Create Docker container action
|
||||
run: |
|
||||
# Create Docker container action
|
||||
${{
|
||||
steps.pre-installed-python.outputs.python-path == ''
|
||||
&& steps.new-python.outputs.python-path
|
||||
|| steps.pre-installed-python.outputs.python-path
|
||||
}} '${{ github.action_path }}/create-docker-action.py'
|
||||
env:
|
||||
REF: ${{ steps.set-repo-and-ref.outputs.ref }}
|
||||
REPO: ${{ steps.set-repo-and-ref.outputs.repo }}
|
||||
REPO_ID: ${{ steps.set-repo-and-ref.outputs.repo-id }}
|
||||
shell: bash
|
||||
- name: Run Docker container
|
||||
# The generated trampoline action must exist in the allowlisted
|
||||
# runner-defined working directory so it can be referenced by the
|
||||
# relative path starting with `./`.
|
||||
#
|
||||
# This mutates the end-user's workspace slightly but uses a path
|
||||
# that is unlikely to clash with somebody else's use.
|
||||
#
|
||||
# We cannot use randomized paths because the composite action
|
||||
# syntax does not allow accessing variables in `uses:`. This
|
||||
# means that we end up having to hardcode this path both here and
|
||||
# in `create-docker-action.py`.
|
||||
uses: ./.github/.tmp/.generated-actions/run-pypi-publish-in-docker-container
|
||||
with:
|
||||
user: ${{ inputs.user }}
|
||||
password: ${{ inputs.password }}
|
||||
repository-url: ${{ inputs.repository-url || inputs.repository_url }}
|
||||
packages-dir: ${{ inputs.packages-dir || inputs.packages_dir }}
|
||||
verify-metadata: ${{ inputs.verify-metadata || inputs.verify_metadata }}
|
||||
skip-existing: ${{ inputs.skip-existing || inputs.skip_existing }}
|
||||
verbose: ${{ inputs.verbose }}
|
||||
print-hash: ${{ inputs.print-hash || inputs.print_hash }}
|
||||
attestations: ${{ inputs.attestations }}
|
||||
|
||||
+149
@@ -0,0 +1,149 @@
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import NoReturn
|
||||
|
||||
from pypi_attestations import Attestation, Distribution
|
||||
from sigstore.oidc import IdentityError, IdentityToken, detect_credential
|
||||
from sigstore.sign import Signer, SigningContext
|
||||
|
||||
# Be very verbose.
|
||||
sigstore_logger = logging.getLogger('sigstore')
|
||||
sigstore_logger.setLevel(logging.DEBUG)
|
||||
sigstore_logger.addHandler(logging.StreamHandler())
|
||||
|
||||
_GITHUB_STEP_SUMMARY = Path(os.getenv('GITHUB_STEP_SUMMARY'))
|
||||
|
||||
# The top-level error message that gets rendered.
|
||||
# This message wraps one of the other templates/messages defined below.
|
||||
_ERROR_SUMMARY_MESSAGE = """
|
||||
Attestation generation failure:
|
||||
|
||||
{message}
|
||||
|
||||
You're seeing this because the action attempted to generated PEP 740
|
||||
attestations for its inputs, but failed to do so.
|
||||
"""
|
||||
|
||||
# Rendered if OIDC identity token retrieval fails for any reason.
|
||||
_TOKEN_RETRIEVAL_FAILED_MESSAGE = """
|
||||
OpenID Connect token retrieval failed: {identity_error}
|
||||
|
||||
This failure occurred after a successful Trusted Publishing Flow,
|
||||
suggesting a transient error.
|
||||
""" # noqa: S105; not a password
|
||||
|
||||
|
||||
def die(msg: str) -> NoReturn:
|
||||
with _GITHUB_STEP_SUMMARY.open('a', encoding='utf-8') as io:
|
||||
print(_ERROR_SUMMARY_MESSAGE.format(message=msg), file=io)
|
||||
|
||||
# HACK: GitHub Actions' annotations don't work across multiple lines naively;
|
||||
# translating `\n` into `%0A` (i.e., HTML percent-encoding) is known to work.
|
||||
# See: https://github.com/actions/toolkit/issues/193
|
||||
msg = msg.replace('\n', '%0A')
|
||||
print(f'::error::Attestation generation failure: {msg}', file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def debug(msg: str) -> None:
|
||||
print(f'::debug::{msg}', file=sys.stderr)
|
||||
|
||||
|
||||
def collect_dists(packages_dir: Path) -> list[Path]:
|
||||
# Collect all sdists and wheels.
|
||||
dist_paths = [sdist.resolve() for sdist in packages_dir.glob('*.tar.gz')]
|
||||
dist_paths.extend(sdist.resolve() for sdist in packages_dir.glob('*.zip'))
|
||||
dist_paths.extend(whl.resolve() for whl in packages_dir.glob('*.whl'))
|
||||
|
||||
# Make sure everything that looks like a dist actually is one.
|
||||
# We do this up-front to prevent partial signing.
|
||||
if (invalid_dists := [path for path in dist_paths if not path.is_file()]):
|
||||
invalid_dist_list = ', '.join(map(str, invalid_dists))
|
||||
die(
|
||||
'The following paths look like distributions but '
|
||||
f'are not actually files: {invalid_dist_list}',
|
||||
)
|
||||
|
||||
return dist_paths
|
||||
|
||||
|
||||
def assert_attestations_do_not_pre_exist(
|
||||
dist_to_attestation_map: dict[Path, Path],
|
||||
) -> None:
|
||||
existing_attestations = {
|
||||
f'* {dist !s} -> {dist_attestation !s}'
|
||||
for dist, dist_attestation in dist_to_attestation_map.items()
|
||||
if dist_attestation.exists()
|
||||
}
|
||||
if not existing_attestations:
|
||||
return
|
||||
|
||||
existing_attestations_list = '\n'.join(map(str, existing_attestations))
|
||||
error_message = (
|
||||
'The following distributions already have publish attestations:'
|
||||
f'{existing_attestations_list}',
|
||||
)
|
||||
die(error_message)
|
||||
|
||||
|
||||
def compose_attestation_mapping(dist_paths: list[Path]) -> dict[Path, Path]:
|
||||
dist_to_attestation_map = {
|
||||
dist_path: dist_path.with_suffix(
|
||||
f'{dist_path.suffix}.publish.attestation',
|
||||
)
|
||||
for dist_path in dist_paths
|
||||
}
|
||||
|
||||
# We are the publishing step, so there should be no pre-existing publish
|
||||
# attestation. The presence of one indicates user confusion.
|
||||
# Make sure there's no publish attestations on disk.
|
||||
# We do this up-front to prevent partial signing.
|
||||
assert_attestations_do_not_pre_exist(dist_to_attestation_map)
|
||||
|
||||
return dist_to_attestation_map
|
||||
|
||||
|
||||
def attest_dist(
|
||||
dist_path: Path,
|
||||
attestation_path: Path,
|
||||
signer: Signer,
|
||||
) -> None:
|
||||
dist = Distribution.from_file(dist_path)
|
||||
attestation = Attestation.sign(signer, dist)
|
||||
|
||||
attestation_path.write_text(attestation.model_dump_json(), encoding='utf-8')
|
||||
debug(f'saved publish attestation: {dist_path=} {attestation_path=}')
|
||||
|
||||
|
||||
def get_identity_token() -> IdentityToken:
|
||||
# Will raise `sigstore.oidc.IdentityError` if it fails to get the token
|
||||
# from the environment or if the token is malformed.
|
||||
# NOTE: audience is always sigstore.
|
||||
oidc_token = detect_credential()
|
||||
return IdentityToken(oidc_token)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
dist_to_attestation_map = compose_attestation_mapping(
|
||||
collect_dists(Path(sys.argv[1])),
|
||||
)
|
||||
|
||||
try:
|
||||
identity = get_identity_token()
|
||||
except IdentityError as identity_error:
|
||||
# NOTE: We only perform attestations in trusted publishing flows, so we
|
||||
# don't need to re-check for the "PR from fork" error mode, only
|
||||
# generic token retrieval errors. We also render a simpler error,
|
||||
# since permissions can't be to blame at this stage.
|
||||
die(_TOKEN_RETRIEVAL_FAILED_MESSAGE.format(identity_error=identity_error))
|
||||
|
||||
with SigningContext.production().signer(identity, cache=True) as signer:
|
||||
debug(f'attesting to dists: {dist_to_attestation_map.keys()}')
|
||||
for dist_path, attestation_path in dist_to_attestation_map.items():
|
||||
attest_dist(dist_path, attestation_path, signer)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,91 @@
|
||||
import json
|
||||
import os
|
||||
import pathlib
|
||||
|
||||
DESCRIPTION = 'description'
|
||||
REQUIRED = 'required'
|
||||
|
||||
REF = os.environ['REF']
|
||||
REPO = os.environ['REPO']
|
||||
REPO_ID = os.environ['REPO_ID']
|
||||
REPO_ID_GH_ACTION = '178055147'
|
||||
|
||||
ACTION_SHELL_CHECKOUT_PATH = pathlib.Path(__file__).parent.resolve()
|
||||
|
||||
|
||||
def set_image(ref: str, repo: str, repo_id: str) -> str:
|
||||
if repo_id == REPO_ID_GH_ACTION:
|
||||
return str(ACTION_SHELL_CHECKOUT_PATH / 'Dockerfile')
|
||||
docker_ref = ref.replace('/', '-')
|
||||
return f'docker://ghcr.io/{repo}:{docker_ref}'
|
||||
|
||||
|
||||
image = set_image(REF, REPO, REPO_ID)
|
||||
|
||||
action = {
|
||||
'name': '🏃',
|
||||
DESCRIPTION: (
|
||||
'Run Docker container to upload Python distribution packages to PyPI'
|
||||
),
|
||||
'inputs': {
|
||||
'user': {DESCRIPTION: 'PyPI user', REQUIRED: False},
|
||||
'password': {
|
||||
DESCRIPTION: 'Password for your PyPI user or an access token',
|
||||
REQUIRED: False,
|
||||
},
|
||||
'repository-url': {
|
||||
DESCRIPTION: 'The repository URL to use',
|
||||
REQUIRED: False,
|
||||
},
|
||||
'packages-dir': {
|
||||
DESCRIPTION: 'The target directory for distribution',
|
||||
REQUIRED: False,
|
||||
},
|
||||
'verify-metadata': {
|
||||
DESCRIPTION: 'Check metadata before uploading',
|
||||
REQUIRED: False,
|
||||
},
|
||||
'skip-existing': {
|
||||
DESCRIPTION: (
|
||||
'Do not fail if a Python package distribution'
|
||||
' exists in the target package index'
|
||||
),
|
||||
REQUIRED: False,
|
||||
},
|
||||
'verbose': {DESCRIPTION: 'Show verbose output.', REQUIRED: False},
|
||||
'print-hash': {
|
||||
DESCRIPTION: 'Show hash values of files to be uploaded',
|
||||
REQUIRED: False,
|
||||
},
|
||||
'attestations': {
|
||||
DESCRIPTION: (
|
||||
'[EXPERIMENTAL]'
|
||||
' Enable experimental support for PEP 740 attestations.'
|
||||
' Only works with PyPI and TestPyPI via Trusted Publishing.'
|
||||
),
|
||||
REQUIRED: False,
|
||||
},
|
||||
},
|
||||
'runs': {
|
||||
'using': 'docker',
|
||||
'image': image,
|
||||
},
|
||||
}
|
||||
|
||||
# The generated trampoline action must exist in the allowlisted
|
||||
# runner-defined working directory so it can be referenced by the
|
||||
# relative path starting with `./`.
|
||||
#
|
||||
# This mutates the end-user's workspace slightly but uses a path
|
||||
# that is unlikely to clash with somebody else's use.
|
||||
#
|
||||
# We cannot use randomized paths because the composite action
|
||||
# syntax does not allow accessing variables in `uses:`. This
|
||||
# means that we end up having to hardcode this path both here and
|
||||
# in `action.yml`.
|
||||
action_path = pathlib.Path(
|
||||
'.github/.tmp/.generated-actions/'
|
||||
'run-pypi-publish-in-docker-container/action.yml',
|
||||
)
|
||||
action_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
action_path.write_text(json.dumps(action, ensure_ascii=False), encoding='utf-8')
|
||||
+129
-28
@@ -1,3 +1,5 @@
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from http import HTTPStatus
|
||||
@@ -8,7 +10,7 @@ from urllib.parse import urlparse
|
||||
import id # pylint: disable=redefined-builtin
|
||||
import requests
|
||||
|
||||
_GITHUB_STEP_SUMMARY = Path(os.getenv("GITHUB_STEP_SUMMARY"))
|
||||
_GITHUB_STEP_SUMMARY = Path(os.getenv('GITHUB_STEP_SUMMARY'))
|
||||
|
||||
# The top-level error message that gets rendered.
|
||||
# This message wraps one of the other templates/messages defined below.
|
||||
@@ -43,13 +45,49 @@ permissions:
|
||||
```
|
||||
|
||||
Learn more at https://docs.github.com/en/actions/deployment/security-hardening-your-deployments/about-security-hardening-with-openid-connect#adding-permissions-settings.
|
||||
"""
|
||||
""" # noqa: S105; not a password
|
||||
|
||||
# Specialization of the token retrieval failure case, when we know that
|
||||
# the failure cause is use within a third-party PR.
|
||||
_TOKEN_RETRIEVAL_FAILED_FORK_PR_MESSAGE = """
|
||||
OpenID Connect token retrieval failed: {identity_error}
|
||||
|
||||
The workflow context indicates that this action was called from a
|
||||
pull request on a fork. GitHub doesn't give these workflows OIDC permissions,
|
||||
even if `id-token: write` is explicitly configured.
|
||||
|
||||
To fix this, change your publishing workflow to use an event that
|
||||
forks of your repository cannot trigger (such as tag or release
|
||||
creation, or a manually triggered workflow dispatch).
|
||||
""" # noqa: S105; not a password
|
||||
|
||||
# Rendered if the package index refuses the given OIDC token.
|
||||
_SERVER_REFUSED_TOKEN_EXCHANGE_MESSAGE = """
|
||||
Token request failed: the server refused the request for the following reasons:
|
||||
|
||||
{reasons}
|
||||
|
||||
This generally indicates a trusted publisher configuration error, but could
|
||||
also indicate an internal error on GitHub or PyPI's part.
|
||||
|
||||
{rendered_claims}
|
||||
""" # noqa: S105; not a password
|
||||
|
||||
_RENDERED_CLAIMS = """
|
||||
The claims rendered below are **for debugging purposes only**. You should **not**
|
||||
use them to configure a trusted publisher unless they already match your expectations.
|
||||
|
||||
If a claim is not present in the claim set, then it is rendered as `MISSING`.
|
||||
|
||||
* `sub`: `{sub}`
|
||||
* `repository`: `{repository}`
|
||||
* `repository_owner`: `{repository_owner}`
|
||||
* `repository_owner_id`: `{repository_owner_id}`
|
||||
* `workflow_ref`: `{workflow_ref}`
|
||||
* `job_workflow_ref`: `{job_workflow_ref}`
|
||||
* `ref`: `{ref}`
|
||||
|
||||
See https://docs.pypi.org/trusted-publishers/troubleshooting/ for more help.
|
||||
"""
|
||||
|
||||
# Rendered if the package index's token response isn't valid JSON.
|
||||
@@ -59,7 +97,9 @@ Token request failed: the index produced an unexpected
|
||||
|
||||
This strongly suggests a server configuration or downtime issue; wait
|
||||
a few minutes and try again.
|
||||
"""
|
||||
|
||||
You can monitor PyPI's status here: https://status.python.org/
|
||||
""" # noqa: S105; not a password
|
||||
|
||||
# Rendered if the package index's token response isn't a valid API token payload.
|
||||
_SERVER_TOKEN_RESPONSE_MALFORMED_MESSAGE = """
|
||||
@@ -67,26 +107,30 @@ Token response error: the index gave us an invalid response.
|
||||
|
||||
This strongly suggests a server configuration or downtime issue; wait
|
||||
a few minutes and try again.
|
||||
"""
|
||||
""" # noqa: S105; not a password
|
||||
|
||||
|
||||
def die(msg: str) -> NoReturn:
|
||||
with _GITHUB_STEP_SUMMARY.open("a", encoding="utf-8") as io:
|
||||
with _GITHUB_STEP_SUMMARY.open('a', encoding='utf-8') as io:
|
||||
print(_ERROR_SUMMARY_MESSAGE.format(message=msg), file=io)
|
||||
|
||||
print(f"::error::Trusted publishing exchange failure: {msg}", file=sys.stderr)
|
||||
# HACK: GitHub Actions' annotations don't work across multiple lines naively;
|
||||
# translating `\n` into `%0A` (i.e., HTML percent-encoding) is known to work.
|
||||
# See: https://github.com/actions/toolkit/issues/193
|
||||
msg = msg.replace('\n', '%0A')
|
||||
print(f'::error::Trusted publishing exchange failure: {msg}', file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def debug(msg: str):
|
||||
print(f"::debug::{msg.title()}", file=sys.stderr)
|
||||
print(f'::debug::{msg.title()}', file=sys.stderr)
|
||||
|
||||
|
||||
def get_normalized_input(name: str) -> str | None:
|
||||
name = f"INPUT_{name.upper()}"
|
||||
name = f'INPUT_{name.upper()}'
|
||||
if val := os.getenv(name):
|
||||
return val
|
||||
return os.getenv(name.replace("-", "_"))
|
||||
return os.getenv(name.replace('-', '_'))
|
||||
|
||||
|
||||
def assert_successful_audience_call(resp: requests.Response, domain: str):
|
||||
@@ -98,13 +142,13 @@ def assert_successful_audience_call(resp: requests.Response, domain: str):
|
||||
# This index supports OIDC, but forbids the client from using
|
||||
# it (either because it's disabled, ratelimited, etc.)
|
||||
die(
|
||||
f"audience retrieval failed: repository at {domain} has trusted publishing disabled",
|
||||
f'audience retrieval failed: repository at {domain} has trusted publishing disabled',
|
||||
)
|
||||
case HTTPStatus.NOT_FOUND:
|
||||
# This index does not support OIDC.
|
||||
die(
|
||||
"audience retrieval failed: repository at "
|
||||
f"{domain} does not indicate trusted publishing support",
|
||||
'audience retrieval failed: repository at '
|
||||
f'{domain} does not indicate trusted publishing support',
|
||||
)
|
||||
case other:
|
||||
status = HTTPStatus(other)
|
||||
@@ -112,34 +156,84 @@ def assert_successful_audience_call(resp: requests.Response, domain: str):
|
||||
# something we expect. This can happen if the index is broken, in maintenance mode,
|
||||
# misconfigured, etc.
|
||||
die(
|
||||
"audience retrieval failed: repository at "
|
||||
f"{domain} responded with unexpected {other}: {status.phrase}",
|
||||
'audience retrieval failed: repository at '
|
||||
f'{domain} responded with unexpected {other}: {status.phrase}',
|
||||
)
|
||||
|
||||
|
||||
repository_url = get_normalized_input("repository-url")
|
||||
def render_claims(token: str) -> str:
|
||||
_, payload, _ = token.split('.', 2)
|
||||
|
||||
# urlsafe_b64decode needs padding; JWT payloads don't contain any.
|
||||
payload += '=' * (4 - (len(payload) % 4))
|
||||
claims = json.loads(base64.urlsafe_b64decode(payload))
|
||||
|
||||
def _get(name: str) -> str: # noqa: WPS430
|
||||
return claims.get(name, 'MISSING')
|
||||
|
||||
return _RENDERED_CLAIMS.format(
|
||||
sub=_get('sub'),
|
||||
repository=_get('repository'),
|
||||
repository_owner=_get('repository_owner'),
|
||||
repository_owner_id=_get('repository_owner_id'),
|
||||
workflow_ref=_get('workflow_ref'),
|
||||
job_workflow_ref=_get('job_workflow_ref'),
|
||||
ref=_get('ref'),
|
||||
)
|
||||
|
||||
|
||||
def event_is_third_party_pr() -> bool:
|
||||
# Non-`pull_request` events cannot be from third-party PRs.
|
||||
if os.getenv('GITHUB_EVENT_NAME') != 'pull_request':
|
||||
return False
|
||||
|
||||
event_path = os.getenv('GITHUB_EVENT_PATH')
|
||||
if not event_path:
|
||||
# No GITHUB_EVENT_PATH indicates a weird GitHub or runner bug.
|
||||
debug('unexpected: no GITHUB_EVENT_PATH to check')
|
||||
return False
|
||||
|
||||
try:
|
||||
event = json.loads(Path(event_path).read_bytes())
|
||||
except json.JSONDecodeError:
|
||||
debug('unexpected: GITHUB_EVENT_PATH does not contain valid JSON')
|
||||
return False
|
||||
|
||||
try:
|
||||
return event['pull_request']['head']['repo']['fork']
|
||||
except KeyError:
|
||||
return False
|
||||
|
||||
|
||||
repository_url = get_normalized_input('repository-url')
|
||||
repository_domain = urlparse(repository_url).netloc
|
||||
token_exchange_url = f"https://{repository_domain}/_/oidc/github/mint-token"
|
||||
token_exchange_url = f'https://{repository_domain}/_/oidc/mint-token'
|
||||
|
||||
# Indices are expected to support `https://{domain}/_/oidc/audience`,
|
||||
# which tells OIDC exchange clients which audience to use.
|
||||
audience_url = f"https://{repository_domain}/_/oidc/audience"
|
||||
audience_resp = requests.get(audience_url)
|
||||
audience_url = f'https://{repository_domain}/_/oidc/audience'
|
||||
audience_resp = requests.get(audience_url, timeout=5) # S113 wants a timeout
|
||||
assert_successful_audience_call(audience_resp, repository_domain)
|
||||
|
||||
oidc_audience = audience_resp.json()["audience"]
|
||||
oidc_audience = audience_resp.json()['audience']
|
||||
|
||||
debug(f"selected trusted publishing exchange endpoint: {token_exchange_url}")
|
||||
debug(f'selected trusted publishing exchange endpoint: {token_exchange_url}')
|
||||
|
||||
try:
|
||||
oidc_token = id.detect_credential(audience=oidc_audience)
|
||||
except id.IdentityError as identity_error:
|
||||
die(_TOKEN_RETRIEVAL_FAILED_MESSAGE.format(identity_error=identity_error))
|
||||
cause_msg_tmpl = (
|
||||
_TOKEN_RETRIEVAL_FAILED_FORK_PR_MESSAGE if event_is_third_party_pr()
|
||||
else _TOKEN_RETRIEVAL_FAILED_MESSAGE
|
||||
)
|
||||
for_cause_msg = cause_msg_tmpl.format(identity_error=identity_error)
|
||||
die(for_cause_msg)
|
||||
|
||||
# Now we can do the actual token exchange.
|
||||
mint_token_resp = requests.post(
|
||||
token_exchange_url,
|
||||
json={"token": oidc_token},
|
||||
json={'token': oidc_token},
|
||||
timeout=5, # S113 wants a timeout
|
||||
)
|
||||
|
||||
try:
|
||||
@@ -156,19 +250,26 @@ except requests.JSONDecodeError:
|
||||
# On failure, the JSON response includes the list of errors that
|
||||
# occurred during minting.
|
||||
if not mint_token_resp.ok:
|
||||
reasons = "\n".join(
|
||||
f"* `{error['code']}`: {error['description']}"
|
||||
for error in mint_token_payload["errors"]
|
||||
reasons = '\n'.join(
|
||||
f'* `{error["code"]}`: {error["description"]}'
|
||||
for error in mint_token_payload['errors']
|
||||
)
|
||||
|
||||
die(_SERVER_REFUSED_TOKEN_EXCHANGE_MESSAGE.format(reasons=reasons))
|
||||
rendered_claims = render_claims(oidc_token)
|
||||
|
||||
pypi_token = mint_token_payload.get("token")
|
||||
die(
|
||||
_SERVER_REFUSED_TOKEN_EXCHANGE_MESSAGE.format(
|
||||
reasons=reasons,
|
||||
rendered_claims=rendered_claims,
|
||||
),
|
||||
)
|
||||
|
||||
pypi_token = mint_token_payload.get('token')
|
||||
if pypi_token is None:
|
||||
die(_SERVER_TOKEN_RESPONSE_MALFORMED_MESSAGE)
|
||||
|
||||
# Mask the newly minted PyPI token, so that we don't accidentally leak it in logs.
|
||||
print(f"::add-mask::{pypi_token}", file=sys.stderr)
|
||||
print(f'::add-mask::{pypi_token}', file=sys.stderr)
|
||||
|
||||
# This final print will be captured by the subshell in `twine-upload.sh`.
|
||||
print(pypi_token)
|
||||
|
||||
+8
-8
@@ -2,17 +2,17 @@ import hashlib
|
||||
import pathlib
|
||||
import sys
|
||||
|
||||
packages_dir = pathlib.Path(sys.argv[1]).resolve().absolute()
|
||||
packages_dir = pathlib.Path(sys.argv[1]).resolve()
|
||||
|
||||
print("Showing hash values of files to be uploaded:")
|
||||
print('Showing hash values of files to be uploaded:')
|
||||
|
||||
for file_object in packages_dir.iterdir():
|
||||
sha256 = hashlib.sha256()
|
||||
md5 = hashlib.md5()
|
||||
md5 = hashlib.md5() # noqa: S324; only use for reference
|
||||
blake2_256 = hashlib.blake2b(digest_size=256 // 8)
|
||||
|
||||
print(file_object)
|
||||
print("")
|
||||
print('')
|
||||
|
||||
content = file_object.read_bytes()
|
||||
|
||||
@@ -20,7 +20,7 @@ for file_object in packages_dir.iterdir():
|
||||
md5.update(content)
|
||||
blake2_256.update(content)
|
||||
|
||||
print(f"SHA256: {sha256.hexdigest()}")
|
||||
print(f"MD5: {md5.hexdigest()}")
|
||||
print(f"BLAKE2-256: {blake2_256.hexdigest()}")
|
||||
print("")
|
||||
print(f'SHA256: {sha256.hexdigest()}')
|
||||
print(f'MD5: {md5.hexdigest()}')
|
||||
print(f'BLAKE2-256: {blake2_256.hexdigest()}')
|
||||
print('')
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import pathlib
|
||||
import sys
|
||||
|
||||
from packaging import utils
|
||||
|
||||
|
||||
def debug(msg: str):
|
||||
print(f'::debug::{msg.title()}', file=sys.stderr)
|
||||
|
||||
|
||||
def safe_parse_pkg_name(file_path: pathlib.Path) -> str | None:
|
||||
if file_path.suffix == '.whl':
|
||||
try:
|
||||
return utils.parse_wheel_filename(file_path.name)[0]
|
||||
except utils.InvalidWheelFilename:
|
||||
debug(f'Invalid wheel filename: {file_path.name}')
|
||||
return None
|
||||
elif file_path.suffix == '.gz':
|
||||
try:
|
||||
return utils.parse_sdist_filename(file_path.name)[0]
|
||||
except utils.InvalidSdistFilename:
|
||||
debug(f'Invalid sdist filename: {file_path.name}')
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
packages_dir = pathlib.Path(sys.argv[1]).resolve()
|
||||
|
||||
pkg_names = {
|
||||
pkg_name for file_path in packages_dir.iterdir() if
|
||||
(pkg_name := safe_parse_pkg_name(file_path)) is not None
|
||||
}
|
||||
|
||||
for package_name in sorted(pkg_names):
|
||||
print(package_name)
|
||||
@@ -0,0 +1,20 @@
|
||||
###############################################################################
|
||||
# #
|
||||
# This file is only meant to exclude broken dependency versions, not feature #
|
||||
# dependencies. #
|
||||
# #
|
||||
# GUIDELINES: #
|
||||
# 1. Only list PyPI project versions that need to be excluded using `!=` #
|
||||
# and `<`. #
|
||||
# 2. It is allowed to have transitive dependency limitations in this file. #
|
||||
# 3. Apply bare minimum constraints under narrow conditions, use #
|
||||
# environment markers if possible. E.g. `; python_version < "3.12"`. #
|
||||
# 4. Whenever there are no constraints, let the file and this header #
|
||||
# remain in Git. #
|
||||
# #
|
||||
###############################################################################
|
||||
|
||||
# NOTE: 1.12.0 and later enable support for metadata 2.4
|
||||
# NOTE: This can be dropped once twine stops using pkginfo
|
||||
# Ref: https://github.com/pypa/twine/pull/1180
|
||||
pkginfo >= 1.12.0
|
||||
@@ -1,12 +1,12 @@
|
||||
#
|
||||
# This file is autogenerated by pip-compile with Python 3.11
|
||||
# This file is autogenerated by pip-compile with Python 3.12
|
||||
# by the following command:
|
||||
#
|
||||
# pip-compile --allow-unsafe --output-file=requirements/runtime-prerequisites.txt --resolver=backtracking --strip-extras requirements/runtime-prerequisites.in
|
||||
# pip-compile --allow-unsafe --config=../.pip-tools.toml --output-file=runtime-prerequisites.txt --strip-extras runtime-prerequisites.in
|
||||
#
|
||||
pip-with-requires-python==1.0.1
|
||||
# via -r requirements/runtime-prerequisites.in
|
||||
# via -r runtime-prerequisites.in
|
||||
|
||||
# The following packages are considered to be unsafe in a requirements file:
|
||||
pip==22.3.1
|
||||
pip==24.0
|
||||
# via pip-with-requires-python
|
||||
|
||||
+12
-11
@@ -1,6 +1,10 @@
|
||||
twine
|
||||
-c runtime-constraints.in # limits known broken versions
|
||||
|
||||
# NOTE: Used to detect an ambient OIDC credential for OIDC publishing.
|
||||
# NOTE: v6 is needed to support metadata v2.4
|
||||
twine >= 6.0
|
||||
|
||||
# NOTE: Used to detect an ambient OIDC credential for OIDC publishing,
|
||||
# NOTE: as well as PEP 740 attestations.
|
||||
id ~= 1.0
|
||||
|
||||
# NOTE: This is pulled in transitively through `twine`, but we also declare
|
||||
@@ -8,12 +12,9 @@ id ~= 1.0
|
||||
# Ref: https://github.com/di/id
|
||||
requests
|
||||
|
||||
# NOTE: `pkginfo` is a transitive dependency for us that is coming from Twine.
|
||||
# NOTE: It is declared here only to avoid installing a broken combination of
|
||||
# NOTE: the distribution packages. This should be removed once a fixed version
|
||||
# NOTE: of Twine is out.
|
||||
# Refs:
|
||||
# * https://github.com/pypa/gh-action-pypi-publish/issues/107
|
||||
# * https://github.com/pypa/twine/issues/940
|
||||
# * https://github.com/pypa/twine/pull/941
|
||||
pkginfo != 1.9.0
|
||||
# NOTE: Used to generate attestations.
|
||||
pypi-attestations ~= 0.0.15
|
||||
sigstore ~= 3.5.1
|
||||
|
||||
# NOTE: Used to detect the PyPI package name from the distribution files
|
||||
packaging
|
||||
|
||||
+119
-46
@@ -1,80 +1,153 @@
|
||||
#
|
||||
# This file is autogenerated by pip-compile with Python 3.11
|
||||
# This file is autogenerated by pip-compile with Python 3.12
|
||||
# by the following command:
|
||||
#
|
||||
# pip-compile --allow-unsafe --output-file=requirements/runtime.txt --resolver=backtracking --strip-extras requirements/runtime.in
|
||||
# pip-compile --allow-unsafe --config=../.pip-tools.toml --output-file=runtime.txt --strip-extras runtime.in
|
||||
#
|
||||
bleach==5.0.1
|
||||
# via readme-renderer
|
||||
certifi==2022.12.7
|
||||
annotated-types==0.6.0
|
||||
# via pydantic
|
||||
betterproto==2.0.0b6
|
||||
# via sigstore-protobuf-specs
|
||||
certifi==2024.2.2
|
||||
# via requests
|
||||
cffi==1.15.1
|
||||
cffi==1.16.0
|
||||
# via cryptography
|
||||
charset-normalizer==2.1.1
|
||||
charset-normalizer==3.3.2
|
||||
# via requests
|
||||
commonmark==0.9.1
|
||||
# via rich
|
||||
cryptography==39.0.1
|
||||
# via secretstorage
|
||||
docutils==0.19
|
||||
# via readme-renderer
|
||||
id==1.0.0
|
||||
# via -r requirements/runtime.in
|
||||
idna==3.4
|
||||
# via requests
|
||||
importlib-metadata==5.1.0
|
||||
cryptography==42.0.7
|
||||
# via
|
||||
# keyring
|
||||
# twine
|
||||
jaraco-classes==3.2.3
|
||||
# pyopenssl
|
||||
# pypi-attestations
|
||||
# secretstorage
|
||||
# sigstore
|
||||
dnspython==2.6.1
|
||||
# via email-validator
|
||||
docutils==0.21.2
|
||||
# via readme-renderer
|
||||
email-validator==2.1.1
|
||||
# via pydantic
|
||||
grpclib==0.4.7
|
||||
# via betterproto
|
||||
h2==4.1.0
|
||||
# via grpclib
|
||||
hpack==4.0.0
|
||||
# via h2
|
||||
hyperframe==6.0.1
|
||||
# via h2
|
||||
id==1.4.0
|
||||
# via
|
||||
# -r runtime.in
|
||||
# sigstore
|
||||
idna==3.7
|
||||
# via
|
||||
# email-validator
|
||||
# requests
|
||||
jaraco-classes==3.4.0
|
||||
# via keyring
|
||||
jaraco-context==5.3.0
|
||||
# via keyring
|
||||
jaraco-functools==4.0.1
|
||||
# via keyring
|
||||
jeepney==0.8.0
|
||||
# via
|
||||
# keyring
|
||||
# secretstorage
|
||||
keyring==23.11.0
|
||||
keyring==25.2.1
|
||||
# via twine
|
||||
more-itertools==9.0.0
|
||||
# via jaraco-classes
|
||||
pkginfo==1.9.2
|
||||
markdown-it-py==3.0.0
|
||||
# via rich
|
||||
mdurl==0.1.2
|
||||
# via markdown-it-py
|
||||
more-itertools==10.2.0
|
||||
# via
|
||||
# -r requirements/runtime.in
|
||||
# jaraco-classes
|
||||
# jaraco-functools
|
||||
multidict==6.0.5
|
||||
# via grpclib
|
||||
nh3==0.2.17
|
||||
# via readme-renderer
|
||||
packaging==24.1
|
||||
# via
|
||||
# -r runtime.in
|
||||
# pypi-attestations
|
||||
# twine
|
||||
pycparser==2.21
|
||||
pkginfo==1.12.0
|
||||
# via
|
||||
# -c runtime-constraints.in
|
||||
# twine
|
||||
platformdirs==4.2.2
|
||||
# via sigstore
|
||||
pyasn1==0.6.0
|
||||
# via
|
||||
# pypi-attestations
|
||||
# sigstore
|
||||
pycparser==2.22
|
||||
# via cffi
|
||||
pydantic==1.10.6
|
||||
# via id
|
||||
pygments==2.13.0
|
||||
pydantic==2.7.1
|
||||
# via
|
||||
# id
|
||||
# pypi-attestations
|
||||
# sigstore
|
||||
# sigstore-rekor-types
|
||||
pydantic-core==2.18.2
|
||||
# via pydantic
|
||||
pygments==2.18.0
|
||||
# via
|
||||
# readme-renderer
|
||||
# rich
|
||||
readme-renderer==37.3
|
||||
pyjwt==2.8.0
|
||||
# via sigstore
|
||||
pyopenssl==24.1.0
|
||||
# via sigstore
|
||||
pypi-attestations==0.0.15
|
||||
# via -r runtime.in
|
||||
python-dateutil==2.9.0.post0
|
||||
# via betterproto
|
||||
readme-renderer==43.0
|
||||
# via twine
|
||||
requests==2.28.1
|
||||
requests==2.32.3
|
||||
# via
|
||||
# -r requirements/runtime.in
|
||||
# -r runtime.in
|
||||
# id
|
||||
# requests-toolbelt
|
||||
# sigstore
|
||||
# tuf
|
||||
# twine
|
||||
requests-toolbelt==0.10.1
|
||||
requests-toolbelt==1.0.0
|
||||
# via twine
|
||||
rfc3986==2.0.0
|
||||
# via twine
|
||||
rich==12.6.0
|
||||
# via twine
|
||||
rfc8785==0.1.2
|
||||
# via sigstore
|
||||
rich==13.7.1
|
||||
# via
|
||||
# sigstore
|
||||
# twine
|
||||
secretstorage==3.3.3
|
||||
# via keyring
|
||||
securesystemslib==1.0.0
|
||||
# via tuf
|
||||
sigstore==3.5.1
|
||||
# via
|
||||
# -r runtime.in
|
||||
# pypi-attestations
|
||||
sigstore-protobuf-specs==0.3.2
|
||||
# via
|
||||
# pypi-attestations
|
||||
# sigstore
|
||||
sigstore-rekor-types==0.0.13
|
||||
# via sigstore
|
||||
six==1.16.0
|
||||
# via bleach
|
||||
twine==4.0.1
|
||||
# via -r requirements/runtime.in
|
||||
typing-extensions==4.5.0
|
||||
# via pydantic
|
||||
urllib3==1.26.13
|
||||
# via python-dateutil
|
||||
tuf==5.0.0
|
||||
# via sigstore
|
||||
twine==6.0.1
|
||||
# via -r runtime.in
|
||||
typing-extensions==4.11.0
|
||||
# via
|
||||
# pydantic
|
||||
# pydantic-core
|
||||
urllib3==2.2.1
|
||||
# via
|
||||
# requests
|
||||
# twine
|
||||
webencodings==0.5.1
|
||||
# via bleach
|
||||
zipp==3.11.0
|
||||
# via importlib-metadata
|
||||
|
||||
+104
-11
@@ -39,24 +39,108 @@ INPUT_PACKAGES_DIR="$(get-normalized-input 'packages-dir')"
|
||||
INPUT_VERIFY_METADATA="$(get-normalized-input 'verify-metadata')"
|
||||
INPUT_SKIP_EXISTING="$(get-normalized-input 'skip-existing')"
|
||||
INPUT_PRINT_HASH="$(get-normalized-input 'print-hash')"
|
||||
INPUT_ATTESTATIONS="$(get-normalized-input 'attestations')"
|
||||
|
||||
if [[ "${INPUT_USER}" == "__token__" && -z "${INPUT_PASSWORD}" ]] ; then
|
||||
REPOSITORY_NAME="$(echo ${GITHUB_REPOSITORY} | cut -d'/' -f2)"
|
||||
WORKFLOW_FILENAME="$(echo ${GITHUB_WORKFLOW_REF} | cut -d'/' -f5- | cut -d'@' -f1)"
|
||||
PACKAGE_NAMES=()
|
||||
while IFS='' read -r line; do PACKAGE_NAMES+=("$line"); done < <(python /app/print-pkg-names.py "${INPUT_PACKAGES_DIR%%/}")
|
||||
|
||||
PASSWORD_DEPRECATION_NUDGE="::error title=Password-based uploads disabled::\
|
||||
As of 2024, PyPI requires all users to enable Two-Factor \
|
||||
Authentication. This consequently requires all users to switch \
|
||||
to either Trusted Publishers (preferred) or API tokens for package \
|
||||
uploads. Read more: \
|
||||
https://blog.pypi.org/posts/2023-05-25-securing-pypi-with-2fa/"
|
||||
|
||||
TRUSTED_PUBLISHING_NUDGE="::warning title=Upgrade to Trusted Publishing::\
|
||||
Trusted Publishers allows publishing packages to PyPI from automated \
|
||||
environments like GitHub Actions without needing to use username/password \
|
||||
combinations or API tokens to authenticate with PyPI. Read more: \
|
||||
https://docs.pypi.org/trusted-publishers"
|
||||
|
||||
ATTESTATIONS_WITHOUT_TP_WARNING="::warning title=attestations input ignored::\
|
||||
The workflow was run with the 'attestations: true' input, but an explicit \
|
||||
password was also set, disabling Trusted Publishing. As a result, the \
|
||||
attestations input is ignored."
|
||||
|
||||
ATTESTATIONS_WRONG_INDEX_WARNING="::warning title=attestations input ignored::\
|
||||
The workflow was run with 'attestations: true' input, but the specified \
|
||||
repository URL does not support PEP 740 attestations. As a result, the \
|
||||
attestations input is ignored."
|
||||
|
||||
MAGIC_LINK_MESSAGE="A new Trusted Publisher for the currently running \
|
||||
publishing workflow can be created by accessing the following link(s) while \
|
||||
logged-in as an owner of the package(s):"
|
||||
|
||||
|
||||
[[ "${INPUT_USER}" == "__token__" && -z "${INPUT_PASSWORD}" ]] \
|
||||
&& TRUSTED_PUBLISHING=true || TRUSTED_PUBLISHING=false
|
||||
|
||||
if [[ "${TRUSTED_PUBLISHING}" == true || ! "${INPUT_REPOSITORY_URL}" =~ pypi\.org || ${#PACKAGE_NAMES[@]} -eq 0 ]] ; then
|
||||
TRUSTED_PUBLISHING_MAGIC_LINK_NUDGE=""
|
||||
else
|
||||
if [[ "${INPUT_REPOSITORY_URL}" =~ test\.pypi\.org ]] ; then
|
||||
INDEX_URL="https://test.pypi.org"
|
||||
else
|
||||
INDEX_URL="https://pypi.org"
|
||||
fi
|
||||
ALL_LINKS=""
|
||||
for PACKAGE_NAME in "${PACKAGE_NAMES[@]}"; do
|
||||
LINK="- ${INDEX_URL}/manage/project/${PACKAGE_NAME}/settings/publishing/?provider=github&owner=${GITHUB_REPOSITORY_OWNER}&repository=${REPOSITORY_NAME}&workflow_filename=${WORKFLOW_FILENAME}"
|
||||
ALL_LINKS+="$LINK"$'\n'
|
||||
done
|
||||
|
||||
# Construct the summary message without the warning header
|
||||
MAGIC_LINK_MESSAGE_WITH_LINKS="${MAGIC_LINK_MESSAGE}"$'\n'"${ALL_LINKS}"
|
||||
echo "${MAGIC_LINK_MESSAGE_WITH_LINKS}" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
# The actual nudge in the log is formatted as a warning
|
||||
TRUSTED_PUBLISHING_MAGIC_LINK_NUDGE="::warning title=Create a Trusted Publisher::${MAGIC_LINK_MESSAGE_WITH_LINKS}"
|
||||
fi
|
||||
|
||||
if [[ "${INPUT_ATTESTATIONS}" != "false" ]] ; then
|
||||
# Setting `attestations: true` without Trusted Publishing indicates
|
||||
# user confusion, since attestations (currently) require it.
|
||||
if ! "${TRUSTED_PUBLISHING}" ; then
|
||||
echo "${ATTESTATIONS_WITHOUT_TP_WARNING}"
|
||||
INPUT_ATTESTATIONS="false"
|
||||
fi
|
||||
|
||||
# Setting `attestations: true` with an index other than PyPI or TestPyPI
|
||||
# indicates user confusion, since attestations are not supported on other
|
||||
# indices presently.
|
||||
if [[ ! "${INPUT_REPOSITORY_URL}" =~ pypi\.org ]] ; then
|
||||
echo "${ATTESTATIONS_WRONG_INDEX_WARNING}"
|
||||
INPUT_ATTESTATIONS="false"
|
||||
fi
|
||||
fi
|
||||
|
||||
if "${TRUSTED_PUBLISHING}" ; then
|
||||
# No password supplied by the user implies that we're in the OIDC flow;
|
||||
# retrieve the OIDC credential and exchange it for a PyPI API token.
|
||||
echo \
|
||||
'::notice::Attempting to perform trusted publishing exchange' \
|
||||
'to retrieve a temporary short-lived API token for authentication' \
|
||||
"against ${INPUT_REPOSITORY_URL} due to __token__ username with no" \
|
||||
'supplied password field'
|
||||
echo "::debug::Authenticating to ${INPUT_REPOSITORY_URL} via Trusted Publishing"
|
||||
INPUT_PASSWORD="$(python /app/oidc-exchange.py)"
|
||||
elif [[ "${INPUT_USER}" == '__token__' ]]; then
|
||||
echo \
|
||||
'::notice::Using a user-provided API token for authentication' \
|
||||
'::debug::Using a user-provided API token for authentication' \
|
||||
"against ${INPUT_REPOSITORY_URL}"
|
||||
|
||||
if [[ "${INPUT_REPOSITORY_URL}" =~ pypi\.org ]]; then
|
||||
echo "${TRUSTED_PUBLISHING_NUDGE}"
|
||||
echo "${TRUSTED_PUBLISHING_MAGIC_LINK_NUDGE}"
|
||||
fi
|
||||
else
|
||||
echo \
|
||||
'::notice::Using a username + password pair for authentication' \
|
||||
"against ${INPUT_REPOSITORY_URL}}"
|
||||
'::debug::Using a username + password pair for authentication' \
|
||||
"against ${INPUT_REPOSITORY_URL}"
|
||||
|
||||
if [[ "${INPUT_REPOSITORY_URL}" =~ pypi\.org ]]; then
|
||||
echo "${PASSWORD_DEPRECATION_NUDGE}"
|
||||
echo "${TRUSTED_PUBLISHING_NUDGE}"
|
||||
echo "${TRUSTED_PUBLISHING_MAGIC_LINK_NUDGE}"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[
|
||||
@@ -102,15 +186,24 @@ if [[ ${INPUT_VERIFY_METADATA,,} != "false" ]] ; then
|
||||
twine check ${INPUT_PACKAGES_DIR%%/}/*
|
||||
fi
|
||||
|
||||
TWINE_EXTRA_ARGS=
|
||||
TWINE_EXTRA_ARGS=--disable-progress-bar
|
||||
if [[ ${INPUT_SKIP_EXISTING,,} != "false" ]] ; then
|
||||
TWINE_EXTRA_ARGS=--skip-existing
|
||||
TWINE_EXTRA_ARGS="${TWINE_EXTRA_ARGS} --skip-existing"
|
||||
fi
|
||||
|
||||
if [[ ${INPUT_VERBOSE,,} != "false" ]] ; then
|
||||
TWINE_EXTRA_ARGS="--verbose $TWINE_EXTRA_ARGS"
|
||||
fi
|
||||
|
||||
if [[ ${INPUT_ATTESTATIONS,,} != "false" ]] ; then
|
||||
# NOTE: Intentionally placed after `twine check`, to prevent attestation
|
||||
# NOTE: generation on distributions with invalid metadata.
|
||||
echo "::notice::Generating and uploading digital attestations"
|
||||
python /app/attestations.py "${INPUT_PACKAGES_DIR%%/}"
|
||||
|
||||
TWINE_EXTRA_ARGS="--attestations $TWINE_EXTRA_ARGS"
|
||||
fi
|
||||
|
||||
if [[ ${INPUT_PRINT_HASH,,} != "false" || ${INPUT_VERBOSE,,} != "false" ]] ; then
|
||||
python /app/print-hash.py ${INPUT_PACKAGES_DIR%%/}
|
||||
fi
|
||||
|
||||
Reference in New Issue
Block a user