From 884baedb543649b43fc0df4b7428657c35d28bfc Mon Sep 17 00:00:00 2001 From: Alex Gaynor Date: Mon, 22 Jul 2024 12:50:37 -0400 Subject: [PATCH] Import improvements from cryptography wheel building and release (#840) Upload to PyPI from GHA --- .github/workflows/pypi-publish.yml | 87 ++++++++++++++++++++++ .github/workflows/wheel-builder.yml | 6 ++ release.py | 108 +--------------------------- 3 files changed, 94 insertions(+), 107 deletions(-) create mode 100644 .github/workflows/pypi-publish.yml diff --git a/.github/workflows/pypi-publish.yml b/.github/workflows/pypi-publish.yml new file mode 100644 index 00000000..14f2152a --- /dev/null +++ b/.github/workflows/pypi-publish.yml @@ -0,0 +1,87 @@ +name: Publish to PyPI + +on: + workflow_dispatch: + inputs: + run_id: + description: The run of wheel-builder to use for finding artifacts. + required: true + environment: + description: Which PyPI environment to upload to + required: true + type: choice + options: ["testpypi", "pypi"] + workflow_run: + workflows: ["Wheel Builder"] + types: [completed] + +permissions: + contents: read + +jobs: + publish: + runs-on: ubuntu-latest + # We're not actually verifying that the triggering push event was for a + # tag, because github doesn't expose enough information to do so. + # wheel-builder.yml currently only has push events for tags. + if: github.event_name == 'workflow_dispatch' || (github.event.workflow_run.event == 'push' && github.event.workflow_run.conclusion == 'success') + permissions: + id-token: "write" + attestations: "write" + steps: + - run: echo "$EVENT_CONTEXT" + env: + EVENT_CONTEXT: ${{ toJson(github.event) }} + - uses: actions/setup-python@39cd14951b08e74b54015e9e001cdefcf80e669f # v5.1.1 + with: + python-version: "3.11" + - uses: dawidd6/action-download-artifact@bf251b5aa9c2f7eeb574a96ee720e24f801b7c11 # v6 + with: + path: dist/ + run_id: ${{ github.event.inputs.run_id || github.event.workflow_run.id }} + - run: pip install twine requests + + - run: | + echo "OIDC_AUDIENCE=pypi" >> $GITHUB_ENV + echo "PYPI_DOMAIN=pypi.org" >> $GITHUB_ENV + echo "TWINE_REPOSITORY=pypi" >> $GITHUB_ENV + echo "TWINE_USERNAME=__token__" >> $GITHUB_ENV + if: github.event_name == 'workflow_run' || (github.event_name == 'workflow_dispatch' && github.event.inputs.environment == 'pypi') + - run: | + echo "OIDC_AUDIENCE=testpypi" >> $GITHUB_ENV + echo "PYPI_DOMAIN=test.pypi.org" >> $GITHUB_ENV + echo "TWINE_REPOSITORY=testpypi" >> $GITHUB_ENV + echo "TWINE_USERNAME=__token__" >> $GITHUB_ENV + if: github.event_name == 'workflow_dispatch' && github.event.inputs.environment == 'testpypi' + + - run: | + import os + + import requests + + response = requests.get( + os.environ["ACTIONS_ID_TOKEN_REQUEST_URL"], + params={"audience": os.environ["OIDC_AUDIENCE"]}, + headers={"Authorization": f"bearer {os.environ['ACTIONS_ID_TOKEN_REQUEST_TOKEN']}"} + ) + response.raise_for_status() + token = response.json()["value"] + + response = requests.post(f"https://{os.environ['PYPI_DOMAIN']}/_/oidc/mint-token", json={"token": token}) + response.raise_for_status() + pypi_token = response.json()["token"] + + with open(os.environ["GITHUB_ENV"], "a") as f: + print(f"::add-mask::{pypi_token}") + f.write(f"TWINE_PASSWORD={pypi_token}\n") + shell: python + + - run: twine upload --skip-existing $(find dist/ -type f -name 'bcrypt*') + + # Do not perform attestation for things for TestPyPI. This is because + # there's nothing that would prevent a malicious PyPI from serving a + # signed TestPyPI asset in place of a release intended for PyPI. + - uses: actions/attest-build-provenance@5e9cb68e95676991667494a6a4e59b8a2f13e1d0 # v1.3.3 + with: + subject-path: 'dist/**/bcrypt*' + if: env.TWINE_REPOSITORY == 'pypi' diff --git a/.github/workflows/wheel-builder.yml b/.github/workflows/wheel-builder.yml index a5ea8c25..0043652f 100644 --- a/.github/workflows/wheel-builder.yml +++ b/.github/workflows/wheel-builder.yml @@ -7,6 +7,12 @@ on: version: description: The version to build required: false + # Do not add any non-tag push events without updating pypi-publish.yml. If + # you do, it'll upload wheels to PyPI. + push: + tags: + - '*.*' + - '*.*.*' pull_request: paths: - .github/workflows/wheel-builder.yml diff --git a/release.py b/release.py index 825f8c50..20e02d1a 100644 --- a/release.py +++ b/release.py @@ -10,16 +10,9 @@ # See the License for the specific language governing permissions and # limitations under the License. -import getpass -import io -import json -import os import subprocess -import time -import zipfile import click -import requests def run(*args, **kwargs): @@ -27,113 +20,14 @@ def run(*args, **kwargs): subprocess.check_call(list(args), **kwargs) -def wait_for_build_complete_github_actions(session, token, run_url): - while True: - response = session.get( - run_url, - headers={ - "Content-Type": "application/json", - "Authorization": f"token {token}", - }, - ) - response.raise_for_status() - if response.json()["conclusion"] is not None: - break - time.sleep(3) - - -def download_artifacts_github_actions(session, token, run_url): - response = session.get( - run_url, - headers={ - "Content-Type": "application/json", - "Authorization": f"token {token}", - }, - ) - response.raise_for_status() - - response = session.get( - response.json()["artifacts_url"], - headers={ - "Content-Type": "application/json", - "Authorization": f"token {token}", - }, - ) - response.raise_for_status() - paths = [] - for artifact in response.json()["artifacts"]: - response = session.get( - artifact["archive_download_url"], - headers={ - "Content-Type": "application/json", - "Authorization": f"token {token}", - }, - ) - with zipfile.ZipFile(io.BytesIO(response.content)) as z: - for name in z.namelist(): - if not name.endswith((".whl", ".tar.gz")): - continue - p = z.open(name) - out_path = os.path.join( - os.path.dirname(__file__), - "dist", - os.path.basename(name), - ) - with open(out_path, "wb") as f: - f.write(p.read()) - paths.append(out_path) - return paths - - -def build_github_actions_sdist_wheels(token, version): - session = requests.Session() - - response = session.post( - "https://api.github.com/repos/pyca/bcrypt/actions/workflows/" - "wheel-builder.yml/dispatches", - headers={ - "Content-Type": "application/json", - "Accept": "application/vnd.github.v3+json", - "Authorization": f"token {token}", - }, - data=json.dumps({"ref": "main", "inputs": {"version": version}}), - ) - response.raise_for_status() - - # Give it a few seconds for the run to kick off. - time.sleep(5) - response = session.get( - ( - "https://api.github.com/repos/pyca/bcrypt/actions/workflows/" - "wheel-builder.yml/runs?event=workflow_dispatch" - ), - headers={ - "Content-Type": "application/json", - "Authorization": f"token {token}", - }, - ) - response.raise_for_status() - run_url = response.json()["workflow_runs"][0]["url"] - wait_for_build_complete_github_actions(session, token, run_url) - return download_artifacts_github_actions(session, token, run_url) - - @click.command() @click.argument("version") def release(version): """ ``version`` should be a string like '0.4' or '1.0'. """ - github_token = getpass.getpass("Github person access token: ") - run("git", "tag", "-s", version, "-m", f"{version} release") - run("git", "push", "--tags") - - github_actions_paths = build_github_actions_sdist_wheels( - github_token, version - ) - - run("twine", "upload", *github_actions_paths) + run("git", "push", "--tags", "git@github.com:pyca/bcrypt.git") if __name__ == "__main__":