diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml new file mode 100644 index 0000000000..e36e6e3683 --- /dev/null +++ b/.github/workflows/cd.yml @@ -0,0 +1,87 @@ +name: CD +concurrency: cd + +# Trigger workflow on any completed CI (see further checks below) +on: + workflow_run: + workflows: [CI] + types: [completed] + +jobs: + build: + name: Build + runs-on: ubuntu-latest + # Skip unless CI was successful and ran on release tag, a ref starting with 'v'. + # NOTE: We assume CI does not trigger on branches that start with 'v' (see #1961) + if: >- + github.event.workflow_run.conclusion == 'success' && + startsWith(github.event.workflow_run.head_branch, 'v') + outputs: + release_id: ${{ steps.gh-release.outputs.id }} + steps: + - name: Checkout release tag + uses: actions/checkout@a12a3943b4bdde767164f792f33f40b04645d846 + with: + ref: ${{ github.event.workflow_run.head_branch }} + + - name: Set up Python + uses: actions/setup-python@0ebf233433c08fb9061af664d501c3f3ff0e9e20 + with: + python-version: '3.x' + + - name: Install build dependency + run: python3 -m pip install --upgrade pip build + + - name: Build binary wheel and source tarball + run: python3 -m build --sdist --wheel --outdir dist/ . + + - id: gh-release + name: Publish GitHub release candiate + uses: softprops/action-gh-release@1e07f4398721186383de40550babbdf2b84acfc5 + with: + name: ${{ github.event.workflow_run.head_branch }}-rc + tag_name: ${{ github.event.workflow_run.head_branch }} + body: "Release waiting for review..." + files: dist/* + + - name: Store build artifacts + uses: actions/upload-artifact@6673cd052c4cd6fcf4b4e6e60ea986c889389535 + # NOTE: The GitHub release page contains the release artifacts too, but using + # GitHub upload/download actions seems robuster: there is no need to compute + # download URLs and tampering with artifacts between jobs is more limited. + with: + name: build-artifacts + path: dist + + release: + name: Release + runs-on: ubuntu-latest + needs: build + environment: release + steps: + - name: Fetch build artifacts + uses: actions/download-artifact@fb598a63ae348fa914e94cd0ff38f362e927b741 + with: + name: build-artifacts + path: dist + + - name: Publish binary wheel and source tarball on PyPI + uses: pypa/gh-action-pypi-publish@717ba43cfbb0387f6ce311b169a825772f54d295 + with: + user: __token__ + password: ${{ secrets.PYPI_API_TOKEN }} + + - name: Finalize GitHub release + uses: actions/github-script@9ac08808f993958e9de277fe43a64532a609130e + with: + script: | + await github.rest.repos.updateRelease({ + owner: context.repo.owner, + repo: context.repo.repo, + release_id: '${{ needs.build.outputs.release_id }}', + name: '${{ github.event.workflow_run.head_branch }}', + body: 'See [CHANGELOG.md](https://github.com/' + + context.repo.owner + '/' + context.repo.repo + '/blob/' + + '${{ github.event.workflow_run.head_branch }}'+ + '/docs/CHANGELOG.md) for details.' + }) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f25da73c3c..b899d33ba4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,9 +1,13 @@ name: CI on: + # NOTE: CD relies on this configuration (see #1961) push: branches: - develop + tags: + - v* + pull_request: workflow_dispatch: diff --git a/docs/RELEASE.md b/docs/RELEASE.md index 69f5ef11ad..a49b3f4c48 100644 --- a/docs/RELEASE.md +++ b/docs/RELEASE.md @@ -1,39 +1,50 @@ # Release process -* Ensure you have a backup of all working files and then remove files not tracked by git - `git clean -xdf`. **NOTE**: this will delete all files in the tuf tree that aren't - tracked by git -* Ensure `docs/CHANGELOG.md` contains a one-line summary of each [notable + +**Prerequisites (one-time setup)** + + +1. Go to [PyPI management page](https://pypi.org/manage/account/#api-tokens) and create + an [API token](https://pypi.org/help/#apitoken) with its scope limited to the tuf project. +1. Go to [GitHub + settings](https://github.com/theupdateframework/python-tuf/settings/environments), + create an + [environment](https://docs.github.com/en/actions/deployment/targeting-different-environments/using-environments-for-deployment#creating-an-environment) + called `release` and configure [review + protection](https://docs.github.com/en/actions/deployment/targeting-different-environments/using-environments-for-deployment#required-reviewers). +1. In the environment create a + [secret](https://docs.github.com/en/actions/deployment/targeting-different-environments/using-environments-for-deployment#environment-secrets) + called `PYPI_API_TOKEN` and paste the token created above. + +## Release + +1. Ensure `docs/CHANGELOG.md` contains a one-line summary of each [notable change](https://keepachangelog.com/) since the prior release -* Update `tuf/__init__.py` to the new version number "A.B.C" -* Test packaging, uploading to Test PyPI and installing from a virtual environment - (ensure commands invoking `python` below are using Python 3) - * Remove existing dist build dirs - * Create source dist and wheel `python3 -m build` - * Sign source dist `gpg --detach-sign -a dist/tuf-A.B.C.tar.gz` - * Sign wheel `gpg --detach-sign -a dist/tuf-A.B.C-py3-none-any.whl` - * Upload to test PyPI `twine upload --repository testpypi dist/*` - * Verify the uploaded package at https://test.pypi.org/project/tuf/: - Note that installing packages with pip using test.pypi.org is potentially - dangerous (as dependencies may be squatted): download the file and install - the local file instead. -* Create a PR with updated `CHANGELOG.md` and version bumps -* Once the PR is merged, pull the updated `develop` branch locally -* Create a signed tag matching the updated version number on the merge commit +2. Update `tuf/__init__.py` to the new version number `A.B.C` +3. Create a PR with updated `CHANGELOG.md` and version bumps + +➔ Review PR on GitHub + +4. Once the PR is merged, pull the updated `develop` branch locally +5. Create a signed tag for the version number on the merge commit `git tag --sign vA.B.C -m "vA.B.C"` - * Push the tag to GitHub `git push origin vA.B.C` -* Create a new release on GitHub, copying the `CHANGELOG.md` entries for the - release -* Create a package for the formal release - (ensure commands invoking `python` below are using Python 3) - * Remove existing dist build dirs - * Create source dist and wheel `python3 -m build` - * Sign source dist `gpg --detach-sign -a dist/tuf-A.B.C.tar.gz` - * Sign wheel `gpg --detach-sign -a dist/tuf-A.B.C-py3-none-any.whl` - * Upload to PyPI `twine upload dist/*` - * Verify the package at https://pypi.org/project/tuf/ and by installing with pip -* Attach both signed dists and their detached signatures to the release on GitHub -* `verify_release` should be used to make sure the release artifacts match the - git sources, preferably by another developer on a different machine. -* Announce the release on [#tuf on CNCF Slack](https://cloud-native.slack.com/archives/C8NMD3QJ3) -* Ensure [POUF 1](https://github.com/theupdateframework/taps/blob/master/POUFs/reference-POUF/pouf1.md), for the reference implementation, is up-to-date +6. Push the tag to GitHub `git push origin vA.B.C` + + *A push triggers the [CI workflow](.github/workfows/ci.yml), which, on success, + triggers the [CD workflow](.github/workfows/cd.yml), which builds source dist and + wheel, creates a preliminary GitHub release under `vA.B.C-rc`, and pauses for review.* + +7. Run `verify_release --skip-pypi` locally to make sure a build on your machine matches + the preliminary release artifacts published on GitHub. + +➔ [Review *deployment*](https://docs.github.com/en/actions/managing-workflow-runs/reviewing-deployments) +on GitHub + + *An approval resumes the CD workflow to publish the release on PyPI, and to finalize the + GitHub release (removes `-rc` suffix and updates release notes).* + +8. `verify_release` may be used again to make sure the PyPI release artifacts match the + local build as well. +9. Announce the release on [#tuf on CNCF Slack](https://cloud-native.slack.com/archives/C8NMD3QJ3) +10. Ensure [POUF 1](https://github.com/theupdateframework/taps/blob/master/POUFs/reference-POUF/pouf1.md), + for the reference implementation, is up-to-date diff --git a/tox.ini b/tox.ini index a27758366f..d61df9390e 100644 --- a/tox.ini +++ b/tox.ini @@ -39,7 +39,7 @@ commands = [testenv:lint] changedir = {toxinidir} -lint_dirs = tuf examples tests +lint_dirs = tuf examples tests verify_release commands = black --check --diff {[testenv:lint]lint_dirs} isort --check --diff {[testenv:lint]lint_dirs} diff --git a/verify_release b/verify_release index 6479720184..b521d4faa5 100755 --- a/verify_release +++ b/verify_release @@ -9,6 +9,7 @@ Builds a release from current commit and verifies that the release artifacts on GitHub and PyPI match the built release artifacts. """ +import argparse import json import os import subprocess @@ -17,12 +18,12 @@ from filecmp import dircmp from tempfile import TemporaryDirectory try: + import build as _ # type: ignore import requests - import build except ImportError: - print ("Error: verify_release requires modules 'requests' and 'build':") - print (" pip install requests build") - exit(1) + print("Error: verify_release requires modules 'requests' and 'build':") + print(" pip install requests build") + sys.exit(1) # Project variables # Note that only these project artifacts are supported: @@ -126,9 +127,17 @@ def progress(s: str) -> None: def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument( + "--skip-pypi", + action="store_true", + dest="skip_pypi", + help="Skip PyPI release check.", + ) + args = parser.parse_args() + success = True with TemporaryDirectory() as build_dir: - progress("Building release") build_version = build(build_dir) finished(f"Built release {build_version}") @@ -143,16 +152,17 @@ def main() -> int: if github_version != build_version: finished(f"WARNING: GitHub latest version is {github_version}") - progress("Checking PyPI latest version") - pypi_version = get_pypi_pip_version() - if pypi_version != build_version: - finished(f"WARNING: PyPI latest version is {pypi_version}") - - progress("Downloading release from PyPI") - if not verify_pypi_release(build_version, build_dir): - # This is expected while build is not reproducible - finished("ERROR: PyPI artifacts do not match built release") - success = False + if not args.skip_pypi: + progress("Checking PyPI latest version") + pypi_version = get_pypi_pip_version() + if pypi_version != build_version: + finished(f"WARNING: PyPI latest version is {pypi_version}") + + progress("Downloading release from PyPI") + if not verify_pypi_release(build_version, build_dir): + # This is expected while build is not reproducible + finished("ERROR: PyPI artifacts do not match built release") + success = False progress("Downloading release from GitHub") if not verify_github_release(build_version, build_dir):