├── .flake8 ├── .github ├── CODEOWNERS ├── CONTRIBUTING.md ├── ISSUE_TEMPLATE │ ├── bug_report.md │ ├── feature_request.md │ └── support_request.md ├── blunderbuss.yml ├── release-please.yml ├── release-trigger.yml └── sync-repo-settings.yaml ├── .gitignore ├── .kokoro-autorelease ├── build.sh ├── common.cfg ├── tag.cfg ├── tag.sh ├── trigger.cfg └── trigger.sh ├── .kokoro ├── build.sh ├── common.cfg ├── continuous.cfg ├── populate-release-secrets.sh ├── populate-secrets.sh ├── presubmit.cfg ├── release.sh ├── release │ ├── common.cfg │ └── release.cfg ├── trampoline.sh └── trampoline_release.sh ├── .trampolinerc ├── CHANGELOG.md ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE ├── MANIFEST.in ├── README.md ├── SECURITY.md ├── autorelease ├── __init__.py ├── __main__.py ├── common.py ├── github.py ├── kokoro.py ├── report.xml.j2 ├── reporter.py ├── tag.py └── trigger.py ├── noxfile.py ├── protos ├── __init__.py ├── kokoro_api.proto └── kokoro_api_pb2.py ├── pytest.ini ├── releasetool ├── __init__.py ├── __main__.py ├── circleci.py ├── commands │ ├── __init__.py │ ├── common.py │ ├── publish_reporter.py │ ├── publish_reporter.sh │ ├── start │ │ ├── __init__.py │ │ ├── go.py │ │ ├── java.py │ │ ├── nodejs.py │ │ ├── python.py │ │ ├── python_tool.py │ │ └── ruby.py │ └── tag │ │ ├── __init__.py │ │ ├── cpp.py │ │ ├── dotnet.py │ │ ├── go.py │ │ ├── java.py │ │ ├── nodejs.py │ │ ├── php.py │ │ ├── python.py │ │ ├── python_tool.py │ │ └── ruby.py ├── filehelpers.py ├── git.py ├── github.py ├── secrets.py └── update_check.py ├── requirements-dev.in ├── requirements-dev.txt ├── requirements.in ├── requirements.txt ├── setup.py ├── testing ├── constraints-3.10.txt ├── constraints-3.11.txt ├── constraints-3.12.txt ├── constraints-3.8.txt └── constraints-3.9.txt └── tests ├── __init__.py ├── commands ├── start │ ├── test_python.py │ └── test_ruby.py └── tag │ ├── test_tag_cpp.py │ ├── test_tag_dotnet.py │ ├── test_tag_go.py │ ├── test_tag_java.py │ ├── test_tag_nodejs.py │ ├── test_tag_php.py │ ├── test_tag_python.py │ ├── test_tag_python_tool.py │ └── test_tag_ruby.py ├── common_test.py ├── test_autorelease_tag.py ├── test_autorelease_trigger.py ├── test_dummy.py ├── test_github.py ├── test_publish_reporter.py └── testdata ├── fake-private-key.pem └── repos.json /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | ignore = E501, W503 3 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | # Code owners file. 2 | # This file controls who is tagged for review for any given pull request. 3 | # 4 | # For syntax help see: 5 | # https://help.github.com/en/github/creating-cloning-and-archiving-repositories/about-code-owners#codeowners-syntax 6 | 7 | * @googleapis/yoshi @googleapis/cloud-client-library-release-automation-team 8 | -------------------------------------------------------------------------------- /.github/CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # How to Contribute 2 | 3 | We'd love to accept your patches and contributions to this project. There are 4 | just a few small guidelines you need to follow. 5 | 6 | ## Contributor License Agreement 7 | 8 | Contributions to this project must be accompanied by a Contributor License 9 | Agreement. You (or your employer) retain the copyright to your contribution; 10 | this simply gives us permission to use and redistribute your contributions as 11 | part of the project. Head over to to see 12 | your current agreements on file or to sign a new one. 13 | 14 | You generally only need to submit a CLA once, so if you've already submitted one 15 | (even if it was for a different project), you probably don't need to do it 16 | again. 17 | 18 | ## Code reviews 19 | 20 | All submissions, including submissions by project members, require review. We 21 | use GitHub pull requests for this purpose. Consult 22 | [GitHub Help](https://help.github.com/articles/about-pull-requests/) for more 23 | information on using pull requests. 24 | 25 | ## Community Guidelines 26 | 27 | This project follows [Google's Open Source Community 28 | Guidelines](https://opensource.google.com/conduct/). 29 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | 5 | --- 6 | 7 | Thanks for stopping by to let us know something could be better! 8 | 9 | **PLEASE READ**: If you have a support contract with Google, please create an issue in the [support console](https://cloud.google.com/support/) instead of filing on GitHub. This will ensure a timely response. 10 | 11 | Please run down the following list and make sure you've tried the usual "quick fixes": 12 | 13 | - Search the issues already opened: https://github.com/googleapis/releasetool/issues 14 | 15 | If you are still having issues, please be sure to include as much information as possible: 16 | 17 | #### Environment details 18 | 19 | - OS: 20 | - Python version: 21 | - pip version: 22 | - `releasetool` version: 23 | 24 | #### Steps to reproduce 25 | 26 | 1. ? 27 | 2. ? 28 | 29 | Making sure to follow these steps will guarantee the quickest resolution possible. 30 | 31 | Thanks! 32 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this library 4 | 5 | --- 6 | 7 | Thanks for stopping by to let us know something could be better! 8 | 9 | **PLEASE READ**: If you have a support contract with Google, please create an issue in the [support console](https://cloud.google.com/support/) instead of filing on GitHub. This will ensure a timely response. 10 | 11 | **Is your feature request related to a problem? Please describe.** 12 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | **Describe alternatives you've considered** 16 | A clear and concise description of any alternative solutions or features you've considered. 17 | **Additional context** 18 | Add any other context or screenshots about the feature request here. 19 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/support_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Support request 3 | about: If you have a support contract with Google, please create an issue in the Google Cloud Support console. 4 | 5 | --- 6 | 7 | **PLEASE READ**: If you have a support contract with Google, please create an issue in the [support console](https://cloud.google.com/support/) instead of filing on GitHub. This will ensure a timely response. 8 | -------------------------------------------------------------------------------- /.github/blunderbuss.yml: -------------------------------------------------------------------------------- 1 | assign_issues: 2 | - chingor13 3 | - SurferJeffAtGoogle 4 | assign_prs: 5 | - chingor13 6 | - SurferJeffAtGoogle 7 | -------------------------------------------------------------------------------- /.github/release-please.yml: -------------------------------------------------------------------------------- 1 | releaseType: python 2 | handleGHRelease: true 3 | -------------------------------------------------------------------------------- /.github/release-trigger.yml: -------------------------------------------------------------------------------- 1 | enabled: true 2 | multiScmName: releasetool 3 | -------------------------------------------------------------------------------- /.github/sync-repo-settings.yaml: -------------------------------------------------------------------------------- 1 | rebaseMergeAllowed: true 2 | squashMergeAllowed: true 3 | mergeCommitAllowed: false 4 | branchProtectionRules: 5 | - pattern: master 6 | requiredStatusCheckContexts: 7 | - 'Kokoro CI' 8 | - 'cla/google' 9 | requiredApprovingReviewCount: 1 10 | requiresCodeOwnerReviews: true 11 | requiresStrictStatusChecks: true 12 | permissionRules: 13 | - team: yoshi 14 | permission: push 15 | - team: yoshi-admins 16 | permission: admin 17 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | MANIFEST 27 | 28 | # PyInstaller 29 | # Usually these files are written by a python script from a template 30 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 31 | *.manifest 32 | *.spec 33 | 34 | # Installer logs 35 | pip-log.txt 36 | pip-delete-this-directory.txt 37 | 38 | # Unit test / coverage reports 39 | htmlcov/ 40 | .tox/ 41 | .nox/ 42 | .coverage 43 | .coverage.* 44 | .cache 45 | .pytest_cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | .hypothesis/ 50 | .pytest_cache/ 51 | 52 | # Translations 53 | *.mo 54 | *.pot 55 | 56 | # Django stuff: 57 | *.log 58 | .static_storage/ 59 | .media/ 60 | local_settings.py 61 | 62 | # Flask stuff: 63 | instance/ 64 | .webassets-cache 65 | 66 | # Scrapy stuff: 67 | .scrapy 68 | 69 | # Sphinx documentation 70 | docs/_build/ 71 | 72 | # PyBuilder 73 | target/ 74 | 75 | # Jupyter Notebook 76 | .ipynb_checkpoints 77 | 78 | # pyenv 79 | .python-version 80 | 81 | # celery beat schedule file 82 | celerybeat-schedule 83 | 84 | # SageMath parsed files 85 | *.sage.py 86 | 87 | # Environments 88 | .env 89 | .venv 90 | env/ 91 | venv/ 92 | ENV/ 93 | env.bak/ 94 | venv.bak/ 95 | 96 | # Spyder project settings 97 | .spyderproject 98 | .spyproject 99 | 100 | # Rope project settings 101 | .ropeproject 102 | 103 | # mkdocs documentation 104 | /site 105 | 106 | # mypy 107 | .mypy_cache/ 108 | 109 | app 110 | installation 111 | pem 112 | 113 | # IDEs 114 | .vscode/ 115 | .idea/ -------------------------------------------------------------------------------- /.kokoro-autorelease/build.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # Copyright 2018 Google LLC 3 | # 4 | # Licensed under the Apache License, Version 2.0 (the "License"); 5 | # you may not use this file except in compliance with the License. 6 | # You may obtain a copy of the License at 7 | # 8 | # https://www.apache.org/licenses/LICENSE-2.0 9 | # 10 | # Unless required by applicable law or agreed to in writing, software 11 | # distributed under the License is distributed on an "AS IS" BASIS, 12 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | # See the License for the specific language governing permissions and 14 | # limitations under the License. 15 | 16 | set -eo pipefail 17 | 18 | pushd github/releasetool 19 | 20 | # Docker images currently uses 3.7.4 21 | 22 | # Disable buffering, so that the logs stream through. 23 | export PYTHONUNBUFFERED=1 24 | 25 | # The key for triggering Kokoro jobs is a Keystore resource, so it'll be here. 26 | export AUTORELEASE_KOKORO_CREDENTIALS=${KOKORO_KEYSTORE_DIR}/73713_kokoro_trigger_credentials 27 | 28 | # install release-please binary to do tagging 29 | npm i release-please 30 | npx release-please --version 31 | 32 | python3 -m pip install --quiet --user --upgrade --require-hashes -r requirements.txt 33 | 34 | python3 -m autorelease --report sponge_log.xml ${AUTORELEASE_COMMAND} 35 | -------------------------------------------------------------------------------- /.kokoro-autorelease/common.cfg: -------------------------------------------------------------------------------- 1 | # Copyright 2018 Google LLC 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # https://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | # Format: //devtools/kokoro/config/proto/build.proto 16 | 17 | # Download trampoline resources. 18 | gfile_resources: "/bigstore/cloud-devrel-kokoro-resources/trampoline" 19 | 20 | build_file: "releasetool/.kokoro/trampoline_release.sh" 21 | 22 | # Configure the docker image for kokoro-trampoline. 23 | env_vars: { 24 | key: "TRAMPOLINE_IMAGE" 25 | value: "us-central1-docker.pkg.dev/cloud-sdk-release-custom-pool/release-images/node18" 26 | } 27 | 28 | # Tell the trampoline which build file to use. 29 | env_vars: { 30 | key: "TRAMPOLINE_BUILD_FILE" 31 | value: "github/releasetool/.kokoro-autorelease/build.sh" 32 | } 33 | 34 | # Build logs will be here 35 | action { 36 | define_artifacts { 37 | regex: "**/*sponge_log.xml" 38 | } 39 | } 40 | 41 | # Magictoken to access GitHub 42 | # TODO(busunkim): Remove this key once KMS setup is complete. 43 | before_action { 44 | fetch_keystore { 45 | keystore_resource { 46 | keystore_config_id: 73713 47 | keyname: "autorelease-magictoken" 48 | } 49 | } 50 | } 51 | 52 | # GitHub token for yoshi-automation 53 | # TODO(busunkim): Remove this key once KMS setup is complete. 54 | before_action { 55 | fetch_keystore { 56 | keystore_resource { 57 | keystore_config_id: 73713 58 | keyname: "yoshi-automation-github-key" 59 | } 60 | } 61 | } 62 | 63 | # Magic GitHub Proxy API 64 | before_action { 65 | fetch_keystore { 66 | keystore_resource { 67 | keystore_config_id: 73713 68 | keyname: "magic-github-proxy-api-key" 69 | } 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /.kokoro-autorelease/tag.cfg: -------------------------------------------------------------------------------- 1 | # Copyright 2018 Google LLC 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # https://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | before_action { 16 | fetch_keystore { 17 | keystore_resource { 18 | keystore_config_id: 73713 19 | keyname: "kokoro_trigger_credentials" 20 | } 21 | } 22 | } 23 | 24 | env_vars: { 25 | key: "AUTORELEASE_COMMAND" 26 | value: "tag" 27 | } 28 | -------------------------------------------------------------------------------- /.kokoro-autorelease/tag.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # Copyright 2024 Google LLC 3 | # 4 | # Licensed under the Apache License, Version 2.0 (the "License"); 5 | # you may not use this file except in compliance with the License. 6 | # You may obtain a copy of the License at 7 | # 8 | # https://www.apache.org/licenses/LICENSE-2.0 9 | # 10 | # Unless required by applicable law or agreed to in writing, software 11 | # distributed under the License is distributed on an "AS IS" BASIS, 12 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | # See the License for the specific language governing permissions and 14 | # limitations under the License. 15 | 16 | CURRENT_DIR=$(dirname "${BASH_SOURCE[0]}") 17 | AUTORELEASE_COMMAND=tag "${CURRENT_DIR}/build.sh" 18 | -------------------------------------------------------------------------------- /.kokoro-autorelease/trigger.cfg: -------------------------------------------------------------------------------- 1 | # Copyright 2021 Google LLC 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # https://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | before_action { 16 | fetch_keystore { 17 | keystore_resource { 18 | keystore_config_id: 73713 19 | keyname: "kokoro_trigger_credentials" 20 | } 21 | } 22 | } 23 | 24 | env_vars: { 25 | key: "AUTORELEASE_COMMAND" 26 | value: "trigger" 27 | } 28 | -------------------------------------------------------------------------------- /.kokoro-autorelease/trigger.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # Copyright 2024 Google LLC 3 | # 4 | # Licensed under the Apache License, Version 2.0 (the "License"); 5 | # you may not use this file except in compliance with the License. 6 | # You may obtain a copy of the License at 7 | # 8 | # https://www.apache.org/licenses/LICENSE-2.0 9 | # 10 | # Unless required by applicable law or agreed to in writing, software 11 | # distributed under the License is distributed on an "AS IS" BASIS, 12 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | # See the License for the specific language governing permissions and 14 | # limitations under the License. 15 | 16 | CURRENT_DIR=$(dirname "${BASH_SOURCE[0]}") 17 | AUTORELEASE_COMMAND=trigger "${CURRENT_DIR}/build.sh" 18 | -------------------------------------------------------------------------------- /.kokoro/build.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Copyright 2018 Google LLC 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the "License"); 6 | # you may not use this file except in compliance with the License. 7 | # You may obtain a copy of the License at 8 | # 9 | # https://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, software 12 | # distributed under the License is distributed on an "AS IS" BASIS, 13 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | # See the License for the specific language governing permissions and 15 | # limitations under the License. 16 | 17 | set -eo pipefail 18 | 19 | cd github/releasetool 20 | 21 | # Disable buffering, so that the logs stream through. 22 | export PYTHONUNBUFFERED=1 23 | 24 | # Run tests 25 | nox -s lint test 26 | 27 | # remove all files, preventing kokoro from trying to sync them. 28 | rm -rf * 29 | -------------------------------------------------------------------------------- /.kokoro/common.cfg: -------------------------------------------------------------------------------- 1 | # Format: //devtools/kokoro/config/proto/build.proto 2 | 3 | # Download trampoline resources. 4 | gfile_resources: "/bigstore/cloud-devrel-kokoro-resources/trampoline" 5 | 6 | # Use the trampoline script to run in docker. 7 | build_file: "releasetool/.kokoro/trampoline.sh" 8 | 9 | # Configure the docker image for kokoro-trampoline. 10 | env_vars: { 11 | key: "TRAMPOLINE_IMAGE" 12 | value: "gcr.io/cloud-devrel-kokoro-resources/python-multi:latest" 13 | } 14 | 15 | # Tell the trampoline which build file to use. 16 | env_vars: { 17 | key: "TRAMPOLINE_BUILD_FILE" 18 | value: "github/releasetool/.kokoro/build.sh" 19 | } 20 | -------------------------------------------------------------------------------- /.kokoro/continuous.cfg: -------------------------------------------------------------------------------- 1 | # Format: //devtools/kokoro/config/proto/build.proto 2 | -------------------------------------------------------------------------------- /.kokoro/populate-release-secrets.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # Copyright 2024 Google LLC 3 | # 4 | # Licensed under the Apache License, Version 2.0 (the "License"); 5 | # you may not use this file except in compliance with the License. 6 | # You may obtain a copy of the License at 7 | # 8 | # http://www.apache.org/licenses/LICENSE-2.0 9 | # 10 | # Unless required by applicable law or agreed to in writing, software 11 | # distributed under the License is distributed on an "AS IS" BASIS, 12 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | # See the License for the specific language governing permissions and 14 | # limitations under the License. 15 | 16 | set -eo pipefail 17 | 18 | function now { date +"%Y-%m-%d %H:%M:%S" | tr -d '\n' ;} 19 | function msg { println "$*" >&2 ;} 20 | function println { printf '%s\n' "$(now) $*" ;} 21 | 22 | 23 | SECRET_LOCATION="${KOKORO_GFILE_DIR}/secret_manager" 24 | msg "Creating folder on disk for secrets: ${SECRET_LOCATION}" 25 | mkdir -p ${SECRET_LOCATION} 26 | for key in $(echo ${SECRET_MANAGER_KEYS} | sed "s/,/ /g") 27 | do 28 | msg "Retrieving secret ${key}" 29 | docker run --entrypoint=gcloud \ 30 | --volume=${KOKORO_GFILE_DIR}:${KOKORO_GFILE_DIR} \ 31 | gcr.io/google.com/cloudsdktool/cloud-sdk \ 32 | secrets versions access latest \ 33 | --project cloud-sdk-release-custom-pool \ 34 | --secret ${key} > \ 35 | "${SECRET_LOCATION}/${key}" 36 | if [[ $? == 0 ]]; then 37 | msg "Secret written to ${SECRET_LOCATION}/${key}" 38 | else 39 | msg "Error retrieving secret ${key}" 40 | fi 41 | done 42 | -------------------------------------------------------------------------------- /.kokoro/populate-secrets.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # Copyright 2020 Google LLC. 3 | # 4 | # Licensed under the Apache License, Version 2.0 (the "License"); 5 | # you may not use this file except in compliance with the License. 6 | # You may obtain a copy of the License at 7 | # 8 | # http://www.apache.org/licenses/LICENSE-2.0 9 | # 10 | # Unless required by applicable law or agreed to in writing, software 11 | # distributed under the License is distributed on an "AS IS" BASIS, 12 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | # See the License for the specific language governing permissions and 14 | # limitations under the License. 15 | 16 | set -eo pipefail 17 | 18 | function now { date +"%Y-%m-%d %H:%M:%S" | tr -d '\n' ;} 19 | function msg { println "$*" >&2 ;} 20 | function println { printf '%s\n' "$(now) $*" ;} 21 | 22 | 23 | # Populates requested secrets set in SECRET_MANAGER_KEYS from service account: 24 | # kokoro-trampoline@cloud-devrel-kokoro-resources.iam.gserviceaccount.com 25 | SECRET_LOCATION="${KOKORO_GFILE_DIR}/secret_manager" 26 | msg "Creating folder on disk for secrets: ${SECRET_LOCATION}" 27 | mkdir -p ${SECRET_LOCATION} 28 | for key in $(echo ${SECRET_MANAGER_KEYS} | sed "s/,/ /g") 29 | do 30 | msg "Retrieving secret ${key}" 31 | docker run --entrypoint=gcloud \ 32 | --volume=${KOKORO_GFILE_DIR}:${KOKORO_GFILE_DIR} \ 33 | gcr.io/google.com/cloudsdktool/cloud-sdk \ 34 | secrets versions access latest \ 35 | --project cloud-devrel-kokoro-resources \ 36 | --secret ${key} > \ 37 | "${SECRET_LOCATION}/${key}" 38 | if [[ $? == 0 ]]; then 39 | msg "Secret written to ${SECRET_LOCATION}/${key}" 40 | else 41 | msg "Error retrieving secret ${key}" 42 | fi 43 | done 44 | -------------------------------------------------------------------------------- /.kokoro/presubmit.cfg: -------------------------------------------------------------------------------- 1 | # Format: //devtools/kokoro/config/proto/build.proto 2 | -------------------------------------------------------------------------------- /.kokoro/release.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Copyright 2018 Google LLC 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the "License"); 6 | # you may not use this file except in compliance with the License. 7 | # You may obtain a copy of the License at 8 | # 9 | # https://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, software 12 | # distributed under the License is distributed on an "AS IS" BASIS, 13 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | # See the License for the specific language governing permissions and 15 | # limitations under the License. 16 | 17 | set -eo pipefail 18 | 19 | # Move into the package, build the distribution and upload. 20 | cd github/releasetool 21 | 22 | # Enable the publish build reporter 23 | # Note: this installs from source since we're in the releasetool repo. Other projects 24 | # will need to use python3 -m pip install gcp-releasetool 25 | python3 -m pip install --require-hashes -r requirements.txt 26 | python3 -m releasetool publish-reporter-script > /tmp/publisher-script; source /tmp/publisher-script 27 | 28 | TWINE_PASSWORD=$(cat "${KOKORO_KEYSTORE_DIR}/73713_google-cloud-pypi-token-keystore-1") 29 | 30 | # Disable buffering, so that the logs stream through. 31 | export PYTHONUNBUFFERED=1 32 | 33 | python3 setup.py sdist bdist_wheel 34 | python3 -m twine upload --username __token__ --password "${TWINE_PASSWORD}" dist/* 35 | -------------------------------------------------------------------------------- /.kokoro/release/common.cfg: -------------------------------------------------------------------------------- 1 | # Format: //devtools/kokoro/config/proto/build.proto 2 | -------------------------------------------------------------------------------- /.kokoro/release/release.cfg: -------------------------------------------------------------------------------- 1 | # Format: //devtools/kokoro/config/proto/build.proto 2 | 3 | # Download trampoline resources. 4 | gfile_resources: "/bigstore/cloud-devrel-kokoro-resources/trampoline" 5 | 6 | # Use the trampoline script to run in docker. 7 | build_file: "releasetool/.kokoro/trampoline_release.sh" 8 | 9 | env_vars: { 10 | key: "SECRET_MANAGER_KEYS" 11 | value: "releasetool-publish-reporter-app,releasetool-publish-reporter-googleapis-installation,releasetool-publish-reporter-pem" 12 | } 13 | 14 | env_vars: { 15 | key: "TRAMPOLINE_BUILD_FILE" 16 | value: "github/releasetool/.kokoro/release.sh" 17 | } 18 | 19 | # Configure the docker image for kokoro-trampoline. 20 | env_vars: { 21 | key: "TRAMPOLINE_IMAGE" 22 | value: "us-central1-docker.pkg.dev/cloud-sdk-release-custom-pool/release-images/python-multi" 23 | } 24 | 25 | # Fetch PyPI password 26 | before_action { 27 | fetch_keystore { 28 | keystore_resource { 29 | keystore_config_id: 73713 30 | keyname: "google-cloud-pypi-token-keystore-1" 31 | } 32 | } 33 | } 34 | 35 | # Store the packages we uploaded to PyPI. That way, we have a record of exactly 36 | # what we published, which we can use to generate SBOMs and attestations. 37 | action { 38 | define_artifacts { 39 | regex: "github/releasetool/**/*.tar.gz" 40 | strip_prefix: "github/releasetool" 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /.kokoro/trampoline.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # Copyright 2017 Google Inc. 3 | # 4 | # Licensed under the Apache License, Version 2.0 (the "License"); 5 | # you may not use this file except in compliance with the License. 6 | # You may obtain a copy of the License at 7 | # 8 | # http://www.apache.org/licenses/LICENSE-2.0 9 | # 10 | # Unless required by applicable law or agreed to in writing, software 11 | # distributed under the License is distributed on an "AS IS" BASIS, 12 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | # See the License for the specific language governing permissions and 14 | # limitations under the License. 15 | set -eo pipefail 16 | # Always run the cleanup script, regardless of the success of bouncing into 17 | # the container. 18 | function cleanup() { 19 | chmod +x ${KOKORO_GFILE_DIR}/trampoline_cleanup.sh 20 | ${KOKORO_GFILE_DIR}/trampoline_cleanup.sh 21 | echo "cleanup"; 22 | } 23 | trap cleanup EXIT 24 | 25 | $(dirname $0)/populate-secrets.sh # Secret Manager secrets. 26 | python3 "${KOKORO_GFILE_DIR}/trampoline_v1.py" 27 | -------------------------------------------------------------------------------- /.kokoro/trampoline_release.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # Copyright 2017 Google Inc. 3 | # 4 | # Licensed under the Apache License, Version 2.0 (the "License"); 5 | # you may not use this file except in compliance with the License. 6 | # You may obtain a copy of the License at 7 | # 8 | # http://www.apache.org/licenses/LICENSE-2.0 9 | # 10 | # Unless required by applicable law or agreed to in writing, software 11 | # distributed under the License is distributed on an "AS IS" BASIS, 12 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | # See the License for the specific language governing permissions and 14 | # limitations under the License. 15 | set -eo pipefail 16 | # Always run the cleanup script, regardless of the success of bouncing into 17 | # the container. 18 | function cleanup() { 19 | chmod +x ${KOKORO_GFILE_DIR}/trampoline_cleanup.sh 20 | ${KOKORO_GFILE_DIR}/trampoline_cleanup.sh 21 | echo "cleanup"; 22 | } 23 | trap cleanup EXIT 24 | 25 | $(dirname $0)/populate-release-secrets.sh # Secret Manager secrets. 26 | TRAMPOLINE_HOST=$(echo "${TRAMPOLINE_IMAGE}" | cut -d/ -f1) 27 | if [[ ! "${TRAMPOLINE_HOST}" =~ "gcr.io" ]]; then 28 | # If you need to specify a host other than gcr.io, you have to run on an update version of gcloud. 29 | echo "TRAMPOLINE_HOST: ${TRAMPOLINE_HOST}" 30 | gcloud components update 31 | gcloud auth configure-docker "${TRAMPOLINE_HOST}" 32 | fi 33 | python3 "${KOKORO_GFILE_DIR}/trampoline_release.py" 34 | -------------------------------------------------------------------------------- /.trampolinerc: -------------------------------------------------------------------------------- 1 | # Copyright 2024 Google LLC 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | # This file adds configuration options for trampoline_v2.sh 16 | # See https://github.com/GoogleCloudPlatform/docker-ci-helper#customization 17 | 18 | pass_down_envvars+=( 19 | # Autorelease command for .kokoro-autorelease/build.sh 20 | "AUTORELEASE_COMMAND" 21 | ) 22 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Code of Conduct 2 | 3 | As contributors and maintainers of this project, 4 | and in the interest of fostering an open and welcoming community, 5 | we pledge to respect all people who contribute through reporting issues, 6 | posting feature requests, updating documentation, 7 | submitting pull requests or patches, and other activities. 8 | 9 | We are committed to making participation in this project 10 | a harassment-free experience for everyone, 11 | regardless of level of experience, gender, gender identity and expression, 12 | sexual orientation, disability, personal appearance, 13 | body size, race, ethnicity, age, religion, or nationality. 14 | 15 | Examples of unacceptable behavior by participants include: 16 | 17 | * The use of sexualized language or imagery 18 | * Personal attacks 19 | * Trolling or insulting/derogatory comments 20 | * Public or private harassment 21 | * Publishing other's private information, 22 | such as physical or electronic 23 | addresses, without explicit permission 24 | * Other unethical or unprofessional conduct. 25 | 26 | Project maintainers have the right and responsibility to remove, edit, or reject 27 | comments, commits, code, wiki edits, issues, and other contributions 28 | that are not aligned to this Code of Conduct. 29 | By adopting this Code of Conduct, 30 | project maintainers commit themselves to fairly and consistently 31 | applying these principles to every aspect of managing this project. 32 | Project maintainers who do not follow or enforce the Code of Conduct 33 | may be permanently removed from the project team. 34 | 35 | This code of conduct applies both within project spaces and in public spaces 36 | when an individual is representing the project or its community. 37 | 38 | Instances of abusive, harassing, or otherwise unacceptable behavior 39 | may be reported by opening an issue 40 | or contacting one or more of the project maintainers. 41 | 42 | This Code of Conduct is adapted from the [Contributor Covenant](http://contributor-covenant.org), version 1.2.0, 43 | available at [http://contributor-covenant.org/version/1/2/0/](http://contributor-covenant.org/version/1/2/0/) 44 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # How to Contribute 2 | 3 | We'd love to accept your patches and contributions to this project. There are 4 | just a few small guidelines you need to follow. 5 | 6 | ## Contributor License Agreement 7 | 8 | Contributions to this project must be accompanied by a Contributor License 9 | Agreement. You (or your employer) retain the copyright to your contribution; 10 | this simply gives us permission to use and redistribute your contributions as 11 | part of the project. Head over to to see 12 | your current agreements on file or to sign a new one. 13 | 14 | You generally only need to submit a CLA once, so if you've already submitted one 15 | (even if it was for a different project), you probably don't need to do it 16 | again. 17 | 18 | ## Code reviews 19 | 20 | All submissions, including submissions by project members, require review. We 21 | use GitHub pull requests for this purpose. Consult 22 | [GitHub Help](https://help.github.com/articles/about-pull-requests/) for more 23 | information on using pull requests. 24 | 25 | ## Community Guidelines 26 | 27 | This project follows [Google's Open Source Community 28 | Guidelines](https://opensource.google.com/conduct/). 29 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENSE 2 | recursive-include releasetool/commands *.sh 3 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Releasetool (for client libraries) 2 | 3 | This tool helps create releases for cloud client libraries. 4 | 5 | Presently, this works for Python, Node.js, and Ruby. However, it's designed 6 | in such a way that it could easily be used for other languages. 7 | 8 | 9 | ## Installation 10 | 11 | **Requirements:** 12 | - Python 3.8+ 13 | - pip 14 | 15 | We recommend following [this guide](https://docs.python-guide.org/starting/installation/#installation-guides) for installing both Python 3 and pip. 16 | 17 | 18 | Install releasetool using pip: 19 | ``` 20 | python3 -m pip install gcp-releasetool 21 | ``` 22 | 23 | ## Usage 24 | 25 | Packages are published in two phases. First, a PR is created to update 26 | `CHANGELOG.md` and the version number. Second, once the PR is merged the 27 | merge commit is tagged and CI publishes the package. 28 | 29 | To start the process of releasing use `releasetool start` from the directory of 30 | the client you want to publish, for example: 31 | 32 | ``` 33 | git clone git@github.com:googleapis/google-cloud-python.git 34 | cd google-cloud-python 35 | cd bigquery 36 | releasetool start 37 | ``` 38 | This will create a PR. 39 | 40 | **If the PR has a `autorelease: pending` label:** 41 | 42 | Upon approval and merging, 43 | `autorelease` will pick up the PR and run `releasetool tag` and release the 44 | package to their respective package managers. Autorelease will comment on the release PR with the status of the release. [Example PR](https://github.com/googleapis/nodejs-pubsub/pull/521) 45 | 46 | **Otherwise:** 47 | 48 | Once the PR has been approved and merged, you can run `releasetool tag` from 49 | anywhere in the repository to tag the commit and start CI. 50 | 51 | ``` 52 | git fetch origin master 53 | git checkout origin/master 54 | releasetool tag 55 | ``` 56 | 57 | ## Authenticating 58 | 59 | When first running `releasetool` you will be prompted for a GitHub token. Make 60 | sure that the token provided has `write:repo_hook` and `public_repo` scopes. 61 | 62 | ### Resetting the GitHub Token 63 | 64 | If you need to change the GitHub API token associated with releasetool, run `releasetool reset-config`. This will delete the existing token. The next time you run `releasetool start` you will be 65 | prompted to enter a new token. 66 | 67 | ## Disclaimer 68 | 69 | This is not an official Google product. 70 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | To report a security issue, please use [g.co/vulnz](https://g.co/vulnz). 4 | 5 | The Google Security Team will respond within 5 working days of your report on g.co/vulnz. 6 | 7 | We use g.co/vulnz for our intake, and do coordination and disclosure here using GitHub Security Advisory to privately discuss and fix the issue. 8 | -------------------------------------------------------------------------------- /autorelease/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/googleapis/releasetool/4a6e25c4a0cb6b6db1815f578012edb8cb884595/autorelease/__init__.py -------------------------------------------------------------------------------- /autorelease/__main__.py: -------------------------------------------------------------------------------- 1 | # Copyright 2018 Google LLC 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # https://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | import argparse 16 | import os 17 | import sys 18 | 19 | from autorelease import tag, trigger 20 | 21 | # TODO(busunkim): Fetch magictoken from KMS once KMS setup is complete. 22 | _KEYSTORE_GITHUB_TOKEN_LOCATION = "73713_yoshi-automation-github-key" 23 | 24 | 25 | def _determine_github_token(github_token): 26 | """Automatically use the GitHub token provided by Keystore if needed.""" 27 | if github_token is not None: 28 | return github_token 29 | 30 | if "KOKORO_KEYSTORE_DIR" in os.environ: 31 | filename = os.path.join( 32 | os.environ["KOKORO_KEYSTORE_DIR"], _KEYSTORE_GITHUB_TOKEN_LOCATION 33 | ) 34 | 35 | if os.path.exists(filename): 36 | with open(filename, "r", encoding="utf-8") as fh: 37 | return fh.read().strip() 38 | 39 | return None 40 | 41 | 42 | def main(): 43 | parser = argparse.ArgumentParser() 44 | parser.add_argument("--report") 45 | parser.add_argument("--github-token", default=os.environ.get("GITHUB_TOKEN")) 46 | parser.add_argument( 47 | "--kokoro-credentials", default=os.environ.get("AUTORELEASE_KOKORO_CREDENTIALS") 48 | ) 49 | parser.add_argument("--pull", default=None) 50 | parser.add_argument("--release", default=None) 51 | parser.add_argument("--lang", default=None) 52 | parser.add_argument("command") 53 | parser.add_argument("--multi-scm-name") 54 | 55 | args = parser.parse_args() 56 | 57 | args.github_token = _determine_github_token(args.github_token) 58 | 59 | if args.command == "tag": 60 | report = tag.main(args.github_token, args.kokoro_credentials) 61 | 62 | if args.report: 63 | report.write(args.report) 64 | 65 | if report.failures: 66 | sys.exit(2) 67 | else: 68 | return 69 | elif args.command == "trigger": 70 | report = trigger.main(args.github_token, args.kokoro_credentials) 71 | 72 | if args.report: 73 | report.write(args.report) 74 | 75 | if report.failures: 76 | sys.exit(2) 77 | else: 78 | return 79 | elif args.command == "trigger-single": 80 | if args.release: 81 | if not args.lang: 82 | raise Exception("missing required arg --lang") 83 | report = trigger.trigger_for_release( 84 | args.github_token, 85 | args.kokoro_credentials, 86 | args.release, 87 | trigger.to_pysafe_language_name(args.lang), 88 | args.multi_scm_name, 89 | ) 90 | if not args.pull: 91 | raise Exception("missing required arg --pull") 92 | else: 93 | report = trigger.trigger_single( 94 | args.github_token, 95 | args.kokoro_credentials, 96 | args.pull, 97 | multi_scm_name=args.multi_scm_name, 98 | ) 99 | 100 | if args.report: 101 | report.write(args.report) 102 | 103 | if report.failures: 104 | sys.exit(2) 105 | else: 106 | return 107 | else: 108 | print(f"Unknown command {args.command}.") 109 | sys.exit(1) 110 | 111 | 112 | if __name__ == "__main__": 113 | main() 114 | -------------------------------------------------------------------------------- /autorelease/common.py: -------------------------------------------------------------------------------- 1 | # Copyright 2018 Google LLC 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # https://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | import json 16 | import re 17 | 18 | from typing import Dict, Any, Callable 19 | from autorelease.github import GitHub 20 | 21 | 22 | def _determine_language( 23 | fetch_repos_json: Callable[[], str], repo_full_name: str 24 | ) -> str: 25 | python_tools = ["synthtool"] 26 | 27 | if repo_full_name.split("/")[1] in python_tools: 28 | return "python_tool" 29 | 30 | repos = json.loads(fetch_repos_json())["repos"] 31 | for repo in repos: 32 | if repo_full_name == repo["repo"]: 33 | return repo["language"] 34 | 35 | raise Exception("Unable to determine repository language.") 36 | 37 | 38 | def determine_language(gh: GitHub, pull: Dict[str, Any]) -> str: 39 | name = pull["base"]["repo"]["full_name"] 40 | 41 | def fetch_repos_json(): 42 | return gh.get_contents("googleapis/sloth", "repos.json") 43 | 44 | return _determine_language(fetch_repos_json, name) 45 | 46 | 47 | """Language names as reported by github.""" 48 | _SILVER_LANGUAGE_NAMES = { 49 | "JavaScript": "nodejs", 50 | "TypeScript": "nodejs", 51 | "Python": "python", 52 | "Java": "java", 53 | "PHP": "php", 54 | "Ruby": "ruby", 55 | "Go": "go", 56 | "C#": "dotnet", 57 | "Elixir": "elixer", 58 | } 59 | 60 | 61 | def guess_language(gh: GitHub, repo_full_name: str) -> str: 62 | special_cases = { 63 | # 1 special case inherited from the original determine_language() code. 64 | "googleapis/synthtool": "python_tool", 65 | # 2 more special cases where the most prevalent language is not the same as 66 | # what was declared in the old repos.json. 67 | "GoogleCloudPlatform/cloud-code-samples": "dotnet", 68 | "googleapis/doc-templates": "python", 69 | # special case for cndb testing protos, set to java instead of proto. 70 | "googleapis/cndb-client-testing-protos": "java", 71 | } 72 | special_case = special_cases.get(repo_full_name) 73 | if special_case: 74 | return special_case 75 | 76 | # Does the repo name have a language name in it? 77 | lang_names = { 78 | "cpp", 79 | "dotnet", 80 | "elixir", 81 | "go", 82 | "java", 83 | "nodejs", 84 | "php", 85 | "python", 86 | "python_tool", 87 | "ruby", 88 | } 89 | chunks = set(re.split("/|-", repo_full_name)) 90 | x = lang_names.intersection(chunks) 91 | if 1 == len(x): 92 | return x.pop() # Found the language name in the repo name 93 | 94 | # Fetch how many lines of each language are found in the repo. 95 | languages = gh.get_languages(repo_full_name) 96 | ranks = [ 97 | (count, lang) 98 | for (lang, count) in languages.items() 99 | # Ignore languages we don't care about, like Shell. 100 | if lang in _SILVER_LANGUAGE_NAMES 101 | ] 102 | ranks.sort(reverse=True) 103 | if ranks: 104 | # Return the most prevalent language in the repo. 105 | return _SILVER_LANGUAGE_NAMES[ranks[0][1]] 106 | else: 107 | raise Exception("Unable to determine repository language.") 108 | -------------------------------------------------------------------------------- /autorelease/kokoro.py: -------------------------------------------------------------------------------- 1 | # Copyright 2018 Google LLC 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # https://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | """This module talks to Kokoro via Pub/Sub messages to devrel-prod.googleplex.com.""" 16 | 17 | import base64 18 | 19 | from google.auth.transport import requests 20 | from google.oauth2 import service_account 21 | import google.auth 22 | 23 | from protos import kokoro_api_pb2 24 | 25 | 26 | _DEVREL_PROD_KOKORO_TOPIC = ( 27 | "projects/google.com:devrel-library-tracker-prod/topics/kokoro" 28 | ) 29 | 30 | 31 | def _send_pubsub_message( 32 | session: requests.AuthorizedSession, topic: str, data: str 33 | ) -> dict: 34 | url = f"https://pubsub.googleapis.com/v1/{topic}:publish" 35 | encoded_data = base64.b64encode(data.encode("utf-8")) 36 | 37 | publish_request = {"messages": [{"data": encoded_data.decode("utf-8")}]} 38 | 39 | resp = session.post(url, json=publish_request) 40 | resp.raise_for_status() 41 | 42 | return resp 43 | 44 | 45 | def _make_build_request( 46 | job_name: str, sha: str, env_vars: dict = None, multi_scm_name: str = "" 47 | ) -> str: 48 | request = kokoro_api_pb2.BuildRequest( 49 | full_job_name=job_name, 50 | ) 51 | 52 | # If the job is configured to use multiple SCMs, then we need to send 53 | # the scm_name field as part of the request 54 | if multi_scm_name: 55 | request.multi_scm_revision.github_scm_revision.add( 56 | name=multi_scm_name, commit_sha=sha 57 | ) 58 | else: 59 | request.scm_revision.github_scm_revision.commit_sha = sha 60 | 61 | if env_vars: 62 | for key, value in env_vars.items(): 63 | request.env_vars[key] = value 64 | 65 | # Transform into a string for TextFormat. See: 66 | # https://sites.google.com/a/google.com/protocol-buffers/user-docs/miscellaneous-howtos/text-format-examples 67 | return str(request) 68 | 69 | 70 | def make_authorized_session(credentials_file: str) -> requests.AuthorizedSession: 71 | """Create a scoped, authorized requests session using a service account 72 | 73 | Args: 74 | credentials_file {str}: Path to service account file 75 | 76 | Returns: 77 | requests.AuthorizedSession: The authorized requests session 78 | """ 79 | credentials = service_account.Credentials.from_service_account_file( 80 | credentials_file, scopes=["https://www.googleapis.com/auth/pubsub"] 81 | ) 82 | session = requests.AuthorizedSession(credentials) 83 | return session 84 | 85 | 86 | def make_adc_session() -> requests.AuthorizedSession: 87 | """Create a scoped, authorized requests session using ADC 88 | 89 | Returns: 90 | requests.AuthorizedSession: The authorized requests session 91 | 92 | Raises: 93 | DefaultCredentialsError if no credentials found 94 | """ 95 | credentials, _ = google.auth.default( 96 | scopes=["https://www.googleapis.com/auth/pubsub"] 97 | ) 98 | session = requests.AuthorizedSession(credentials) 99 | return session 100 | 101 | 102 | def trigger_build( 103 | session: requests.AuthorizedSession, 104 | job_name: str, 105 | sha: str, 106 | env_vars: dict = None, 107 | multi_scm_name: str = "", 108 | ): 109 | build_request = _make_build_request( 110 | job_name, sha, env_vars=env_vars, multi_scm_name=multi_scm_name 111 | ) 112 | _send_pubsub_message(session, _DEVREL_PROD_KOKORO_TOPIC, build_request) 113 | -------------------------------------------------------------------------------- /autorelease/report.xml.j2: -------------------------------------------------------------------------------- 1 | 2 | 3 | {% for result in reporter.results %} 4 | 5 | 6 | {% if result.error %} 7 | {{result.output|e}} 8 | {% else %} 9 | {{result.output|e}} 10 | {% endif %} 11 | 12 | 13 | {% endfor %} 14 | 15 | -------------------------------------------------------------------------------- /autorelease/reporter.py: -------------------------------------------------------------------------------- 1 | # Copyright 2018 Google LLC 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # https://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | """This module is used for reporting status via junit XML files that can be 16 | consumed by Kokoro/Sponge.""" 17 | 18 | import io 19 | import os 20 | 21 | import attr 22 | import jinja2 23 | 24 | with open(os.path.join(os.path.dirname(__file__), "report.xml.j2"), "r") as fh: 25 | _TEMPLATE = jinja2.Template(fh.read()) 26 | 27 | 28 | @attr.s(auto_attribs=True, slots=True) 29 | class Result: 30 | name: str 31 | error: bool = False 32 | skipped: bool = False 33 | _output: io.StringIO = attr.ib(factory=io.StringIO) 34 | 35 | @property 36 | def output(self): 37 | return self._output.getvalue() 38 | 39 | def print(self, *args, **kwargs): 40 | print(*args, **kwargs) 41 | print(*args, file=self._output, **kwargs) 42 | 43 | 44 | class Reporter: 45 | def __init__(self, name): 46 | self.name = name 47 | self.results = [] 48 | 49 | @property 50 | def failures(self): 51 | return len([result for result in self.results if result.error]) 52 | 53 | @property 54 | def skips(self): 55 | return len([result for result in self.results if result.skipped]) 56 | 57 | def add(self, result): 58 | self.results.append(result) 59 | 60 | def render(self): 61 | return _TEMPLATE.render(reporter=self) 62 | 63 | def write(self, filename): 64 | with open(filename, "w") as fh: 65 | fh.write(self.render()) 66 | -------------------------------------------------------------------------------- /autorelease/tag.py: -------------------------------------------------------------------------------- 1 | # Copyright 2018 Google LLC 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # https://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | """This module handles automatically running releasetool tag against all pending PRs.""" 16 | 17 | import importlib 18 | 19 | from autorelease import common, github, kokoro, reporter 20 | from releasetool.commands.common import TagContext 21 | import releasetool.github 22 | 23 | LANGUAGE_ALLOWLIST = [] 24 | 25 | # This is only used in the autorelease/tag workflows. 26 | # Direct, explicit triggering on repos in this list is still possible. 27 | REPO_DENYLIST = [] 28 | 29 | 30 | ORGANIZATIONS_TO_SCAN = ["googleapis", "GoogleCloudPlatform"] 31 | 32 | 33 | def run_releasetool_tag(lang: str, gh: github.GitHub, pull: dict) -> TagContext: 34 | """Runs releasetool tag using external config.""" 35 | language_module = importlib.import_module(f"releasetool.commands.tag.{lang}") 36 | ctx = TagContext() 37 | ctx.interactive = False 38 | # TODO(busunkim): Use proxy once KMS setup is complete. 39 | ctx.github = releasetool.github.GitHub(gh.token, use_proxy=False) 40 | ctx.token = gh.token 41 | ctx.upstream_repo = pull["base"]["repo"]["full_name"] 42 | ctx.release_pr = pull 43 | return language_module.tag(ctx) 44 | 45 | 46 | def process_issue( 47 | kokoro_session, gh: github.GitHub, issue: dict, result: reporter.Result 48 | ) -> None: 49 | # Reify the "issue" into a full pull request object from github. This 50 | # is necessary because github gives us back an issue object, but it 51 | # doesn't contain all of the PR info. 52 | pull = gh.get_url(issue["pull_request"]["url"]) 53 | repo_full_name = pull["base"]["repo"]["full_name"] 54 | 55 | # Determine language. 56 | lang = common.guess_language(gh, repo_full_name) 57 | 58 | # As part of the migration to release-please tagging, cross-reference the 59 | # language against an allowlist to allow migrating language-by-language. 60 | if lang not in LANGUAGE_ALLOWLIST: 61 | result.skipped = True 62 | result.print(f"Language {lang} not in allowlist, skipping.") 63 | return 64 | # As part of the migration to release-please tagging, cross-reference the 65 | # repository against a denylist to allow migrating repo-by-repo. 66 | for denied_repo in REPO_DENYLIST: 67 | if denied_repo == repo_full_name: 68 | result.skipped = True 69 | result.print(f"Repo {denied_repo} in denylist, skipping.") 70 | return 71 | 72 | # Before doing any processing, check to make sure the PR was actually merged. 73 | # "closed" PRs can be merged or just closed without merging. 74 | if not pull.get("merged_at"): 75 | result.skipped = True 76 | result.print("Closed, not merged, skipping.") 77 | # Remove the label so we don't continue processing it. 78 | gh.update_pull_labels( 79 | pull, add=["autorelease: closed"], remove=["autorelease: pending"] 80 | ) 81 | return 82 | 83 | # Run releasetool tag for the PR. 84 | ctx = run_releasetool_tag(lang, gh, pull) 85 | 86 | # Trigger Kokoro release build 87 | result.print(f"Triggering {ctx.kokoro_job_name} using {ctx.release_tag}") 88 | if ctx.kokoro_job_name and ctx.release_tag: 89 | kokoro.trigger_build( 90 | kokoro_session, 91 | job_name=ctx.kokoro_job_name, 92 | sha=ctx.release_tag, 93 | env_vars={"AUTORELEASE_PR": pull["html_url"]}, 94 | ) 95 | 96 | 97 | def main(github_token: str, kokoro_credentials: str) -> reporter.Reporter: 98 | report = reporter.Reporter("autorelease.tag") 99 | # TODO(busunkim): Use proxy once KMS setup is complete. 100 | gh = github.GitHub(github_token, use_proxy=False) 101 | 102 | if kokoro_credentials: 103 | kokoro_session = kokoro.make_authorized_session(kokoro_credentials) 104 | else: 105 | kokoro_session = kokoro.make_adc_session() 106 | 107 | # First, we need to get a list of all pull requests (GitHub calls these "issues") 108 | # that are merged ("closed") and have the label "autorelease: pending". 109 | list_result = reporter.Result("list issues") 110 | report.add(list_result) 111 | 112 | all_issues = [] 113 | for org in ORGANIZATIONS_TO_SCAN: 114 | try: 115 | issues = gh.list_org_issues( 116 | org=org, 117 | # Must be merged ("closed"). 118 | state="closed", 119 | # Must be labeled with "autorelease: pending" 120 | labels="autorelease: pending", 121 | ) 122 | 123 | # Just in case any non-PRs got in here. 124 | issues = [result for result in issues if "pull_request" in result] 125 | 126 | all_issues.extend(issues) 127 | 128 | # Exceptions while getting the list of pull requests constitutes a total failure. 129 | except Exception as exc: 130 | list_result.error = True 131 | list_result.print(exc) 132 | 133 | # Print out our findings as a checkpoint. 134 | list_result.print("Working set:") 135 | for issue in all_issues: 136 | list_result.print(f" * {issue['title']}: {issue['pull_request']['html_url']}") 137 | 138 | # For each pull request, execute releasetool tag for it. 139 | for issue in all_issues: 140 | result = reporter.Result(f"{issue['title']}") 141 | report.add(result) 142 | result.print( 143 | f"Processing {issue['title']}: {issue['pull_request']['html_url']}" 144 | ) 145 | 146 | try: 147 | process_issue(kokoro_session, gh, issue, result) 148 | # Failing any one PR is fine, just record it in the log and continue. 149 | except Exception as exc: 150 | result.error = True 151 | result.print(f"{exc!r}") 152 | 153 | return report 154 | -------------------------------------------------------------------------------- /noxfile.py: -------------------------------------------------------------------------------- 1 | # Copyright 2018 Google LLC 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # https://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | import nox 16 | import pathlib 17 | 18 | CURRENT_DIRECTORY = pathlib.Path(__file__).parent.absolute() 19 | 20 | ALL_PYTHON = ["3.9", "3.10", "3.11", "3.12"] 21 | 22 | @nox.session(python='3.8') 23 | def blacken(session): 24 | session.install('black') 25 | session.run('black', 'autorelease', 'releasetool', 'tests') 26 | 27 | 28 | @nox.session(python='3.8') 29 | def lint(session): 30 | session.install('mypy==0.812', 'flake8', 'black') 31 | session.install('-e', '.') 32 | session.run('black', '--check', 'autorelease', 'releasetool', 'tests') 33 | session.run('flake8', 'autorelease', 'releasetool', 'tests') 34 | session.run( 35 | 'mypy', 36 | '--no-strict-optional', 37 | '--ignore-missing-imports', 38 | 'releasetool') 39 | 40 | 41 | @nox.session(python=ALL_PYTHON) 42 | def test(session): 43 | # Use a constraints file for the specific python runtime version. 44 | # We do this to make sure that we're testing against the lowest 45 | # supported version of a dependency. 46 | session.install("-r", f"{CURRENT_DIRECTORY}/requirements-dev.txt") 47 | constraints_file = f"{CURRENT_DIRECTORY}/testing/constraints-{session.python}.txt" 48 | session.install('-e', '.', "-r", constraints_file) 49 | session.run('pytest', 'tests', *session.posargs) 50 | -------------------------------------------------------------------------------- /protos/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/googleapis/releasetool/4a6e25c4a0cb6b6db1815f578012edb8cb884595/protos/__init__.py -------------------------------------------------------------------------------- /protos/kokoro_api.proto: -------------------------------------------------------------------------------- 1 | // Copyright 2018 Google LLC 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // https://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | // Protocol messages and service definition for Kokoro master API. 16 | // This has been heavily modified from the upstream source to just contain 17 | // the fields we need. 18 | // Source: https://cs.corp.google.com/piper///depot/google3/devtools/kokoro/api/proto/kokoro_api.proto 19 | 20 | syntax = "proto2"; 21 | 22 | package devtools.kokoro.api.proto; 23 | 24 | // Request for new build of a job. 25 | message BuildRequest { 26 | optional string full_job_name = 1; 27 | 28 | // The specific revision to build. If not set, Jenkins will use whatever 29 | // revision is latest when the build actually executes. 30 | optional ScmRevision scm_revision = 3; 31 | 32 | // Use this for multi scm jobs 33 | optional MultiScmRevision multi_scm_revision = 9; 34 | 35 | // The input file/directory paths for a kokoro build, they should be 36 | // accessible by GoogleFile. Those files will be downloaded to src directory 37 | // and available before build step starts. 38 | repeated string input_file_paths = 4; 39 | 40 | // Build parameters passed to job as environment variable key value pairs. 41 | // Multiple env_vars blocks are allowed. See 42 | // http://cs/piper///depot/google3/devtools/kokoro/config/proto/build.proto?type=cs&q=project:kokoro+file:build.proto+%22env_vars+%3D+6;%22 43 | map env_vars = 6; 44 | 45 | // Keystore Resources to be fetched pre build. These will be downloaded to 46 | // src/keystore directory and will be available before build starts. 47 | // Resource with configID X and name Y will be placed at src/keystore/X_Y. 48 | // optional .kokoro.FetchKeystore fetch_keystore = 7; 49 | 50 | // Kokoro build config parameters key value pairs. The key is the variable in 51 | // the build config and the value is the value that you want to replace the 52 | // variable in the build config (string type only). 53 | // Multiple build_params blocks are allowed. See build_params in 54 | // https://cs.corp.google.com/piper///depot/google3/devtools/kokoro/config/proto/build.proto?dr=C&rcl=206864844&g=0&l=18 55 | map build_params = 12; 56 | 57 | // next index: 13; 58 | } 59 | 60 | // Represents a specific revision in one of the supported SCM systems. 61 | message ScmRevision { 62 | oneof scmrevision { 63 | GithubScmRevision github_scm_revision = 4; 64 | } 65 | } 66 | 67 | // Represents a specific revision for multi-scm. 68 | message MultiScmRevision { 69 | repeated GithubScmRevision github_scm_revision = 4; 70 | } 71 | 72 | // Represents a specific revision in GitHub repository. 73 | message GithubScmRevision { 74 | // Name of the scm that this revision belongs to. This name should be the same 75 | // as multi_scm.github_scm.name 76 | optional string name = 4; 77 | 78 | optional string commit_sha = 1; 79 | 80 | // Pull request number in GitHub. If this field is present, then commit_sha 81 | // will be the commit in the pull request. 82 | optional string pull_request_number = 2; 83 | 84 | // The URL for the change in Github repository. This field will be populated 85 | // in the build result, and will be ignored if it is specified in the query. 86 | optional string repository_url = 3; 87 | 88 | // Owner of the repository. If supplied, this field will override the owner 89 | // field in job.proto. 90 | optional string owner = 5; 91 | 92 | // GitHub repository name. If supplied, this field will override the 93 | // repository field in job.proto. 94 | optional string repository = 6; 95 | 96 | // next index: 7 97 | } 98 | -------------------------------------------------------------------------------- /protos/kokoro_api_pb2.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by the protocol buffer compiler. DO NOT EDIT! 3 | # source: protos/kokoro_api.proto 4 | """Generated protocol buffer code.""" 5 | from google.protobuf import descriptor as _descriptor 6 | from google.protobuf import descriptor_pool as _descriptor_pool 7 | from google.protobuf import message as _message 8 | from google.protobuf import reflection as _reflection 9 | from google.protobuf import symbol_database as _symbol_database 10 | # @@protoc_insertion_point(imports) 11 | 12 | _sym_db = _symbol_database.Default() 13 | 14 | 15 | 16 | 17 | DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x17protos/kokoro_api.proto\x12\x19\x64\x65vtools.kokoro.api.proto\"\xc2\x03\n\x0c\x42uildRequest\x12\x15\n\rfull_job_name\x18\x01 \x01(\t\x12<\n\x0cscm_revision\x18\x03 \x01(\x0b\x32&.devtools.kokoro.api.proto.ScmRevision\x12G\n\x12multi_scm_revision\x18\t \x01(\x0b\x32+.devtools.kokoro.api.proto.MultiScmRevision\x12\x18\n\x10input_file_paths\x18\x04 \x03(\t\x12\x46\n\x08\x65nv_vars\x18\x06 \x03(\x0b\x32\x34.devtools.kokoro.api.proto.BuildRequest.EnvVarsEntry\x12N\n\x0c\x62uild_params\x18\x0c \x03(\x0b\x32\x38.devtools.kokoro.api.proto.BuildRequest.BuildParamsEntry\x1a.\n\x0c\x45nvVarsEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01\x1a\x32\n\x10\x42uildParamsEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01\"i\n\x0bScmRevision\x12K\n\x13github_scm_revision\x18\x04 \x01(\x0b\x32,.devtools.kokoro.api.proto.GithubScmRevisionH\x00\x42\r\n\x0bscmrevision\"]\n\x10MultiScmRevision\x12I\n\x13github_scm_revision\x18\x04 \x03(\x0b\x32,.devtools.kokoro.api.proto.GithubScmRevision\"\x8d\x01\n\x11GithubScmRevision\x12\x0c\n\x04name\x18\x04 \x01(\t\x12\x12\n\ncommit_sha\x18\x01 \x01(\t\x12\x1b\n\x13pull_request_number\x18\x02 \x01(\t\x12\x16\n\x0erepository_url\x18\x03 \x01(\t\x12\r\n\x05owner\x18\x05 \x01(\t\x12\x12\n\nrepository\x18\x06 \x01(\t') 18 | 19 | 20 | 21 | _BUILDREQUEST = DESCRIPTOR.message_types_by_name['BuildRequest'] 22 | _BUILDREQUEST_ENVVARSENTRY = _BUILDREQUEST.nested_types_by_name['EnvVarsEntry'] 23 | _BUILDREQUEST_BUILDPARAMSENTRY = _BUILDREQUEST.nested_types_by_name['BuildParamsEntry'] 24 | _SCMREVISION = DESCRIPTOR.message_types_by_name['ScmRevision'] 25 | _MULTISCMREVISION = DESCRIPTOR.message_types_by_name['MultiScmRevision'] 26 | _GITHUBSCMREVISION = DESCRIPTOR.message_types_by_name['GithubScmRevision'] 27 | BuildRequest = _reflection.GeneratedProtocolMessageType('BuildRequest', (_message.Message,), { 28 | 29 | 'EnvVarsEntry' : _reflection.GeneratedProtocolMessageType('EnvVarsEntry', (_message.Message,), { 30 | 'DESCRIPTOR' : _BUILDREQUEST_ENVVARSENTRY, 31 | '__module__' : 'protos.kokoro_api_pb2' 32 | # @@protoc_insertion_point(class_scope:devtools.kokoro.api.proto.BuildRequest.EnvVarsEntry) 33 | }) 34 | , 35 | 36 | 'BuildParamsEntry' : _reflection.GeneratedProtocolMessageType('BuildParamsEntry', (_message.Message,), { 37 | 'DESCRIPTOR' : _BUILDREQUEST_BUILDPARAMSENTRY, 38 | '__module__' : 'protos.kokoro_api_pb2' 39 | # @@protoc_insertion_point(class_scope:devtools.kokoro.api.proto.BuildRequest.BuildParamsEntry) 40 | }) 41 | , 42 | 'DESCRIPTOR' : _BUILDREQUEST, 43 | '__module__' : 'protos.kokoro_api_pb2' 44 | # @@protoc_insertion_point(class_scope:devtools.kokoro.api.proto.BuildRequest) 45 | }) 46 | _sym_db.RegisterMessage(BuildRequest) 47 | _sym_db.RegisterMessage(BuildRequest.EnvVarsEntry) 48 | _sym_db.RegisterMessage(BuildRequest.BuildParamsEntry) 49 | 50 | ScmRevision = _reflection.GeneratedProtocolMessageType('ScmRevision', (_message.Message,), { 51 | 'DESCRIPTOR' : _SCMREVISION, 52 | '__module__' : 'protos.kokoro_api_pb2' 53 | # @@protoc_insertion_point(class_scope:devtools.kokoro.api.proto.ScmRevision) 54 | }) 55 | _sym_db.RegisterMessage(ScmRevision) 56 | 57 | MultiScmRevision = _reflection.GeneratedProtocolMessageType('MultiScmRevision', (_message.Message,), { 58 | 'DESCRIPTOR' : _MULTISCMREVISION, 59 | '__module__' : 'protos.kokoro_api_pb2' 60 | # @@protoc_insertion_point(class_scope:devtools.kokoro.api.proto.MultiScmRevision) 61 | }) 62 | _sym_db.RegisterMessage(MultiScmRevision) 63 | 64 | GithubScmRevision = _reflection.GeneratedProtocolMessageType('GithubScmRevision', (_message.Message,), { 65 | 'DESCRIPTOR' : _GITHUBSCMREVISION, 66 | '__module__' : 'protos.kokoro_api_pb2' 67 | # @@protoc_insertion_point(class_scope:devtools.kokoro.api.proto.GithubScmRevision) 68 | }) 69 | _sym_db.RegisterMessage(GithubScmRevision) 70 | 71 | if _descriptor._USE_C_DESCRIPTORS == False: 72 | 73 | DESCRIPTOR._options = None 74 | _BUILDREQUEST_ENVVARSENTRY._options = None 75 | _BUILDREQUEST_ENVVARSENTRY._serialized_options = b'8\001' 76 | _BUILDREQUEST_BUILDPARAMSENTRY._options = None 77 | _BUILDREQUEST_BUILDPARAMSENTRY._serialized_options = b'8\001' 78 | _BUILDREQUEST._serialized_start=55 79 | _BUILDREQUEST._serialized_end=505 80 | _BUILDREQUEST_ENVVARSENTRY._serialized_start=407 81 | _BUILDREQUEST_ENVVARSENTRY._serialized_end=453 82 | _BUILDREQUEST_BUILDPARAMSENTRY._serialized_start=455 83 | _BUILDREQUEST_BUILDPARAMSENTRY._serialized_end=505 84 | _SCMREVISION._serialized_start=507 85 | _SCMREVISION._serialized_end=612 86 | _MULTISCMREVISION._serialized_start=614 87 | _MULTISCMREVISION._serialized_end=707 88 | _GITHUBSCMREVISION._serialized_start=710 89 | _GITHUBSCMREVISION._serialized_end=851 90 | # @@protoc_insertion_point(module_scope) 91 | -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | filterwarnings = 3 | # treat all warnings as errors 4 | error 5 | # Remove once https://github.com/protocolbuffers/protobuf/issues/15077 is fixed 6 | ignore:.*custom tp_new.*in Python 3.14:DeprecationWarning 7 | # Remove once https://github.com/dateutil/dateutil/issues/1314 is fixed 8 | ignore:.*datetime.datetime.utcfromtimestamp\(\) is deprecated:DeprecationWarning:dateutil.tz.tz 9 | -------------------------------------------------------------------------------- /releasetool/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright 2018 Google LLC 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # https://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | """releasetool helps make releases.""" 16 | 17 | __version__ = "1.8.2" 18 | -------------------------------------------------------------------------------- /releasetool/__main__.py: -------------------------------------------------------------------------------- 1 | # Copyright 2018 Google LLC 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # https://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | import functools 16 | import os 17 | 18 | import click 19 | 20 | import releasetool.secrets 21 | import releasetool.update_check 22 | import releasetool.commands.publish_reporter 23 | import releasetool.commands.start.python 24 | import releasetool.commands.start.python_tool 25 | import releasetool.commands.start.nodejs 26 | import releasetool.commands.start.java 27 | import releasetool.commands.start.ruby 28 | import releasetool.commands.start.go 29 | import releasetool.commands.tag.python 30 | import releasetool.commands.tag.python_tool 31 | import releasetool.commands.tag.nodejs 32 | import releasetool.commands.tag.java 33 | import releasetool.commands.tag.php 34 | import releasetool.commands.tag.ruby 35 | import releasetool.commands.tag.dotnet 36 | 37 | 38 | class _OptionPromptIfNone(click.Option): 39 | """A custom option that only prompts if the default value can't be 40 | determined.""" 41 | 42 | _value_key = "_default_val" 43 | 44 | def get_default(self, ctx): 45 | if not hasattr(self, self._value_key): 46 | default = super(_OptionPromptIfNone, self).get_default(ctx) 47 | setattr(self, self._value_key, default) 48 | return getattr(self, self._value_key) 49 | 50 | def prompt_for_value(self, ctx): 51 | default = self.get_default(ctx) 52 | 53 | # only prompt if the default value is None 54 | if default is None: 55 | return super(_OptionPromptIfNone, self).prompt_for_value(ctx) 56 | 57 | return default 58 | 59 | 60 | @click.group(invoke_without_command=True) 61 | @click.pass_context 62 | @click.version_option(message="%(version)s") 63 | def main(ctx): 64 | if ctx.invoked_subcommand is None: 65 | return ctx.invoke(start) 66 | 67 | 68 | def _detect_language(): 69 | if os.path.exists("package.json"): 70 | return "nodejs" 71 | elif os.path.exists("setup.py"): 72 | if os.path.exists("releasetool") or os.path.exists("synthtool"): 73 | return "python-tool" 74 | else: 75 | return "python" 76 | elif os.path.exists("Gemfile"): 77 | return "ruby" 78 | elif os.path.exists("pom.xml") or os.path.exists("build.gradle"): 79 | return "java" 80 | elif os.path.exists("global.json"): 81 | return "dotnet" 82 | return None 83 | 84 | 85 | _language_choices = [ 86 | "python", 87 | "python-tool", 88 | "nodejs", 89 | "java", 90 | "ruby", 91 | "go", 92 | "php", 93 | "dotnet", 94 | ] 95 | 96 | 97 | def _language_option(): 98 | return click.option( 99 | "--language", 100 | prompt=f"Which language ({', '.join(_language_choices)})?", 101 | type=click.Choice(_language_choices), 102 | default=_detect_language, 103 | cls=_OptionPromptIfNone, 104 | ) 105 | 106 | 107 | @main.command() 108 | @_language_option() 109 | def start(language): 110 | releasetool.update_check.check_for_updates( 111 | "gcp-releasetool", print=functools.partial(click.secho, fg="magenta") 112 | ) 113 | 114 | if language == "python": 115 | return releasetool.commands.start.python.start() 116 | if language == "python-tool": 117 | return releasetool.commands.start.python_tool.start() 118 | if language == "nodejs": 119 | return releasetool.commands.start.nodejs.start() 120 | if language == "java": 121 | return releasetool.commands.start.java.start() 122 | if language == "ruby": 123 | return releasetool.commands.start.ruby.start() 124 | if language == "go": 125 | return releasetool.commands.start.go.start() 126 | 127 | 128 | @main.command() 129 | @_language_option() 130 | def tag(language): 131 | if language == "python": 132 | return releasetool.commands.tag.python.tag() 133 | if language == "python-tool": 134 | return releasetool.commands.tag.python_tool.tag() 135 | if language == "nodejs": 136 | return releasetool.commands.tag.nodejs.tag() 137 | if language == "java": 138 | return releasetool.commands.tag.java.tag() 139 | if language == "php": 140 | return releasetool.commands.tag.php.tag() 141 | if language == "ruby": 142 | return releasetool.commands.tag.ruby.tag() 143 | if language == "dotnet": 144 | return releasetool.commands.tag.dotnet.tag() 145 | 146 | 147 | @main.command(name="reset-config") 148 | def reset_config(): 149 | releasetool.secrets.delete_password() 150 | 151 | 152 | @main.command(name="publish-reporter-start") 153 | @click.option("--github_token", envvar="GITHUB_TOKEN", default=None) 154 | @click.option("--pr", envvar="AUTORELEASE_PR", default=None) 155 | @click.option("--app_id_path", envvar="APP_ID_PATH", default=None) 156 | @click.option("--installation_id_path", envvar="INSTALLATION_ID_PATH", default=None) 157 | @click.option("--private_key_path", envvar="GITHUB_PRIVATE_KEY_PATH", default=None) 158 | def publish_reporter_start( 159 | github_token: str, 160 | pr: str, 161 | app_id_path: str, 162 | installation_id_path: str, 163 | private_key_path: str, 164 | ): 165 | if app_id_path: 166 | github_token = github_jwt_dict( 167 | app_id_path, installation_id_path, private_key_path 168 | ) 169 | releasetool.commands.publish_reporter.start(github_token, pr) 170 | 171 | 172 | @main.command(name="publish-reporter-finish") 173 | @click.option("--github_token", envvar="GITHUB_TOKEN", default=None) 174 | @click.option("--pr", envvar="AUTORELEASE_PR", default=None) 175 | @click.option("--status", type=bool, default=True) 176 | @click.option("--details", envvar="PUBLISH_DETAILS", default=None) 177 | @click.option("--app_id_path", envvar="APP_ID_PATH", default=None) 178 | @click.option("--installation_id_path", envvar="INSTALLATION_ID_PATH", default=None) 179 | @click.option("--private_key_path", envvar="GITHUB_PRIVATE_KEY_PATH", default=None) 180 | def publish_reporter_finish( 181 | github_token: str, 182 | pr: str, 183 | status: bool, 184 | details: str, 185 | app_id_path: str, 186 | installation_id_path: str, 187 | private_key_path: str, 188 | ): 189 | if app_id_path: 190 | github_token = github_jwt_dict( 191 | app_id_path, installation_id_path, private_key_path 192 | ) 193 | releasetool.commands.publish_reporter.finish(github_token, pr, status, details) 194 | 195 | 196 | def github_jwt_dict(app_id_path: str, installation_id_path: str, private_key_path: str): 197 | """An app_id, installation_id, and private_key may be provided, rather 198 | than a github_token. This dictionary of values is passed to publish_reporter 199 | which exchanges them for a JWT.""" 200 | return { 201 | "app_id": open(app_id_path, "r").read(), 202 | "installation_id": open(installation_id_path, "r").read(), 203 | "private_key": open(private_key_path, "r").read(), 204 | } 205 | 206 | 207 | @main.command(name="publish-reporter-script") 208 | def publish_reporter_script(): 209 | releasetool.commands.publish_reporter.script() 210 | 211 | 212 | if __name__ == "__main__": 213 | main() 214 | -------------------------------------------------------------------------------- /releasetool/circleci.py: -------------------------------------------------------------------------------- 1 | # Copyright 2018 Google LLC 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # https://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | from typing import Iterator, Optional 16 | import time 17 | from datetime import datetime 18 | 19 | import requests 20 | 21 | _CIRCLE_ROOT: str = "https://circleci.com/api/v1.1" 22 | 23 | 24 | class CircleCI: 25 | def __init__(self, repository: str, vcs: str = "github") -> None: 26 | self.session: requests.Session = requests.Session() 27 | self.vcs = vcs 28 | self.repo = repository 29 | 30 | def get_latest_build_by_tag(self, tag: str, retries: int = 15) -> Optional[dict]: 31 | url = f"{_CIRCLE_ROOT}/project/{self.vcs}/{self.repo}" 32 | 33 | for retry in range(1, retries): 34 | response = self.session.get(url) 35 | response.raise_for_status() 36 | for build in response.json(): 37 | if "branch" in build.keys() and build["vcs_tag"] == tag: 38 | return build 39 | time.sleep(retry) 40 | 41 | return None 42 | 43 | def get_latest_build_by_branch(self, branch_name: str) -> Optional[dict]: 44 | url = f"{_CIRCLE_ROOT}/project/{self.vcs}/{self.repo}" 45 | 46 | response = self.session.get(url) 47 | response.raise_for_status() 48 | for build in response.json(): 49 | if "branch" in build.keys() and build["branch"] == branch_name: 50 | return build 51 | 52 | return None 53 | 54 | def get_fresh_build_by_branch( 55 | self, branch_name: str, seconds_fresh: int = 60, retries: int = 15 56 | ) -> Optional[dict]: 57 | """ 58 | Find a build that is less than seconds_fresh old. Useful if you 59 | need to find a build that isn't an old run 60 | """ 61 | for retry in range(1, retries): 62 | build = self.get_latest_build_by_branch(branch_name) 63 | if not build: 64 | continue 65 | build_queued = build["queued_at"] 66 | queued_time = datetime.strptime(build_queued, "%Y-%m-%dT%H:%M:%S.%fZ") 67 | time_delta = datetime.utcnow() - queued_time 68 | if time_delta.total_seconds() <= seconds_fresh: 69 | return build 70 | 71 | # we either didn't find a build (hasn't been queued) or we 72 | # found a build but it was stale. Wait for new build to be queued. 73 | time.sleep(retry) 74 | return None 75 | 76 | def get_build(self, build_num: str) -> dict: 77 | url = f"{_CIRCLE_ROOT}/project/{self.vcs}/{self.repo}/{build_num}" 78 | response = self.session.get(url) 79 | response.raise_for_status() 80 | return response.json() 81 | 82 | def get_link_to_build(self, build_num: int): 83 | # API vcs and FE vcs are different 84 | vcs_map = {"github": "gh"} 85 | if self.vcs in vcs_map.keys(): 86 | url_vcs = vcs_map[self.vcs] 87 | else: 88 | # if we don't have it in the mapping provide it directly. 89 | url_vcs = self.vcs 90 | 91 | # https://circleci.com/gh/GitHubUser/RepositoryName/1234 92 | return f"https://circleci.com/{url_vcs}/{self.repo}/{build_num}" 93 | 94 | def get_build_status_generator(self, build_num: str) -> Iterator[str]: 95 | """ 96 | Returns a generator that polls circle for the status of a branch. It 97 | continues to return results until it enters a finished state 98 | """ 99 | """ 100 | lifecycle_states = [ 101 | "queued", "scheduled", "not_run", "not_running", "running", 102 | "finished" ] 103 | 104 | build_status_states = [ 105 | "retried", "canceled", "infrastructure_fail", "timedout", 106 | "not_run", "running", "failed", "queued", "scheduled", 107 | "not_running", "no_tests", "fixed", "success" ] 108 | """ 109 | build = self.get_build(build_num) 110 | while "lifecycle" in build.keys() and build["lifecycle"] != "finished": 111 | yield build["status"] 112 | time.sleep(10) 113 | build = self.get_build(build_num) 114 | yield build["status"] 115 | return 116 | -------------------------------------------------------------------------------- /releasetool/commands/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/googleapis/releasetool/4a6e25c4a0cb6b6db1815f578012edb8cb884595/releasetool/commands/__init__.py -------------------------------------------------------------------------------- /releasetool/commands/common.py: -------------------------------------------------------------------------------- 1 | # Copyright 2018 Google LLC 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # https://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | import datetime 16 | from typing import Optional, Sequence, Tuple 17 | from urllib import parse 18 | 19 | import attr 20 | import click 21 | import pyperclip 22 | from dateutil import tz 23 | import requests 24 | 25 | import releasetool.filehelpers 26 | import releasetool.git 27 | import releasetool.github 28 | import releasetool.secrets 29 | 30 | 31 | @attr.s(auto_attribs=True, slots=True) 32 | class BaseContext: 33 | interactive: bool = True 34 | 35 | 36 | @attr.s(auto_attribs=True, slots=True) 37 | class GitHubContext(BaseContext): 38 | github: Optional[releasetool.github.GitHub] = None 39 | origin_user: Optional[str] = None 40 | origin_repo: Optional[str] = None 41 | upstream_name: Optional[str] = None 42 | upstream_repo: Optional[str] = None 43 | package_name: Optional[str] = None 44 | changes: Sequence[str] = () 45 | release_notes: Optional[str] = None 46 | 47 | 48 | @attr.s(auto_attribs=True, slots=True) 49 | class TagContext(GitHubContext): 50 | release_pr: Optional[dict] = None 51 | release_tag: Optional[str] = None 52 | release_version: Optional[str] = None 53 | github_release: Optional[dict] = None 54 | kokoro_job_name: Optional[str] = None 55 | fusion_url: Optional[str] = None 56 | token: Optional[str] = None 57 | 58 | 59 | def _determine_origin(ctx: GitHubContext) -> None: 60 | remotes = releasetool.git.get_github_remotes() 61 | origin = remotes["origin"] 62 | ctx.origin_user = origin.split("/")[0] 63 | ctx.origin_repo = origin 64 | 65 | 66 | def _determine_upstream(ctx: GitHubContext, owners: Tuple[str, ...]) -> None: 67 | remotes = releasetool.git.get_github_remotes() 68 | repos = { 69 | name: repo for name, repo in remotes.items() if repo.lower().startswith(owners) 70 | } 71 | 72 | if not repos: 73 | raise ValueError("Unable to determine the upstream GitHub repo. :(") 74 | 75 | if len(repos) > 1: 76 | options = "\n".join(f" * {name}: {repo}" for name, repo in repos.items()) 77 | choice = click.prompt( 78 | click.style( 79 | f"More than one possible upstream remote was found." 80 | f"\n\n{options}\n\n" 81 | f"Please enter the *name* of one you want to use", 82 | fg="yellow", 83 | ), 84 | default="origin", 85 | ) 86 | try: 87 | upstream = choice, repos[choice] 88 | except KeyError: 89 | click.secho(f"No remote named {choice}!", fg="red") 90 | raise click.Abort() 91 | else: 92 | upstream = repos.popitem() 93 | 94 | ctx.upstream_name, ctx.upstream_repo = upstream 95 | 96 | 97 | def setup_github_context( 98 | ctx: GitHubContext, 99 | owners: Tuple[str, ...] = ("googlecloudplatform", "googleapis", "google"), 100 | ) -> None: 101 | click.secho("> Determining GitHub context.", fg="cyan") 102 | github_token = releasetool.secrets.ensure_password( 103 | "github", 104 | "Please provide your GitHub API token with write:repo_hook and " 105 | "public_repo (https://github.com/settings/tokens)", 106 | ) 107 | 108 | ctx.github = releasetool.github.GitHub( 109 | releasetool.github.GitHubToken(github_token, "Bearer") 110 | ) 111 | 112 | _determine_origin(ctx) 113 | _determine_upstream(ctx, owners) 114 | 115 | click.secho(f"Origin: {ctx.origin_repo}, Upstream: {ctx.upstream_repo}") 116 | 117 | # Compare upstream/master with master 118 | click.secho(f"> Comparing {ctx.upstream_name}/master to master.", fg="cyan") 119 | 120 | if releasetool.git.get_latest_commit( 121 | f"{ctx.upstream_name}/master" 122 | ) == releasetool.git.get_latest_commit("master"): 123 | click.echo(f"master is up to date with {ctx.upstream_name}/master") 124 | else: 125 | click.secho( 126 | f"WARNING: master is not up to date with {ctx.upstream_name}/master", 127 | fg="red", 128 | ) 129 | if click.confirm("> Would you like to continue?") is False: 130 | exit() 131 | 132 | 133 | def edit_release_notes(ctx: GitHubContext) -> None: 134 | click.secho("> Opening your editor to finalize release notes.", fg="cyan") 135 | release_notes = ( 136 | datetime.datetime.now(datetime.timezone.utc) 137 | .astimezone(tz.gettz("US/Pacific")) 138 | .strftime("%m-%d-%Y %H:%M %Z\n\n") 139 | ) 140 | release_notes += "\n".join(f"- {change}" for change in ctx.changes) 141 | release_notes += "\n\n### ".join( 142 | [ 143 | "", 144 | "Implementation Changes", 145 | "New Features", 146 | "Dependencies", 147 | "Documentation", 148 | "Internal / Testing Changes", 149 | ] 150 | ) 151 | ctx.release_notes = releasetool.filehelpers.open_editor_with_tempfile( 152 | release_notes, "release-notes.md" 153 | ).strip() 154 | 155 | 156 | def release_exists(ctx: TagContext) -> bool: 157 | try: 158 | release = ctx.github.get_release(ctx.upstream_repo, ctx.release_tag) 159 | tag_sha = ctx.github.get_tag_sha(ctx.upstream_repo, ctx.release_tag) 160 | # If a 404 or similar happened, return False as it indicates the release 161 | # doesn't exit. 162 | except requests.HTTPError: 163 | return False 164 | 165 | if release is not None and tag_sha == ctx.release_pr["merge_commit_sha"]: 166 | return True 167 | else: 168 | return False 169 | 170 | 171 | def publish_via_kokoro(ctx: TagContext) -> None: 172 | kokoro_url = "https://fusion2.corp.google.com/ci;prev=s/kokoro/prod" 173 | ctx.fusion_url = f"{kokoro_url}:{parse.quote_plus(ctx.kokoro_job_name)}" 174 | 175 | if ctx.interactive: 176 | pyperclip.copy(ctx.release_tag) 177 | 178 | click.secho( 179 | "> Trigger the Kokoro build with the commitish below to publish to PyPI. The commitish has been copied to the clipboard.", 180 | fg="cyan", 181 | ) 182 | 183 | click.secho(f"Kokoro build URL:\t\t{click.style(ctx.fusion_url, underline=True)}") 184 | click.secho(f"Commitish:\t{click.style(ctx.release_tag, bold=True)}") 185 | 186 | if ctx.interactive: 187 | if click.confirm("Would you like to go the Kokoro build page?", default=True): 188 | click.launch(ctx.fusion_url) 189 | -------------------------------------------------------------------------------- /releasetool/commands/publish_reporter.py: -------------------------------------------------------------------------------- 1 | # Copyright 2018 Google LLC 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # https://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | """Used by publish CI jobs to report status back to GitHub.""" 16 | 17 | import os 18 | import importlib 19 | import re 20 | from typing import cast, Tuple, Union 21 | from requests import HTTPError 22 | 23 | import releasetool.github 24 | 25 | 26 | def figure_out_github_token(github_token: str) -> str: 27 | # This script is designed to run in Kokoro. There's several sources where 28 | # the GitHub token could be, and we want to make adding this script easy. 29 | # We'll try the common ones before giving up. 30 | 31 | # A valid github token was passed on the command line, make sure it's 32 | # not a file and just return it. 33 | if github_token is not None: 34 | if os.path.exists(github_token): 35 | with open(github_token, "r", encoding="utf-8") as fh: 36 | return fh.read().strip() 37 | else: 38 | return github_token 39 | 40 | # First, try KeyStore 41 | paths = [] 42 | if "KOKORO_KEYSTORE_DIR" in os.environ: 43 | paths.append( 44 | os.path.join( 45 | os.environ["KOKORO_KEYSTORE_DIR"], "73713_releasetool-magictoken" 46 | ) 47 | ) 48 | 49 | # Second, try gfile resources. 50 | if "KOKORO_GFILE_DIR" in os.environ: 51 | paths.append( 52 | os.path.join( 53 | os.environ["KOKORO_GFILE_DIR"], "yoshi-releasetool-magictoken.txt" 54 | ) 55 | ) 56 | 57 | for path in paths: 58 | if os.path.exists(path): 59 | with open(path, "r", encoding="utf-8") as fh: 60 | return fh.read().strip() 61 | 62 | return None 63 | 64 | 65 | def extract_pr_details(pr) -> Tuple[str, str, str]: 66 | match = re.match( 67 | r"https://github\.com/(?P.+?)/(?P.+?)/pull/(?P\d+?)$", pr 68 | ) 69 | 70 | if not match: 71 | raise ValueError("Not a PR URL.") 72 | 73 | return match.group("owner"), match.group("repo"), match.group("number") 74 | 75 | 76 | def start(github_token_raw: Union[str, dict], pr: str) -> None: 77 | """Reports the start of a publication job to GitHub.""" 78 | # If we are passed a dictionary for github_token, assume we are 79 | # retrieveing a JWT, and do not use magic proxy: 80 | use_proxy = True 81 | if type(github_token_raw) is dict: 82 | use_proxy = False 83 | github_token = releasetool.github.GitHubToken.token_from_dict( 84 | cast(dict, github_token_raw) 85 | ) 86 | else: 87 | github_token_raw = figure_out_github_token(cast(str, github_token_raw)) 88 | github_token = releasetool.github.GitHubToken(github_token_raw, "Bearer") 89 | 90 | if not github_token or not pr: 91 | print("No github token or PR specified to report status to, returning.") 92 | return 93 | 94 | gh = releasetool.github.GitHub(github_token, use_proxy=use_proxy) 95 | 96 | try: 97 | owner, repo, number = extract_pr_details(pr) 98 | except ValueError: 99 | print("Invalid PR number, returning.") 100 | return 101 | 102 | build_url = os.environ.get("CLOUD_LOGGING_URL") 103 | 104 | if not build_url: 105 | kokoro_build_id = os.environ.get("KOKORO_BUILD_ID") 106 | 107 | if kokoro_build_id: 108 | build_url = f"http://sponge/{kokoro_build_id}" 109 | 110 | if build_url: 111 | message = f"The release build has started, the log can be viewed [here]({build_url}). :sunflower:" 112 | else: 113 | message = "The release build has started, but the build log URL could not be determined. :broken_heart:" 114 | 115 | try: 116 | gh.create_pull_request_comment(f"{owner}/{repo}", number, message) 117 | except HTTPError as e: 118 | # wrap exception so we don't show the proxy url 119 | raise Exception(f"Error commenting on PR: {e.response.status_code}") 120 | 121 | 122 | def finish( 123 | github_token_raw: Union[str, dict], pr: str, status: bool, details: str 124 | ) -> None: 125 | """Reports the completion of a publication job to GitHub.""" 126 | # If we are passed a dictionary for github_token, assume we are 127 | # retrieveing a JWT, and do not use magic proxy: 128 | use_proxy = True 129 | if type(github_token_raw) is dict: 130 | use_proxy = False 131 | github_token = releasetool.github.GitHubToken.token_from_dict( 132 | cast(dict, github_token_raw) 133 | ) 134 | else: 135 | github_token_raw = figure_out_github_token(cast(str, github_token_raw)) 136 | github_token = releasetool.github.GitHubToken(github_token_raw, "Bearer") 137 | 138 | if not github_token or not pr: 139 | print("No github token or PR specified to report status to, returning.") 140 | return 141 | 142 | gh = releasetool.github.GitHub(github_token, use_proxy=use_proxy) 143 | 144 | try: 145 | owner, repo, number = extract_pr_details(pr) 146 | except ValueError: 147 | print("Invalid PR number, returning.") 148 | return 149 | 150 | if status: 151 | message = ":egg: You hatched a release! The release build finished successfully! :purple_heart:" 152 | labels = ["autorelease: published"] 153 | else: 154 | message = "The release build failed! Please investigate!" 155 | labels = ["autorelease: failed"] 156 | 157 | if details: 158 | message += f"\n{details}" 159 | 160 | try: 161 | gh.create_pull_request_comment(f"{owner}/{repo}", number, message) 162 | except HTTPError as e: 163 | # wrap exception so we don't show the proxy url 164 | raise Exception(f"Error commenting on PR: {e.response.status_code}") 165 | 166 | try: 167 | pull = gh.get_pull_request(f"{owner}/{repo}", number) 168 | gh.update_pull_labels(pull, add=labels, remove=["autorelease: tagged"]) 169 | except HTTPError as e: 170 | # wrap exception so we don't show the proxy url 171 | raise Exception(f"Error updating lables on PR: {e.response.status_code}") 172 | 173 | 174 | def script(): 175 | resource = importlib.resources.read_binary( 176 | "releasetool.commands", "publish_reporter.sh" 177 | ) 178 | print(resource.decode("utf-8"), flush=True) 179 | -------------------------------------------------------------------------------- /releasetool/commands/publish_reporter.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Copyright 2018 Google LLC 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the "License"); 6 | # you may not use this file except in compliance with the License. 7 | # You may obtain a copy of the License at 8 | # 9 | # https://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, software 12 | # distributed under the License is distributed on an "AS IS" BASIS, 13 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | # See the License for the specific language governing permissions and 15 | # limitations under the License. 16 | 17 | if [ -f "${KOKORO_GFILE_DIR}/secret_manager/releasetool-publish-reporter-app" ]; then 18 | export APP_ID_PATH="${KOKORO_GFILE_DIR}/secret_manager/releasetool-publish-reporter-app" 19 | if [ -f "${KOKORO_GFILE_DIR}/secret_manager/releasetool-publish-reporter-googleapis-installation" ]; then 20 | export INSTALLATION_ID_PATH="${KOKORO_GFILE_DIR}/secret_manager/releasetool-publish-reporter-googleapis-installation" 21 | elif [ -f "${KOKORO_GFILE_DIR}/secret_manager/releasetool-publish-reporter-googlecloudplatform-installation" ]; then 22 | export INSTALLATION_ID_PATH="${KOKORO_GFILE_DIR}/secret_manager/releasetool-publish-reporter-googlecloudplatform-installation" 23 | else 24 | echo 'could not load GitHub installation id' 25 | fi 26 | export GITHUB_PRIVATE_KEY_PATH="${KOKORO_GFILE_DIR}/secret_manager/releasetool-publish-reporter-pem" 27 | else 28 | echo 'could not load GitHub installation credentials' 29 | fi 30 | 31 | # Install an exit hook to report status. 32 | releasetool_finish_report() { 33 | rv=$? 34 | if [[ $rv == 0 ]]; then 35 | python3 -m releasetool publish-reporter-finish --status yes || true 36 | else 37 | python3 -m releasetool publish-reporter-finish --status no || true 38 | fi 39 | echo "Release status reported." 40 | exit $rv 41 | } 42 | 43 | trap releasetool_finish_report EXIT 44 | 45 | # Report the start of a build 46 | python3 -m releasetool publish-reporter-start || true 47 | -------------------------------------------------------------------------------- /releasetool/commands/start/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/googleapis/releasetool/4a6e25c4a0cb6b6db1815f578012edb8cb884595/releasetool/commands/start/__init__.py -------------------------------------------------------------------------------- /releasetool/commands/start/go.py: -------------------------------------------------------------------------------- 1 | # Copyright 2018 Google LLC 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # https://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | import datetime 16 | import getpass 17 | import json 18 | import os 19 | import subprocess 20 | import textwrap 21 | from typing import Dict, List, Optional, Sequence 22 | 23 | import attr 24 | import click 25 | from dateutil import tz 26 | 27 | import releasetool.filehelpers 28 | import releasetool.git 29 | 30 | 31 | _CHANGELOG_FILENAME = "CHANGES.md" 32 | 33 | _CHANGELOG_TEMPLATE = """\ 34 | # Changes 35 | 36 | """ 37 | 38 | 39 | @attr.s(auto_attribs=True, slots=True) 40 | class Context: 41 | module_name: str = None 42 | relative_module_name: str = None # relative to repo root; used for tags 43 | changes: Sequence[str] = () 44 | release_notes: Optional[str] = None 45 | last_release_version: Optional[str] = None 46 | last_release_committish: Optional[str] = None 47 | release_version: Optional[str] = None 48 | release_branch: Optional[str] = None 49 | 50 | 51 | def determine_module_name(ctx: Context) -> None: 52 | # Get the module name from the go.mod file in the current directory. 53 | click.secho("> Figuring out the module name.", fg="cyan") 54 | info = read_gomod() 55 | if info is None: 56 | click.secho("Looks like we're releasing the repo (no modules).") 57 | else: 58 | ctx.module_name = info["Module"]["Path"] 59 | ctx.relative_module_name = relative_module_name(ctx.module_name) 60 | click.secho( 61 | f"Looks like we're releasing {ctx.module_name} (relative path {ctx.relative_module_name})." 62 | ) 63 | 64 | 65 | def read_gomod() -> Optional[dict]: 66 | if os.path.isdir(".git"): 67 | return None 68 | elif os.path.isfile("go.mod"): 69 | output = subprocess.check_output(["go", "mod", "edit", "-json"]).decode("utf-8") 70 | return json.loads(output) 71 | else: 72 | raise ValueError("no go.mod; must release from repo root") 73 | 74 | 75 | def relative_module_name(modname) -> str: 76 | """Returns modname relative to the repo root. 77 | 78 | Assumes modname's go.mod file is in the current directory. 79 | """ 80 | dir = os.getcwd() 81 | components: List[str] = [] 82 | while dir != "/": 83 | if os.path.isdir(os.path.join(dir, ".git")): 84 | return "/".join(reversed(components)) 85 | dir, c = os.path.split(dir) 86 | components.append(c) 87 | raise ValueError("not inside a git repo") 88 | 89 | 90 | def determine_last_release(ctx: Context) -> None: 91 | click.secho("> Figuring out what the last release was.", fg="cyan") 92 | if ctx.relative_module_name is None: 93 | prefix = "v" 94 | else: 95 | prefix = ctx.relative_module_name + "/v" 96 | tags = releasetool.git.list_tags() 97 | candidates = [tag for tag in tags if tag.startswith(prefix)] 98 | 99 | if candidates: 100 | ctx.last_release_committish = candidates[0] 101 | ctx.last_release_version = candidates[0].rsplit("/").pop()[1:] 102 | 103 | else: 104 | click.secho( 105 | f"I couldn't figure out the last release for {ctx.module_name}, " 106 | "so I'm assuming this is the first release. Can you tell me " 107 | "which git rev/sha to start the changelog at?", 108 | fg="yellow", 109 | ) 110 | ctx.last_release_committish = click.prompt("Committish") 111 | ctx.last_release_version = "0.0.0" 112 | 113 | click.secho(f"The last release was {ctx.last_release_version}.") 114 | 115 | 116 | def gather_changes(ctx: Context) -> None: 117 | click.secho(f"> Gathering changes since {ctx.last_release_version}", fg="cyan") 118 | ctx.changes = releasetool.git.summary_log( 119 | from_=ctx.last_release_committish, to="origin/master" 120 | ) 121 | click.secho(f"Cool, {len(ctx.changes)} changes found.") 122 | 123 | 124 | def determine_release_version(ctx: Context) -> None: 125 | click.secho("> Now it's time to pick a release version!", fg="cyan") 126 | release_notes = textwrap.indent(ctx.release_notes, "\t") 127 | click.secho(f"Here's the release notes you wrote:\n\n{release_notes}\n") 128 | 129 | parsed_version = [int(x) for x in ctx.last_release_version.split(".")] 130 | 131 | if parsed_version == [0, 0, 0]: 132 | ctx.release_version = "0.1.0" 133 | if not click.confirm(f"Release {ctx.release_version}?", default=True): 134 | version = click.prompt("What version should we release?") 135 | ctx.release_version = version 136 | return 137 | 138 | selection = click.prompt( 139 | "Is this a major, minor, or patch update (or enter the new version " "directly)" 140 | ) 141 | if selection == "major": 142 | parsed_version[0] += 1 143 | parsed_version[1] = 0 144 | parsed_version[2] = 0 145 | elif selection == "minor": 146 | parsed_version[1] += 1 147 | parsed_version[2] = 0 148 | elif selection == "patch": 149 | parsed_version[2] += 1 150 | else: 151 | ctx.release_version = selection 152 | return 153 | 154 | ctx.release_version = "{}.{}.{}".format(*parsed_version) 155 | 156 | click.secho(f"Got it, releasing {ctx.release_version}.") 157 | 158 | 159 | def create_release_branch(ctx) -> None: 160 | if ctx.module_name is None: 161 | ctx.release_branch = f"release-{ctx.release_version}" 162 | else: 163 | ctx.release_branch = f"release-{ctx.module_name}-{ctx.release_version}" 164 | click.secho(f"> Creating branch {ctx.release_branch}", fg="cyan") 165 | return releasetool.git.checkout_create_branch(ctx.release_branch) 166 | 167 | 168 | def update_changelog(ctx: Context) -> None: 169 | click.secho(f"> Updating {_CHANGELOG_FILENAME}.", fg="cyan") 170 | 171 | if not os.path.exists(_CHANGELOG_FILENAME): 172 | print(f"{_CHANGELOG_FILENAME} does not yet exist. Opening it for creation.") 173 | 174 | releasetool.filehelpers.open_editor_with_content( 175 | _CHANGELOG_FILENAME, _CHANGELOG_TEMPLATE 176 | ) 177 | 178 | changelog_entry = ( 179 | f"## v{ctx.release_version}" f"\n\n" f"{ctx.release_notes}" f"\n\n" 180 | ) 181 | releasetool.filehelpers.insert_before( 182 | _CHANGELOG_FILENAME, changelog_entry, r"^## (.+)$|\Z" 183 | ) 184 | 185 | 186 | def create_release_commit(ctx: Context) -> None: 187 | """Create a release commit.""" 188 | click.secho("> Comitting changes", fg="cyan") 189 | releasetool.git.commit([_CHANGELOG_FILENAME], f"all: release {ctx.release_version}") 190 | 191 | 192 | def create_release_cl(ctx: Context) -> None: 193 | click.secho("> Creating release CL.", fg="cyan") 194 | revs = click.prompt("reviewers (comma-separated)", default="deklerk,jba") 195 | subprocess.check_output(["git", "codereview", "mail", "-r", revs, "HEAD"]) 196 | 197 | 198 | def edit_release_notes(ctx: Context) -> None: 199 | click.secho("> Opening your editor to finalize release notes.", fg="cyan") 200 | release_notes = ( 201 | datetime.datetime.now(datetime.timezone.utc) 202 | .astimezone(tz.gettz("US/Pacific")) 203 | .strftime("%m-%d-%Y %H:%M %Z\n\n") 204 | ) 205 | 206 | packages: Dict[str, List[str]] = {} 207 | for change in ctx.changes: 208 | try: 209 | package, commit = change.split(":", 1) 210 | except ValueError: 211 | package = "all" 212 | commit = change 213 | commit = commit.strip() 214 | try: 215 | packages[package].append(commit) 216 | except KeyError: 217 | packages[package] = [commit] 218 | 219 | # sort packages alphabetically 220 | sorted_packages = sorted(list(packages.items()), key=lambda x: x[0]) 221 | for package, commits in sorted_packages: 222 | commit_list = "\n".join(f" - {commit}" for commit in commits) 223 | release_notes += f"- {package}:\n{commit_list}\n" 224 | 225 | ctx.release_notes = releasetool.filehelpers.open_editor_with_tempfile( 226 | release_notes, "release-notes.md" 227 | ).strip() 228 | 229 | 230 | def start() -> None: 231 | ctx = Context() 232 | 233 | click.secho(f"o/ Hey, {getpass.getuser()}, let's release some stuff!", fg="magenta") 234 | 235 | determine_module_name(ctx) 236 | determine_last_release(ctx) 237 | gather_changes(ctx) 238 | edit_release_notes(ctx) 239 | determine_release_version(ctx) 240 | create_release_branch(ctx) 241 | update_changelog(ctx) 242 | create_release_commit(ctx) 243 | create_release_cl(ctx) 244 | 245 | click.secho("\\o/ All done!", fg="magenta") 246 | -------------------------------------------------------------------------------- /releasetool/commands/start/nodejs.py: -------------------------------------------------------------------------------- 1 | # Copyright 2018 Google LLC 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # https://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | import getpass 16 | import os 17 | import textwrap 18 | from typing import Optional 19 | 20 | import attr 21 | import click 22 | 23 | import releasetool.filehelpers 24 | import releasetool.git 25 | import releasetool.github 26 | import releasetool.secrets 27 | import releasetool.commands.common 28 | 29 | 30 | _CHANGELOG_TEMPLATE = """\ 31 | # Changelog 32 | 33 | [npm history][1] 34 | 35 | [1]: https://www.npmjs.com/package/{package_name}?activeTab=versions 36 | 37 | """ 38 | 39 | 40 | @attr.s(auto_attribs=True, slots=True) 41 | class Context(releasetool.commands.common.GitHubContext): 42 | last_release_version: Optional[str] = None 43 | last_release_committish: Optional[str] = None 44 | release_version: Optional[str] = None 45 | release_branch: Optional[str] = None 46 | pull_request: Optional[dict] = None 47 | 48 | 49 | def determine_package_name(ctx: Context) -> None: 50 | click.secho("> Figuring out the package name.", fg="cyan") 51 | ctx.package_name = releasetool.filehelpers.extract( 52 | "package.json", r'"name": "(.*?)"' 53 | ) 54 | click.secho(f"Looks like we're releasing {ctx.package_name}.") 55 | 56 | 57 | def determine_last_release(ctx: Context) -> None: 58 | click.secho("> Figuring out what the last release was.", fg="cyan") 59 | tags = releasetool.git.list_tags() 60 | candidates = [tag for tag in tags if tag.startswith("v")] 61 | 62 | if candidates: 63 | ctx.last_release_committish = candidates[0] 64 | # strip the leading 'v' 65 | ctx.last_release_version = candidates[0].lstrip("v") 66 | 67 | else: 68 | click.secho( 69 | f"I couldn't figure out the last release for {ctx.package_name}, " 70 | "so I'm assuming this is the first release. Can you tell me " 71 | "which git rev/sha to start the changelog at?", 72 | fg="yellow", 73 | ) 74 | ctx.last_release_committish = click.prompt("Committish") 75 | ctx.last_release_version = "0.0.0" 76 | 77 | click.secho(f"The last release was {ctx.last_release_version}.") 78 | 79 | 80 | def gather_changes(ctx: Context) -> None: 81 | click.secho(f"> Gathering changes since {ctx.last_release_version}", fg="cyan") 82 | ctx.changes = releasetool.git.summary_log( 83 | from_=ctx.last_release_committish, to=f"{ctx.upstream_name}/master" 84 | ) 85 | ctx.changes = [ 86 | ctx.github.link_pull_request(c, ctx.upstream_repo) for c in ctx.changes 87 | ] 88 | click.secho(f"Cool, {len(ctx.changes)} changes found.") 89 | 90 | 91 | def determine_release_version(ctx: Context) -> None: 92 | click.secho("> Now it's time to pick a release version!", fg="cyan") 93 | release_notes = textwrap.indent(ctx.release_notes, "\t") 94 | click.secho(f"Here's the release notes you wrote:\n\n{release_notes}\n") 95 | 96 | parsed_version = [int(x) for x in ctx.last_release_version.split(".")] 97 | 98 | if parsed_version == [0, 0, 0]: 99 | ctx.release_version = "0.1.0" 100 | if not click.confirm(f"Release {ctx.release_version}?", default=True): 101 | version = click.prompt("What version should we release?") 102 | ctx.release_version = version 103 | return 104 | 105 | selection = click.prompt( 106 | "Is this a major, minor, or patch update (or enter the new version " "directly)" 107 | ) 108 | if selection == "major": 109 | parsed_version[0] += 1 110 | parsed_version[1] = 0 111 | parsed_version[2] = 0 112 | elif selection == "minor": 113 | parsed_version[1] += 1 114 | parsed_version[2] = 0 115 | elif selection == "patch": 116 | parsed_version[2] += 1 117 | else: 118 | ctx.release_version = selection 119 | return 120 | 121 | ctx.release_version = "{}.{}.{}".format(*parsed_version) 122 | 123 | click.secho(f"Got it, releasing {ctx.release_version}.") 124 | 125 | 126 | def create_release_branch(ctx) -> None: 127 | ctx.release_branch = f"release-v{ctx.release_version}" 128 | click.secho(f"> Creating branch {ctx.release_branch}", fg="cyan") 129 | return releasetool.git.checkout_create_branch(ctx.release_branch) 130 | 131 | 132 | def update_changelog(ctx: Context) -> None: 133 | changelog_filename = "CHANGELOG.md" 134 | click.secho(f"> Updating {changelog_filename}.", fg="cyan") 135 | 136 | if not os.path.exists(changelog_filename): 137 | print(f"{changelog_filename} does not yet exist. Opening it for " "creation.") 138 | 139 | releasetool.filehelpers.open_editor_with_content( 140 | changelog_filename, 141 | _CHANGELOG_TEMPLATE.format(package_name=ctx.package_name), 142 | ) 143 | 144 | changelog_entry = ( 145 | f"## v{ctx.release_version}" f"\n\n" f"{ctx.release_notes}" f"\n\n" 146 | ) 147 | releasetool.filehelpers.insert_before( 148 | changelog_filename, changelog_entry, r"^## (.+)$|\Z" 149 | ) 150 | 151 | 152 | def update_package_json(ctx: Context) -> None: 153 | click.secho("> Updating package.json.", fg="cyan") 154 | releasetool.filehelpers.replace( 155 | "package.json", r'"version": "(.+?)"', f'"version": "{ctx.release_version}"' 156 | ) 157 | 158 | 159 | def update_samples_package_json(ctx: Context) -> None: 160 | click.secho("> Updating samples/package.json.", fg="cyan") 161 | try: 162 | releasetool.filehelpers.replace( 163 | "samples/package.json", 164 | f'"{ctx.package_name}": "(.+?)"', 165 | f'"{ctx.package_name}": "^{ctx.release_version}"', 166 | ) 167 | except FileNotFoundError: 168 | pass 169 | 170 | 171 | def create_release_commit(ctx: Context) -> None: 172 | """Create a release commit.""" 173 | click.secho("> Committing changes", fg="cyan") 174 | files = ["CHANGELOG.md", "package.json"] 175 | 176 | # Not all node.js repos have a samples directory 177 | if os.path.exists("samples/package.json"): 178 | files.append("samples/package.json") 179 | 180 | releasetool.git.commit(files, f"Release v{ctx.release_version}") 181 | 182 | 183 | def push_release_branch(ctx: Context) -> None: 184 | click.secho("> Pushing release branch.", fg="cyan") 185 | releasetool.git.push(ctx.release_branch) 186 | 187 | 188 | def create_release_pr(ctx: Context, autorelease: bool = True) -> None: 189 | click.secho("> Creating release pull request.", fg="cyan") 190 | 191 | if ctx.upstream_repo == ctx.origin_repo: 192 | head = ctx.release_branch 193 | else: 194 | head = f"{ctx.origin_user}:{ctx.release_branch}" 195 | 196 | ctx.pull_request = ctx.github.create_pull_request( 197 | ctx.upstream_repo, 198 | head=head, 199 | title=f"Release {ctx.package_name} v{ctx.release_version}", 200 | body="This pull request was generated using releasetool.", 201 | ) 202 | 203 | if autorelease: 204 | ctx.github.add_issue_labels( 205 | ctx.upstream_repo, ctx.pull_request["number"], ["autorelease: pending"] 206 | ) 207 | 208 | click.secho(f"Pull request is at {ctx.pull_request['html_url']}.") 209 | 210 | 211 | def start() -> None: 212 | ctx = Context() 213 | 214 | click.secho(f"o/ Hey, {getpass.getuser()}, let's release some stuff!", fg="magenta") 215 | 216 | releasetool.commands.common.setup_github_context(ctx) 217 | determine_package_name(ctx) 218 | determine_last_release(ctx) 219 | gather_changes(ctx) 220 | releasetool.commands.common.edit_release_notes(ctx) 221 | determine_release_version(ctx) 222 | create_release_branch(ctx) 223 | update_changelog(ctx) 224 | update_package_json(ctx) 225 | update_samples_package_json(ctx) 226 | create_release_commit(ctx) 227 | push_release_branch(ctx) 228 | # TODO: Confirm? 229 | create_release_pr(ctx) 230 | 231 | click.secho("\\o/ All done!", fg="magenta") 232 | -------------------------------------------------------------------------------- /releasetool/commands/start/python.py: -------------------------------------------------------------------------------- 1 | # Copyright 2018 Google LLC 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # https://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | import getpass 16 | import os 17 | import re 18 | import subprocess 19 | import sys 20 | import textwrap 21 | from typing import Optional, Sequence, Tuple 22 | 23 | import attr 24 | import click 25 | 26 | import releasetool.filehelpers 27 | import releasetool.git 28 | import releasetool.github 29 | import releasetool.secrets 30 | import releasetool.commands.common 31 | 32 | 33 | _CHANGELOG_TEMPLATE = """\ 34 | # Changelog 35 | 36 | [PyPI History][1] 37 | 38 | [1]: https://pypi.org/project/DISTRIBUTION NAME/#history 39 | 40 | """ 41 | 42 | 43 | @attr.s(auto_attribs=True, slots=True) 44 | class Context(releasetool.commands.common.GitHubContext): 45 | last_release_version: Optional[str] = None 46 | last_release_committish: Optional[str] = None 47 | release_version: Optional[str] = None 48 | release_branch: Optional[str] = None 49 | pull_request: Optional[dict] = None 50 | monorepo: bool = False # true only when releasing from google-cloud-python 51 | 52 | 53 | def determine_package_name(ctx: Context) -> None: 54 | click.secho("> Figuring out the package name.", fg="cyan") 55 | if ctx.monorepo: 56 | ctx.package_name = os.path.basename(os.getcwd()) 57 | else: 58 | ctx.package_name = subprocess.check_output( 59 | [sys.executable, "setup.py", "--name"] 60 | ).decode("utf-8") 61 | 62 | click.secho(f"Looks like we're releasing {ctx.package_name}.") 63 | 64 | 65 | def find_last_release_tag( 66 | tags: Sequence[str], package_name: str, monorepo: bool 67 | ) -> Optional[Tuple[str, str]]: 68 | commitish = None 69 | if monorepo: 70 | # tags look like storage-1.2.3 71 | package_names = [package_name, package_name.replace("_", "-")] 72 | candidates = [tag for tag in tags if tag.rsplit("-")[0] in package_names] 73 | 74 | if candidates: 75 | commitish = candidates[0] 76 | version = commitish.rsplit("-").pop() 77 | else: 78 | # tags look like v1.2.3 or 1.2.3 79 | candidates = [tag for tag in tags if re.match(r"v?(\d+\.\d+\.\d+)", tag)] 80 | if candidates: 81 | commitish = candidates[0] 82 | version = commitish.split("v").pop() 83 | 84 | if commitish: 85 | return commitish, version 86 | return None 87 | 88 | 89 | def determine_last_release(ctx: Context) -> None: 90 | click.secho("> Figuring out what the last release was.", fg="cyan") 91 | tags = releasetool.git.list_tags() 92 | 93 | candidate = find_last_release_tag(tags, ctx.package_name, ctx.monorepo) 94 | if candidate is not None: 95 | ctx.last_release_committish = candidate[0] 96 | ctx.last_release_version = candidate[1] 97 | 98 | else: 99 | click.secho( 100 | f"I couldn't figure out the last release for {ctx.package_name}, " 101 | "so I'm assuming this is the first release. Can you tell me " 102 | "which git rev/sha to start the changelog at?", 103 | fg="yellow", 104 | ) 105 | ctx.last_release_committish = click.prompt("Committish") 106 | ctx.last_release_version = "0.0.0" 107 | 108 | click.secho(f"The last release was {ctx.last_release_version}.") 109 | 110 | 111 | def gather_changes(ctx: Context) -> None: 112 | click.secho(f"> Gathering changes since {ctx.last_release_version}", fg="cyan") 113 | ctx.changes = releasetool.git.summary_log( 114 | from_=ctx.last_release_committish, to="master" 115 | ) 116 | ctx.changes = [ 117 | ctx.github.link_pull_request(c, ctx.upstream_repo) for c in ctx.changes 118 | ] 119 | click.secho(f"Cool, {len(ctx.changes)} changes found.") 120 | 121 | 122 | def determine_release_version(ctx: Context) -> None: 123 | click.secho("> Now it's time to pick a release version!", fg="cyan") 124 | release_notes = textwrap.indent(ctx.release_notes, "\t") 125 | click.secho(f"Here's the release notes you wrote:\n\n{release_notes}\n") 126 | 127 | parsed_version = [int(x) for x in ctx.last_release_version.split(".")] 128 | 129 | if parsed_version == [0, 0, 0]: 130 | ctx.release_version = "0.1.0" 131 | if not click.confirm(f"Release {ctx.release_version}?", default=True): 132 | version = click.prompt("What version should we release?") 133 | ctx.release_version = version 134 | return 135 | 136 | selection = click.prompt( 137 | "Is this a major, minor, or patch update (or enter the new version " "directly)" 138 | ) 139 | if selection == "major": 140 | parsed_version[0] += 1 141 | parsed_version[1] = 0 142 | parsed_version[2] = 0 143 | elif selection == "minor": 144 | parsed_version[1] += 1 145 | parsed_version[2] = 0 146 | elif selection == "patch": 147 | parsed_version[2] += 1 148 | else: 149 | ctx.release_version = selection 150 | return 151 | 152 | ctx.release_version = "{}.{}.{}".format(*parsed_version) 153 | 154 | click.secho(f"Got it, releasing {ctx.release_version}.") 155 | 156 | 157 | def create_release_branch(ctx) -> None: 158 | if ctx.monorepo: 159 | ctx.release_branch = f"release-{ctx.package_name}-{ctx.release_version}" 160 | else: 161 | ctx.release_branch = f"release-v{ctx.release_version}" 162 | click.secho(f"> Creating branch {ctx.release_branch}", fg="cyan") 163 | return releasetool.git.checkout_create_branch(ctx.release_branch) 164 | 165 | 166 | def update_changelog(ctx: Context) -> None: 167 | changelog_filename = "CHANGELOG.md" 168 | click.secho(f"> Updating {changelog_filename}.", fg="cyan") 169 | 170 | if not os.path.exists(changelog_filename): 171 | print(f"{changelog_filename} does not yet exist. Opening it for " "creation.") 172 | 173 | releasetool.filehelpers.open_editor_with_content( 174 | changelog_filename, _CHANGELOG_TEMPLATE 175 | ) 176 | 177 | changelog_entry = f"## {ctx.release_version}" f"\n\n" f"{ctx.release_notes}" f"\n\n" 178 | releasetool.filehelpers.insert_before( 179 | changelog_filename, changelog_entry, r"^## (.+)$|\Z" 180 | ) 181 | 182 | 183 | def update_setup_py(ctx: Context) -> None: 184 | click.secho("> Updating setup.py.", fg="cyan") 185 | releasetool.filehelpers.replace( 186 | "setup.py", 187 | r"version\s*=\s*(['\"])(.+?)['\"]", 188 | f"version = \\g<1>{ctx.release_version}\\g<1>", 189 | ) 190 | 191 | 192 | def create_release_commit(ctx: Context) -> None: 193 | """Create a release commit.""" 194 | click.secho("> Comitting changes", fg="cyan") 195 | if ctx.monorepo: 196 | commit_msg = f"chore({ctx.package_name}): release {ctx.release_version}" 197 | else: 198 | commit_msg = f"chore: release v{ctx.release_version}" 199 | releasetool.git.commit(["CHANGELOG.md", "setup.py"], commit_msg) 200 | 201 | 202 | def push_release_branch(ctx: Context) -> None: 203 | click.secho("> Pushing release branch.", fg="cyan") 204 | releasetool.git.push(ctx.release_branch) 205 | 206 | 207 | def create_release_pr(ctx: Context, autorelease: bool = True) -> None: 208 | click.secho("> Creating release pull request.", fg="cyan") 209 | 210 | if ctx.upstream_repo == ctx.origin_repo: 211 | head = ctx.release_branch 212 | else: 213 | head = f"{ctx.origin_user}:{ctx.release_branch}" 214 | 215 | if ctx.monorepo: 216 | pr_title = f"chore({ctx.package_name}): release {ctx.release_version}" 217 | else: 218 | pr_title = f"chore: Release v{ctx.release_version}" 219 | 220 | ctx.pull_request = ctx.github.create_pull_request( 221 | ctx.upstream_repo, 222 | head=head, 223 | title=pr_title, 224 | body="This pull request was generated using releasetool.", 225 | ) 226 | 227 | if autorelease: 228 | ctx.github.add_issue_labels( 229 | ctx.upstream_repo, ctx.pull_request["number"], ["autorelease: pending"] 230 | ) 231 | 232 | click.secho(f"Pull request is at {ctx.pull_request['html_url']}.") 233 | 234 | 235 | def start() -> None: 236 | ctx = Context() 237 | 238 | click.secho(f"o/ Hey, {getpass.getuser()}, let's release some stuff!", fg="magenta") 239 | 240 | releasetool.commands.common.setup_github_context(ctx) 241 | 242 | if "google-cloud-python" in ctx.origin_repo: 243 | ctx.monorepo = True 244 | 245 | determine_package_name(ctx) 246 | determine_last_release(ctx) 247 | gather_changes(ctx) 248 | releasetool.commands.common.edit_release_notes(ctx) 249 | determine_release_version(ctx) 250 | create_release_branch(ctx) 251 | update_changelog(ctx) 252 | update_setup_py(ctx) 253 | create_release_commit(ctx) 254 | push_release_branch(ctx) 255 | create_release_pr(ctx) 256 | 257 | click.secho("\\o/ All done!", fg="magenta") 258 | -------------------------------------------------------------------------------- /releasetool/commands/start/python_tool.py: -------------------------------------------------------------------------------- 1 | # Copyright 2018 Google LLC 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # https://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | import getpass 16 | import datetime 17 | 18 | import click 19 | from dateutil import tz 20 | 21 | import releasetool.commands.start.python 22 | from releasetool.commands.start.python import Context 23 | 24 | 25 | def determine_release_version(ctx: Context) -> None: 26 | ctx.release_version = ( 27 | datetime.datetime.now(datetime.timezone.utc) 28 | .astimezone(tz.gettz("US/Pacific")) 29 | .strftime("%Y.%m.%d") 30 | ) 31 | 32 | if ctx.release_version in ctx.last_release_version: 33 | click.secho( 34 | f"The release version {ctx.release_version} is already used.", fg="red" 35 | ) 36 | ctx.release_version = click.prompt("Please input another version: ") 37 | 38 | click.secho(f"Releasing {ctx.release_version}.") 39 | 40 | 41 | def start() -> None: 42 | # Python tools use calver, otherwise the process is the same as python 43 | # libraries. 44 | ctx = Context() 45 | 46 | click.secho(f"o/ Hey, {getpass.getuser()}, let's release some stuff!", fg="magenta") 47 | 48 | releasetool.commands.common.setup_github_context(ctx) 49 | releasetool.commands.start.python.determine_package_name(ctx) 50 | releasetool.commands.start.python.determine_last_release(ctx) 51 | releasetool.commands.start.python.gather_changes(ctx) 52 | releasetool.commands.common.edit_release_notes(ctx) 53 | determine_release_version(ctx) 54 | releasetool.commands.start.python.create_release_branch(ctx) 55 | releasetool.commands.start.python.update_changelog(ctx) 56 | releasetool.commands.start.python.update_setup_py(ctx) 57 | releasetool.commands.start.python.create_release_commit(ctx) 58 | releasetool.commands.start.python.push_release_branch(ctx) 59 | # TODO: Confirm? 60 | releasetool.commands.start.python.create_release_pr(ctx) 61 | 62 | click.secho("\\o/ All done!", fg="magenta") 63 | -------------------------------------------------------------------------------- /releasetool/commands/tag/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/googleapis/releasetool/4a6e25c4a0cb6b6db1815f578012edb8cb884595/releasetool/commands/tag/__init__.py -------------------------------------------------------------------------------- /releasetool/commands/tag/cpp.py: -------------------------------------------------------------------------------- 1 | # Copyright 2023 Google LLC 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # https://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | from typing import Union 16 | 17 | 18 | def kokoro_job_name(upstream_repo: str, package_name: str) -> Union[str, None]: 19 | """Return the Kokoro job name. 20 | 21 | Args: 22 | upstream_repo (str): The GitHub repo in the form of `/` 23 | package_name (str): The name of package to release 24 | 25 | Returns: 26 | The name of the Kokoro job to trigger or None if there is no job to trigger 27 | """ 28 | return f"cloud-devrel/client-libraries/cpp/{upstream_repo.rsplit('/', 1)[-1]}/release/publish" 29 | 30 | 31 | def package_name(pull: dict) -> Union[str, None]: 32 | return None 33 | -------------------------------------------------------------------------------- /releasetool/commands/tag/dotnet.py: -------------------------------------------------------------------------------- 1 | # Copyright 2019 Google LLC 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # https://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | import getpass 16 | import re 17 | from typing import Union 18 | 19 | import click 20 | 21 | import releasetool.git 22 | import releasetool.github 23 | import releasetool.secrets 24 | import releasetool.commands.common 25 | from releasetool.commands.common import TagContext 26 | 27 | # We need to match the PR titles we use in google-cloud-dotnet 28 | # Release Google.Maps.Places.V1 version 1.0.0-beta12 29 | # We need to match the PR titles set by Release Please 30 | # chore(main): release Google.CloudEvents.Protobuf 1.7.0-alpha01 31 | RELEASE_LINE_PATTERN = ( 32 | r"^((?:- )?R|chore\(main\): r)elease ([^ ]*)( version)? (\d+\.\d+.\d+(-[^ ]*)?)$" 33 | ) 34 | 35 | 36 | def determine_release_pr(ctx: TagContext) -> None: 37 | click.secho( 38 | "> Let's figure out which pull request corresponds to your release.", fg="cyan" 39 | ) 40 | 41 | pulls = ctx.github.list_pull_requests(ctx.upstream_repo, state="closed") 42 | pulls = [pull for pull in pulls if "release" in pull["title"].lower()][:30] 43 | 44 | click.secho("> Please pick one of the following PRs:\n") 45 | for n, pull in enumerate(pulls, 1): 46 | print(f"\t{n}: {pull['title']} ({pull['number']})") 47 | 48 | pull_idx = click.prompt( 49 | "\nWhich one do you want to tag and release?", type=click.INT 50 | ) 51 | ctx.release_pr = pulls[pull_idx - 1] 52 | 53 | 54 | def create_releases(ctx: TagContext) -> None: 55 | click.secho("> Creating the release.") 56 | 57 | commitish = ctx.release_pr["merge_commit_sha"] 58 | title = ctx.release_pr["title"] 59 | body_lines = (ctx.release_pr["body"] or "").splitlines() 60 | all_lines = [title] + body_lines 61 | pr_comment = "" 62 | for line in all_lines: 63 | match = re.search(RELEASE_LINE_PATTERN, line) 64 | if match is not None: 65 | package = match.group(2) 66 | version = match.group(4) 67 | tag = package + "-" + version 68 | ctx.github.create_release( 69 | repository=ctx.upstream_repo, 70 | tag_name=tag, 71 | target_commitish=commitish, 72 | name=f"{package} version {version}", 73 | # TODO: either reformat the message as we do in TagReleases, 74 | # or make sure we create the PR with an "already-formatted" 75 | # body. (The latter is probably simpler, and will make the 76 | # PR easier to read anyway.) 77 | body=ctx.release_pr["body"], 78 | # Versions like "1.0.0-beta01" or "0.9.0" are prerelease 79 | prerelease="-" in version or version.startswith("0."), 80 | ) 81 | click.secho(f"Created release for {tag}") 82 | pr_comment = pr_comment + f"- Created release for {tag}\n" 83 | 84 | if pr_comment == "": 85 | raise ValueError("No releases found within pull request") 86 | 87 | ctx.github.create_pull_request_comment( 88 | ctx.upstream_repo, ctx.release_pr["number"], pr_comment 89 | ) 90 | 91 | # This isn't a tag, but that's okay - it just needs to be a commitish for 92 | # Kokoro to build against. 93 | ctx.release_tag = commitish 94 | 95 | ctx.kokoro_job_name = kokoro_job_name(ctx.upstream_repo, "") 96 | ctx.github.update_pull_labels( 97 | ctx.release_pr, add=["autorelease: tagged"], remove=["autorelease: pending"] 98 | ) 99 | releasetool.commands.common.publish_via_kokoro(ctx) 100 | 101 | 102 | def kokoro_job_name(upstream_repo: str, package_name: str) -> Union[str, None]: 103 | """Return the Kokoro job name. 104 | 105 | Args: 106 | upstream_repo (str): The GitHub repo in the form of `/` 107 | package_name (str): The name of package to release 108 | 109 | Returns: 110 | The name of the Kokoro job to trigger or None if there is no job to trigger 111 | """ 112 | repo_short_name = upstream_repo.split("/")[-1] 113 | if repo_short_name in ( 114 | "dotnet-spanner-entity-framework", 115 | "google-cloudevents-dotnet", 116 | ): 117 | return ( 118 | f"cloud-libraries-dotnet/{repo_short_name}/rbe_windows_releases/autorelease" 119 | ) 120 | else: 121 | return f"cloud-sharp/{repo_short_name}/gcp_windows/autorelease" 122 | 123 | 124 | def package_name(pull: dict) -> Union[str, None]: 125 | return None 126 | 127 | 128 | # Note: unlike other languages, the .NET libraries may need multiple 129 | # tags for a single release PR, usually for dependent APIs, e.g. 130 | # Google.Cloud.Spanner.Data depending on Google.Cloud.Spanner.V1. 131 | # We create multiple releases in the create_releases function, and set 132 | # ctx.release_tag to the commit we've tagged (as all tags will use the same commit). 133 | def tag(ctx: TagContext = None) -> TagContext: 134 | if not ctx: 135 | ctx = TagContext() 136 | 137 | if ctx.interactive: 138 | click.secho(f"o/ Hey, {getpass.getuser()}, let's tag a release!", fg="magenta") 139 | 140 | if ctx.github is None: 141 | releasetool.commands.common.setup_github_context(ctx) 142 | 143 | if ctx.release_pr is None: 144 | determine_release_pr(ctx) 145 | 146 | create_releases(ctx) 147 | 148 | return ctx 149 | -------------------------------------------------------------------------------- /releasetool/commands/tag/go.py: -------------------------------------------------------------------------------- 1 | # Copyright 2023 Google LLC 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # https://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | from typing import Union 16 | 17 | 18 | def kokoro_job_name(upstream_repo: str, package_name: str) -> Union[str, None]: 19 | """Return the Kokoro job name. 20 | 21 | Args: 22 | upstream_repo (str): The GitHub repo in the form of `/` 23 | package_name (str): The name of package to release 24 | 25 | Returns: 26 | The name of the Kokoro job to trigger or None if there is no job to trigger 27 | """ 28 | return ( 29 | f"cloud-devrel/client-libraries/go/{upstream_repo.rsplit('/', 1)[-1]}/release" 30 | ) 31 | 32 | 33 | def package_name(pull: dict) -> Union[str, None]: 34 | return None 35 | -------------------------------------------------------------------------------- /releasetool/commands/tag/java.py: -------------------------------------------------------------------------------- 1 | # Copyright 2018 Google LLC 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # https://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | import getpass 16 | import re 17 | import subprocess 18 | import tempfile 19 | import click 20 | from typing import List, Union 21 | 22 | import releasetool.circleci 23 | import releasetool.git 24 | import releasetool.github 25 | import releasetool.secrets 26 | import releasetool.commands.common 27 | from releasetool.commands.common import TagContext 28 | 29 | 30 | def _parse_release_tag(output: str) -> str: 31 | match = re.search("creating release (v.*)", output) 32 | if match: 33 | return match[1] 34 | return None 35 | 36 | 37 | """A list of repositories that have a different kokoro job location. 38 | """ 39 | # Standard Java Framework repos in the GoogleCloudPlatform org 40 | java_framework_release_pool_repos: List[str] = [ 41 | "google-cloud-spanner-hibernate", 42 | "spring-cloud-gcp", 43 | "cloud-spanner-r2dbc", 44 | ] 45 | # App engine plugin repos in the GoogleCloudPlatform org 46 | java_appengine_plugins_repos: List[str] = ["appengine-plugins"] 47 | functions_framework_java_packages: List[str] = [ 48 | "functions-framework-api", 49 | "java-function-invoker", 50 | "function-maven-plugin", 51 | ] 52 | 53 | 54 | def kokoro_job_name(upstream_repo: str, package_name: str) -> Union[str, None]: 55 | """Return the Kokoro job name. 56 | 57 | Args: 58 | upstream_repo (str): The GitHub repo in the form of `/` 59 | package_name (str): The name of package to release 60 | 61 | Returns: 62 | The name of the Kokoro job to trigger or None if there is no job to trigger 63 | """ 64 | repo_short_name = upstream_repo.split("/")[-1] 65 | 66 | if repo_short_name in java_framework_release_pool_repos: 67 | return f"cloud-java-frameworks/{repo_short_name}/stage" 68 | 69 | if repo_short_name in java_appengine_plugins_repos: 70 | return "appengine-plugins-core/gcp_ubuntu/stage" 71 | 72 | if ( 73 | repo_short_name == "functions-framework-java" 74 | and package_name in functions_framework_java_packages 75 | ): 76 | return f"functions-framework/java/{package_name}/release" 77 | 78 | else: 79 | return f"cloud-devrel/client-libraries/java/{repo_short_name}/release/stage" 80 | 81 | 82 | def package_name(pull: dict) -> Union[str, None]: 83 | if pull.__contains__("title"): 84 | title = pull["title"] 85 | match = re.search(".* release (.*) [0-9].*", title) 86 | if match: 87 | return match[1] 88 | return None 89 | 90 | 91 | def tag(ctx: TagContext = None) -> TagContext: 92 | if not ctx: 93 | ctx = TagContext() 94 | 95 | if ctx.interactive: 96 | click.secho(f"o/ Hey, {getpass.getuser()}, let's tag a release!", fg="magenta") 97 | 98 | if ctx.github is None: 99 | releasetool.commands.common.setup_github_context(ctx) 100 | 101 | # delegate releaase tagging to release-please 102 | default_branch = ctx.release_pr["base"]["ref"] 103 | repo = ctx.release_pr["base"]["repo"]["full_name"] 104 | 105 | with tempfile.NamedTemporaryFile("w+t", delete=False) as fp: 106 | fp.write(ctx.token) 107 | token_file = fp.name 108 | 109 | output = subprocess.check_output( 110 | [ 111 | "npx", 112 | "release-please", 113 | "github-release", 114 | f"--token={token_file}", 115 | f"--default-branch={default_branch}", 116 | "--release-type=java-yoshi", 117 | "--bump-minor-pre-major=true", 118 | f"--repo-url={repo}", 119 | "--package-name=", 120 | "--debug", 121 | ] 122 | ) 123 | 124 | ctx.release_tag = _parse_release_tag(output.decode("utf-8")) 125 | ctx.kokoro_job_name = kokoro_job_name(ctx.upstream_repo, ctx.package_name) 126 | 127 | if ctx.interactive: 128 | click.secho("\\o/ All done!", fg="magenta") 129 | 130 | return ctx 131 | -------------------------------------------------------------------------------- /releasetool/commands/tag/nodejs.py: -------------------------------------------------------------------------------- 1 | # Copyright 2018 Google LLC 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # https://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | import getpass 16 | import re 17 | from typing import Union 18 | 19 | import click 20 | 21 | import releasetool.circleci 22 | import releasetool.git 23 | import releasetool.github 24 | import releasetool.secrets 25 | import releasetool.commands.common 26 | from releasetool.commands.common import TagContext 27 | import subprocess 28 | import tempfile 29 | 30 | # Repos that have their publication process handled by GitHub actions: 31 | manifest_release = [ 32 | "googleapis/google-api-nodejs-client", 33 | "googleapis/repo-automation-bots", 34 | ] 35 | 36 | 37 | def determine_release_pr(ctx: TagContext) -> None: 38 | click.secho( 39 | "> Let's figure out which pull request corresponds to your release.", fg="cyan" 40 | ) 41 | 42 | pulls = ctx.github.list_pull_requests(ctx.upstream_repo, state="closed") 43 | pulls = [pull for pull in pulls if "release" in pull["title"].lower()][:30] 44 | 45 | click.secho("> Please pick one of the following PRs:\n") 46 | for n, pull in enumerate(pulls, 1): 47 | print(f"\t{n}: {pull['title']} ({pull['number']})") 48 | 49 | pull_idx = click.prompt( 50 | "\nWhich one do you want to tag and release?", type=click.INT 51 | ) 52 | 53 | ctx.release_pr = pulls[pull_idx - 1] 54 | 55 | 56 | def determine_release_tag(ctx: TagContext) -> None: 57 | click.secho("> Determining what the release tag should be.", fg="cyan") 58 | head_ref = ctx.release_pr["head"]["ref"] 59 | match = re.match("release-(.+)", head_ref) 60 | 61 | if match is not None: 62 | ctx.release_tag = match.group(1) 63 | else: 64 | click.secho( 65 | "I couldn't determine what the release tag should be from the PR's" 66 | f"head ref {head_ref}.", 67 | fg="red", 68 | ) 69 | ctx.release_tag = click.prompt( 70 | "What should the release tag be (for example, storage-1.2.3)?" 71 | ) 72 | 73 | click.secho(f"Release tag is {ctx.release_tag}.") 74 | 75 | 76 | def determine_package_version(ctx: TagContext) -> None: 77 | click.secho("> Determining the package version.", fg="cyan") 78 | match = re.match(r"(?Pv?\d+\.\d+\.\d+)", ctx.release_tag) 79 | ctx.release_version = match.group("version") 80 | click.secho(f"package version: {ctx.release_version}.") 81 | 82 | 83 | def get_release_notes(ctx: TagContext) -> None: 84 | click.secho("> Grabbing the release notes.") 85 | changelog = ctx.github.get_contents( 86 | ctx.upstream_repo, "CHANGELOG.md", ref=ctx.release_pr["merge_commit_sha"] 87 | ).decode("utf-8") 88 | 89 | _get_latest_release_notes(ctx, changelog) 90 | 91 | 92 | def _get_latest_release_notes(ctx: TagContext, changelog: str): 93 | # the 'v' prefix is not used in the conventional-changelog templates 94 | # used in automated CHANGELOG generation: 95 | version = re.sub(r"^v", "", ctx.release_version) 96 | match = re.search( 97 | rf"## v?\[?{version}[^\n]*\n(?P.+?)(\n##\s|\n### \[?[0-9]+\.|\Z)", 98 | changelog, 99 | re.DOTALL | re.MULTILINE, 100 | ) 101 | if match is not None: 102 | ctx.release_notes = match.group("notes").strip() 103 | else: 104 | ctx.release_notes = "" 105 | 106 | 107 | def create_release(ctx: TagContext) -> None: 108 | click.secho("> Creating the release.") 109 | 110 | if ctx.upstream_repo in manifest_release: 111 | # delegate releaase tagging to release-please 112 | default_branch = ctx.release_pr["base"]["ref"] 113 | repo = ctx.release_pr["base"]["repo"]["full_name"] 114 | 115 | with tempfile.NamedTemporaryFile("w+t", delete=False) as fp: 116 | fp.write(ctx.token) 117 | token_file = fp.name 118 | 119 | subprocess.check_output( 120 | [ 121 | # TODO(sofisl): remove pinning to a specific version 122 | # once we've tested: 123 | "npx", 124 | "release-please", 125 | "manifest-release", 126 | f"--token={token_file}", 127 | f"--default-branch={default_branch}", 128 | f"--repo-url={repo}", 129 | "--debug", 130 | ] 131 | ) 132 | else: 133 | # TODO(sofisl): move the non-manifest release to release-please too 134 | # for consistency: 135 | ctx.github_release = ctx.github.create_release( 136 | repository=ctx.upstream_repo, 137 | tag_name=ctx.release_version, 138 | target_commitish=ctx.release_pr["merge_commit_sha"], 139 | name=f"{ctx.release_version}", 140 | body=ctx.release_notes, 141 | ) 142 | 143 | release_location_string = f"Release is at {ctx.github_release['html_url']}" 144 | click.secho(release_location_string) 145 | click.secho("CI will handle publishing the package to npm.") 146 | 147 | ctx.github.create_pull_request_comment( 148 | ctx.upstream_repo, ctx.release_pr["number"], release_location_string 149 | ) 150 | 151 | ctx.github.update_pull_labels( 152 | ctx.release_pr, add=["autorelease: tagged"], remove=["autorelease: pending"] 153 | ) 154 | 155 | 156 | def wait_on_circle(ctx: TagContext) -> None: 157 | circle = releasetool.circleci.CircleCI(repository=ctx.upstream_repo) 158 | click.secho("> Waiting for CircleCI to queue a release build") 159 | tag_name = ctx.release_version 160 | fresh_build = circle.get_latest_build_by_tag(tag_name) 161 | if fresh_build: 162 | click.secho(f"CircleCI Build: {fresh_build['build_url']}") 163 | click.secho("> Monitoring CircleCI for completion of release") 164 | click.secho("") 165 | for state in circle.get_build_status_generator(fresh_build["build_num"]): 166 | click.secho(f"CircleCI Build State: {state}\r", nl=False) 167 | else: 168 | click.secho(f"CircleCI Build not found for tag {tag_name}...") 169 | 170 | 171 | def kokoro_job_name(upstream_repo: str, package_name: str) -> Union[str, None]: 172 | """Return the Kokoro job name. 173 | 174 | Args: 175 | upstream_repo (str): The GitHub repo in the form of `/` 176 | package_name (str): The name of package to release 177 | 178 | Returns: 179 | The name of the Kokoro job to trigger or None if there is no job to trigger 180 | """ 181 | return f"cloud-devrel/client-libraries/nodejs/release/{upstream_repo}/publish" 182 | 183 | 184 | def package_name(pull: dict) -> Union[str, None]: 185 | return None 186 | 187 | 188 | def tag(ctx: TagContext = None) -> TagContext: 189 | if not ctx: 190 | ctx = TagContext() 191 | 192 | if ctx.interactive: 193 | click.secho(f"o/ Hey, {getpass.getuser()}, let's tag a release!", fg="magenta") 194 | 195 | if ctx.github is None: 196 | releasetool.commands.common.setup_github_context(ctx) 197 | 198 | if ctx.release_pr is None: 199 | determine_release_pr(ctx) 200 | 201 | # If using manifest release, the manifest releaser determines 202 | # release tag, version, and release notes: 203 | if ctx.upstream_repo not in manifest_release: 204 | determine_release_tag(ctx) 205 | determine_package_version(ctx) 206 | # If the release already exists, don't do anything 207 | if releasetool.commands.common.release_exists(ctx): 208 | click.secho(f"{ctx.release_tag} already exists.", fg="magenta") 209 | return ctx 210 | get_release_notes(ctx) 211 | else: 212 | # If using mono-release strategy, fallback to using sha from 213 | # time of merge: 214 | ctx.release_tag = ctx.release_pr["merge_commit_sha"] 215 | 216 | create_release(ctx) 217 | ctx.kokoro_job_name = kokoro_job_name(ctx.upstream_repo, ctx.package_name) 218 | releasetool.commands.common.publish_via_kokoro(ctx) 219 | 220 | if ctx.interactive: 221 | click.secho("\\o/ All done!", fg="magenta") 222 | 223 | return ctx 224 | -------------------------------------------------------------------------------- /releasetool/commands/tag/php.py: -------------------------------------------------------------------------------- 1 | import getpass 2 | from typing import Union 3 | 4 | import click 5 | 6 | import releasetool.commands.tag.nodejs 7 | from releasetool.commands.common import TagContext 8 | 9 | 10 | def kokoro_job_name(upstream_repo: str, package_name: str) -> Union[str, None]: 11 | """Return the Kokoro job name. 12 | 13 | Args: 14 | upstream_repo (str): The GitHub repo in the form of `/` 15 | package_name (str): The name of package to release 16 | 17 | Returns: 18 | The name of the Kokoro job to trigger or None if there is no job to trigger 19 | """ 20 | repo_short_name = upstream_repo.split("/")[-1] 21 | 22 | if repo_short_name == "google-cloud-php": 23 | return "cloud-devrel/client-libraries/php/google-cloud-php/docs/docs" 24 | else: 25 | return f"cloud-devrel/client-libraries/php/{upstream_repo}/release" 26 | 27 | 28 | def package_name(pull: dict) -> Union[str, None]: 29 | return None 30 | 31 | 32 | def tag(ctx: TagContext = None) -> TagContext: 33 | # PHP just needs a release to be tagged on GitHub. 34 | # Tagging logic is the same as NodeJs. 35 | if not ctx: 36 | ctx = TagContext() 37 | 38 | if ctx.interactive: 39 | click.secho(f"o/ Hey, {getpass.getuser()}, let's tag a release!", fg="magenta") 40 | 41 | if ctx.github is None: 42 | releasetool.commands.common.setup_github_context(ctx) 43 | 44 | if ctx.release_pr is None: 45 | releasetool.commands.tag.nodejs.determine_release_pr(ctx) 46 | 47 | releasetool.commands.tag.nodejs.determine_release_tag(ctx) 48 | releasetool.commands.tag.nodejs.determine_package_version(ctx) 49 | 50 | # If the release already exists, don't do anything 51 | if releasetool.commands.common.release_exists(ctx): 52 | click.secho(f"{ctx.release_tag} already exists.", fg="magenta") 53 | return ctx 54 | 55 | releasetool.commands.tag.nodejs.get_release_notes(ctx) 56 | releasetool.commands.tag.nodejs.create_release(ctx) 57 | 58 | if ctx.interactive: 59 | click.secho("\\o/ All done!", fg="magenta") 60 | 61 | return ctx 62 | -------------------------------------------------------------------------------- /releasetool/commands/tag/python.py: -------------------------------------------------------------------------------- 1 | # Copyright 2018 Google LLC 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # https://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | import getpass 16 | import re 17 | from typing import List, Union 18 | 19 | import click 20 | 21 | import releasetool.circleci 22 | import releasetool.git 23 | import releasetool.github 24 | import releasetool.secrets 25 | import releasetool.commands.common 26 | from releasetool.commands.common import TagContext 27 | 28 | 29 | def determine_release_pr(ctx: TagContext) -> None: 30 | click.secho( 31 | "> Let's figure out which pull request corresponds to your release.", fg="cyan" 32 | ) 33 | 34 | pulls = ctx.github.list_pull_requests(ctx.upstream_repo, state="closed") 35 | pulls = [pull for pull in pulls if "release" in pull["title"].lower()][:30] 36 | 37 | click.secho("> Please pick one of the following PRs:\n") 38 | for n, pull in enumerate(pulls, 1): 39 | print(f"\t{n}: {pull['title']} ({pull['number']})") 40 | 41 | pull_idx = click.prompt( 42 | "\nWhich one do you want to tag and release?", type=click.INT 43 | ) 44 | 45 | ctx.release_pr = pulls[pull_idx - 1] 46 | 47 | 48 | def determine_release_tag(ctx: TagContext) -> None: 49 | click.secho("> Determining what the release tag should be.", fg="cyan") 50 | head_ref = ctx.release_pr["head"]["ref"] 51 | 52 | # try release-please v13 pull requests 53 | title = ctx.release_pr["title"] 54 | match = re.match("chore\\(.*\\): release (\\d+\\.\\d+\\.\\d+.*)", title) 55 | if match is not None: 56 | ctx.release_tag = f"v{match.group(1)}" 57 | return 58 | 59 | match = re.match("release-(.+)", head_ref) 60 | 61 | if match is not None: 62 | ctx.release_tag = match.group(1) 63 | else: 64 | print( 65 | "I couldn't determine what the release tag should be from the PR's" 66 | f"head ref {head_ref}." 67 | ) 68 | ctx.release_tag = click.prompt( 69 | "What should the release tag be (for example, v1.2.3)?" 70 | ) 71 | 72 | click.secho(f"Release tag is {ctx.release_tag}.") 73 | 74 | 75 | def determine_package_version(ctx: TagContext) -> None: 76 | click.secho("> Determining the package version.", fg="cyan") 77 | # strip the leading 'v' from the tag 78 | ctx.release_version = re.sub(r"^v", "", ctx.release_tag) 79 | click.secho(f"Package version: {ctx.release_version}.") 80 | 81 | 82 | def get_release_notes(ctx: TagContext) -> None: 83 | click.secho("> Grabbing the release notes.") 84 | 85 | changelog_path = "CHANGELOG.md" 86 | changelog = ctx.github.get_contents( 87 | ctx.upstream_repo, changelog_path, ref=ctx.release_pr["merge_commit_sha"] 88 | ).decode("utf-8") 89 | 90 | _get_latest_release_notes(ctx, changelog) 91 | 92 | 93 | def _get_latest_release_notes(ctx: TagContext, changelog: str): 94 | match = re.search( 95 | rf"## v?\[?{ctx.release_version}[^\n]*\n(?P.+?)(\n##\s|\n### \[?[0-9]+\.|\Z)", 96 | changelog, 97 | re.DOTALL | re.MULTILINE, 98 | ) 99 | if match is not None: 100 | ctx.release_notes = match.group("notes").strip() 101 | else: 102 | ctx.release_notes = "" 103 | 104 | 105 | def create_release(ctx: TagContext) -> None: 106 | click.secho("> Creating the release.") 107 | 108 | release_name = f"v{ctx.release_version}" 109 | ctx.github_release = ctx.github.create_release( 110 | repository=ctx.upstream_repo, 111 | tag_name=ctx.release_tag, 112 | target_commitish=ctx.release_pr["merge_commit_sha"], 113 | name=release_name, 114 | body=ctx.release_notes, 115 | ) 116 | 117 | release_location_string = f"Release is at {ctx.github_release['html_url']}" 118 | click.secho(release_location_string) 119 | 120 | ctx.github.create_pull_request_comment( 121 | ctx.upstream_repo, ctx.release_pr["number"], release_location_string 122 | ) 123 | 124 | ctx.github.update_pull_labels( 125 | ctx.release_pr, add=["autorelease: tagged"], remove=["autorelease: pending"] 126 | ) 127 | 128 | 129 | """A list of repositories that have been migrated to use the new KOKORO VM 130 | pool dedicated to releasing client libraries. 131 | """ 132 | release_pool_repos: List[str] = ["googleapis/python-apigee-connect"] 133 | 134 | 135 | def kokoro_job_name(upstream_repo: str, package_name: str) -> Union[str, None]: 136 | """Return the Kokoro job name. 137 | 138 | Args: 139 | upstream_repo (str): The GitHub repo in the form of `/` 140 | package_name (str): The name of package to release 141 | 142 | Returns: 143 | The name of the Kokoro job to trigger or None if there is no job to trigger 144 | """ 145 | if upstream_repo in release_pool_repos: 146 | return f"cloud-devrel/client-libraries/release/python/{upstream_repo}/release" 147 | else: 148 | return f"cloud-devrel/client-libraries/python/{upstream_repo}/release/release" 149 | 150 | 151 | def package_name(pull: dict) -> Union[str, None]: 152 | return None 153 | 154 | 155 | def tag(ctx: TagContext = None) -> TagContext: 156 | if not ctx: 157 | ctx = TagContext() 158 | 159 | if ctx.interactive: 160 | click.secho(f"o/ Hey, {getpass.getuser()}, let's tag a release!", fg="magenta") 161 | 162 | if ctx.github is None: 163 | releasetool.commands.common.setup_github_context(ctx) 164 | 165 | if ctx.release_pr is None: 166 | determine_release_pr(ctx) 167 | 168 | determine_release_tag(ctx) 169 | determine_package_version(ctx) 170 | 171 | # If the release already exists, don't do anything 172 | if releasetool.commands.common.release_exists(ctx): 173 | click.secho(f"{ctx.release_tag} already exists.", fg="magenta") 174 | return ctx 175 | 176 | get_release_notes(ctx) 177 | 178 | create_release(ctx) 179 | 180 | ctx.kokoro_job_name = kokoro_job_name(ctx.upstream_repo, ctx.package_name) 181 | releasetool.commands.common.publish_via_kokoro(ctx) 182 | 183 | if ctx.interactive: 184 | click.secho("\\o/ All done!", fg="magenta") 185 | 186 | return ctx 187 | -------------------------------------------------------------------------------- /releasetool/commands/tag/python_tool.py: -------------------------------------------------------------------------------- 1 | # Copyright 2018 Google LLC 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # https://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | import getpass 16 | import re 17 | from typing import Union 18 | 19 | import click 20 | 21 | import releasetool.commands.common 22 | from releasetool.commands.common import TagContext 23 | from releasetool.commands.tag import python 24 | 25 | 26 | def get_release_notes(ctx: TagContext) -> None: 27 | click.secho("> Grabbing the release notes.") 28 | changelog = ctx.github.get_contents( 29 | ctx.upstream_repo, "CHANGELOG.md", ref=ctx.release_pr["merge_commit_sha"] 30 | ).decode("utf-8") 31 | 32 | match = re.search( 33 | rf"## {ctx.release_version}\n(?P.+?)(\n##\s|\Z)", 34 | changelog, 35 | re.DOTALL | re.MULTILINE, 36 | ) 37 | if match is not None: 38 | ctx.release_notes = match.group("notes").strip() 39 | else: 40 | ctx.release_notes = "" 41 | 42 | 43 | def create_release(ctx: TagContext) -> None: 44 | click.secho("> Creating the release.") 45 | 46 | ctx.github_release = ctx.github.create_release( 47 | repository=ctx.upstream_repo, 48 | tag_name=ctx.release_tag, 49 | target_commitish=ctx.release_pr["merge_commit_sha"], 50 | name=f"{ctx.package_name} {ctx.release_version}", 51 | body=ctx.release_notes, 52 | ) 53 | 54 | release_location_string = f"Release is at {ctx.github_release['html_url']}" 55 | click.secho(release_location_string) 56 | 57 | ctx.github.create_pull_request_comment( 58 | ctx.upstream_repo, ctx.release_pr["number"], release_location_string 59 | ) 60 | 61 | ctx.github.update_pull_labels( 62 | ctx.release_pr, add=["autorelease: tagged"], remove=["autorelease: pending"] 63 | ) 64 | 65 | 66 | def kokoro_job_name(upstream_repo: str, package_name: str) -> Union[str, None]: 67 | """Return the Kokoro job name. 68 | 69 | Args: 70 | upstream_repo (str): The GitHub repo in the form of `/` 71 | package_name (str): The name of package to release 72 | 73 | Returns: 74 | The name of the Kokoro job to trigger or None if there is no job to trigger 75 | """ 76 | return f"cloud-devrel/client-libraries/{package_name}/release" 77 | 78 | 79 | def package_name(pull: dict) -> Union[str, None]: 80 | return None 81 | 82 | 83 | def tag(ctx: TagContext = None) -> TagContext: 84 | if not ctx: 85 | ctx = TagContext() 86 | 87 | if ctx.interactive: 88 | click.secho(f"o/ Hey, {getpass.getuser()}, let's tag a release!", fg="magenta") 89 | 90 | if ctx.github is None: 91 | releasetool.commands.common.setup_github_context(ctx) 92 | 93 | if ctx.release_pr is None: 94 | python.determine_release_pr(ctx) 95 | 96 | python.determine_release_tag(ctx) 97 | python.determine_package_version(ctx) 98 | 99 | # If the release already exists, don't do anything 100 | if releasetool.commands.common.release_exists(ctx): 101 | click.secho(f"{ctx.release_tag} already exists.", fg="magenta") 102 | return ctx 103 | 104 | get_release_notes(ctx) 105 | 106 | create_release(ctx) 107 | 108 | ctx.kokoro_job_name = kokoro_job_name(ctx.upstream_repo, ctx.package_name) 109 | releasetool.commands.common.publish_via_kokoro(ctx) 110 | 111 | if ctx.interactive: 112 | click.secho("\\o/ All done!", fg="magenta") 113 | 114 | return ctx 115 | -------------------------------------------------------------------------------- /releasetool/commands/tag/ruby.py: -------------------------------------------------------------------------------- 1 | # Copyright 2018 Google LLC 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # https://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | import getpass 16 | import re 17 | from typing import Union 18 | 19 | import click 20 | from requests import HTTPError 21 | 22 | import releasetool.circleci 23 | import releasetool.git 24 | import releasetool.github 25 | import releasetool.secrets 26 | import releasetool.commands.common 27 | from releasetool.commands.common import TagContext 28 | 29 | 30 | # Standard Ruby repos in the googleapis org 31 | RUBY_CLIENT_REPOS = [ 32 | "common-protos-ruby", 33 | "gapic-generator-ruby", 34 | "google-api-ruby-client", 35 | "google-auth-library-ruby", 36 | "google-cloud-ruby", 37 | "google-cloudevents-ruby", 38 | "opentelemetry-operations-ruby", 39 | "ruby-cloud-env", 40 | "ruby-core-libraries", 41 | "ruby-spanner", 42 | "ruby-spanner-activerecord", 43 | "ruby-style", 44 | "signet", 45 | ] 46 | 47 | # Standard Ruby repos in the GoogleCloudPlatform org 48 | RUBY_CLOUD_REPOS = [ 49 | "appengine-ruby", 50 | "functions-framework-ruby", 51 | "serverless-exec-ruby", 52 | ] 53 | 54 | # Standard Ruby monorepos with gems located in subdirectories 55 | RUBY_MONO_REPOS = [ 56 | "common-protos-ruby", 57 | "opentelemetry-operations-ruby", 58 | "gapic-generator-ruby", 59 | "google-api-ruby-client", 60 | "google-cloud-ruby", 61 | "ruby-core-libraries", 62 | "ruby-spanner", 63 | ] 64 | 65 | 66 | def determine_release_pr(ctx: TagContext) -> None: 67 | click.secho( 68 | "> Let's figure out which pull request corresponds to your release.", fg="cyan" 69 | ) 70 | 71 | pulls = ctx.github.list_pull_requests(ctx.upstream_repo, state="closed") 72 | pulls = [pull for pull in pulls if "release" in pull["title"].lower()][:30] 73 | 74 | click.secho("> Please pick one of the following PRs:\n") 75 | for n, pull in enumerate(pulls, 1): 76 | print(f"\t{n}: {pull['title']} ({pull['number']})") 77 | 78 | pull_idx = click.prompt( 79 | "\nWhich one do you want to tag and release?", type=click.INT 80 | ) 81 | 82 | ctx.release_pr = pulls[pull_idx - 1] 83 | 84 | 85 | def determine_release_tag(ctx: TagContext) -> None: 86 | click.secho("> Determining the release tag.", fg="cyan") 87 | head_ref = ctx.release_pr["head"]["ref"] 88 | click.secho(f"PR head ref is {head_ref}") 89 | match = re.match(r"release-(.+)-v(\d+\.\d+\.\d+)", head_ref) 90 | rp13_match = re.match(r"release-please--branches--(.+)--components--(.+)", head_ref) 91 | title_match = re.match( 92 | r"chore\(.+\): release (.+) (\d+\.\d+\.\d+)", ctx.release_pr["title"] 93 | ) 94 | 95 | if match is not None: 96 | ctx.package_name = match.group(1) 97 | ctx.release_version = match.group(2) 98 | ctx.release_tag = f"{ctx.package_name}/v{ctx.release_version}" 99 | elif rp13_match is not None and title_match is not None: 100 | ctx.package_name = title_match.group(1) 101 | ctx.release_version = title_match.group(2) 102 | ctx.release_tag = f"{ctx.package_name}/v{ctx.release_version}" 103 | 104 | if ctx.release_tag is None: 105 | click.secho( 106 | "I couldn't determine what the release tag should be from the PR's " 107 | f"head ref {head_ref}.", 108 | fg="red", 109 | ) 110 | ctx.release_tag = click.prompt( 111 | "What should the release tag be (for example, google-cloud-storage/v1.2.3)?" 112 | ) 113 | 114 | click.secho(f"Package name is {ctx.package_name}") 115 | click.secho(f"Package version is {ctx.release_version}") 116 | click.secho(f"Release tag is {ctx.release_tag}") 117 | 118 | 119 | def determine_package_name_and_version(ctx: TagContext) -> None: 120 | click.secho( 121 | "> Determining the package name and version from your release tag.", fg="cyan" 122 | ) 123 | match = re.match(r"^([a-z0-9-_]+)\/v(\d+.\d+.\d+)$", ctx.release_tag) 124 | ctx.package_name = match.group(1) 125 | ctx.release_version = match.group(2) 126 | 127 | 128 | def get_release_notes(ctx: TagContext) -> None: 129 | click.secho("> Grabbing the release notes.", fg="cyan") 130 | changelog_file = "CHANGELOG.md" 131 | repo_name = ctx.upstream_repo.split("/")[-1] 132 | for name in RUBY_MONO_REPOS: 133 | if name == repo_name: 134 | changelog_file = f"{ctx.package_name}/CHANGELOG.md" 135 | changelog = ctx.github.get_contents( 136 | ctx.upstream_repo, changelog_file, ref=ctx.release_pr["merge_commit_sha"] 137 | ).decode("utf-8") 138 | 139 | match = re.search( 140 | rf"^### {ctx.release_version} \/ \d\d\d\d-\d\d-\d\d\n(?P.+?)(\n###\s|\Z)", 141 | changelog, 142 | re.DOTALL | re.MULTILINE, 143 | ) 144 | v13_match = re.search( 145 | rf"^### {ctx.release_version} \(\d\d\d\d-\d\d-\d\d\)\n(?P.+?)(\n###\s|\Z)", 146 | changelog, 147 | re.DOTALL | re.MULTILINE, 148 | ) 149 | if match is not None: 150 | ctx.release_notes = match.group("notes").strip() 151 | elif v13_match is not None: 152 | ctx.release_notes = v13_match.group("notes").strip() 153 | else: 154 | ctx.release_notes = "" 155 | 156 | click.secho(f"Here's the release notes:\n\n{ctx.release_notes}\n") 157 | 158 | 159 | def create_release(ctx: TagContext) -> None: 160 | click.secho("> Creating the release.") 161 | click.secho(f"pkg={ctx.package_name} pr={ctx.release_pr['number']}") 162 | 163 | ctx.github_release = ctx.github.create_release( 164 | repository=ctx.upstream_repo, 165 | tag_name=ctx.release_tag, 166 | target_commitish=ctx.release_pr["merge_commit_sha"], 167 | name=f"Release {ctx.package_name} {ctx.release_version}", 168 | body=ctx.release_notes, 169 | ) 170 | 171 | release_location_string = f"Release is at {ctx.github_release['html_url']}" 172 | click.secho(release_location_string) 173 | click.secho("CI will handle publishing the package to Rubygems.") 174 | 175 | ctx.github.create_pull_request_comment( 176 | ctx.upstream_repo, ctx.release_pr["number"], release_location_string 177 | ) 178 | 179 | ctx.github.update_pull_labels( 180 | ctx.release_pr, add=["autorelease: tagged"], remove=["autorelease: pending"] 181 | ) 182 | 183 | 184 | def kokoro_job_name(upstream_repo: str, package_name: str) -> Union[str, None]: 185 | """Return the Kokoro job name. 186 | 187 | Args: 188 | upstream_repo (str): The GitHub repo in the form of `/` 189 | package_name (str): The name of package to release 190 | 191 | Returns: 192 | The name of the Kokoro job to trigger or None if there is no job to trigger 193 | """ 194 | 195 | repo_name = upstream_repo.split("/")[-1] 196 | for name in RUBY_CLIENT_REPOS: 197 | if name == repo_name: 198 | return f"cloud-devrel/client-libraries/{name}/release" 199 | for name in RUBY_CLOUD_REPOS: 200 | if name == repo_name: 201 | return f"cloud-devrel/ruby/{name}/release" 202 | 203 | return f"cloud-devrel/client-libraries/{package_name}/release" 204 | 205 | 206 | def package_name(pull: dict) -> Union[str, None]: 207 | head_ref = pull["head"]["ref"] 208 | click.secho(f"PR head ref is {head_ref}") 209 | match = re.match(r"release-(.+)-v(\d+\.\d+\.\d+)", head_ref) 210 | if match is None: 211 | return None 212 | 213 | return match.group(1) 214 | 215 | 216 | def tag(ctx: TagContext = None) -> TagContext: 217 | if not ctx: 218 | ctx = TagContext() 219 | 220 | if ctx.interactive: 221 | click.secho( 222 | f"o/ Hey, {getpass.getuser()}, let's tag a Ruby release!", fg="magenta" 223 | ) 224 | 225 | if ctx.github is None: 226 | releasetool.commands.common.setup_github_context(ctx) 227 | 228 | if ctx.release_pr is None: 229 | determine_release_pr(ctx) 230 | 231 | determine_release_tag(ctx) 232 | determine_package_name_and_version(ctx) 233 | 234 | # If the release already exists, don't do anything 235 | if releasetool.commands.common.release_exists(ctx): 236 | click.secho(f"{ctx.release_tag} already exists.", fg="magenta") 237 | return ctx 238 | 239 | get_release_notes(ctx) 240 | 241 | create_release(ctx) 242 | ctx.kokoro_job_name = kokoro_job_name(ctx.upstream_repo, ctx.package_name) 243 | 244 | releasetool.commands.common.publish_via_kokoro(ctx) 245 | 246 | if ctx.interactive: 247 | click.secho("\\o/ All done!", fg="magenta") 248 | 249 | branch = ctx.release_pr["head"]["ref"] 250 | 251 | try: 252 | ctx.github.delete_branch(repository=ctx.upstream_repo, branch=branch) 253 | click.secho(f"Deleted branch {branch}") 254 | # If user has already deleted the branch, this will fail. 255 | except HTTPError as exc: 256 | if exc.response.status_code != 422: 257 | click.secho(f"{exc!r}") 258 | 259 | return ctx 260 | -------------------------------------------------------------------------------- /releasetool/filehelpers.py: -------------------------------------------------------------------------------- 1 | # Copyright 2018 Google LLC 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # https://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | import os 16 | import re 17 | import tempfile 18 | from typing import Optional 19 | 20 | import click 21 | 22 | 23 | def open_editor(filename: str, return_contents: bool = False) -> Optional[str]: 24 | click.edit(filename=filename) 25 | 26 | if return_contents: 27 | with open(filename, "r") as fh: 28 | return fh.read() 29 | else: 30 | return None 31 | 32 | 33 | def open_editor_with_content( 34 | filename: str, contents: str, return_contents: bool = False 35 | ) -> Optional[str]: 36 | with open(filename, "w") as fh: 37 | fh.write(contents) 38 | 39 | return open_editor(filename, return_contents=return_contents) 40 | 41 | 42 | def open_editor_with_tempfile(contents: str, suffix: str = ".txt") -> Optional[str]: 43 | handle, filename = tempfile.mkstemp(suffix) 44 | os.close(handle) 45 | 46 | content = open_editor_with_content(filename, contents, return_contents=True) 47 | 48 | os.remove(filename) 49 | 50 | return content 51 | 52 | 53 | def insert_before( 54 | filename: str, new_content: str, expr: str, separator: str = "\n" 55 | ) -> None: 56 | with open(filename, "r+") as fh: 57 | if not new_content.endswith(separator): 58 | new_content += separator 59 | 60 | content = fh.read() 61 | match = re.search(expr, content, re.MULTILINE) 62 | 63 | if not match: 64 | return 65 | 66 | position = match.start() 67 | 68 | output = content[:position] + new_content + content[position:] 69 | 70 | fh.seek(0) 71 | fh.write(output) 72 | 73 | 74 | def replace(filename: str, expr: str, replacement: str) -> None: 75 | with open(filename, "r+") as fh: 76 | content = fh.read() 77 | 78 | content = re.sub(expr, replacement, content) 79 | 80 | fh.seek(0) 81 | fh.write(content) 82 | fh.truncate() 83 | 84 | 85 | def extract(filename: str, expr: str) -> str: 86 | with open(filename, "r") as fh: 87 | content = fh.read() 88 | 89 | matches = re.search(expr, content) 90 | return matches.group(1) 91 | -------------------------------------------------------------------------------- /releasetool/git.py: -------------------------------------------------------------------------------- 1 | # Copyright 2018 Google LLC 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # https://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | import re 16 | import subprocess 17 | from typing import Dict, Sequence 18 | 19 | 20 | def list_tags() -> Sequence[str]: 21 | subprocess.check_output(["git", "fetch", "--tags"]) 22 | output = subprocess.check_output( 23 | ["git", "tag", "--list", "--sort=-creatordate"] 24 | ).decode("utf-8") 25 | tags = output.split("\n") 26 | 27 | return tags 28 | 29 | 30 | def get_latest_commit(branch: str) -> str: 31 | commit = subprocess.check_output( 32 | ["git", "log", "-1", branch, "--pretty=%H"] 33 | ).decode("utf-8") 34 | return commit 35 | 36 | 37 | def summary_log( 38 | from_: str, to: str = "master", where: str = ".", format: str = "%s" 39 | ) -> Sequence[str]: 40 | output = subprocess.check_output( 41 | ["git", "log", f"--format={format}", f"{from_}..{to}", where] 42 | ).decode("utf-8") 43 | commits = output.strip().split("\n") 44 | return commits 45 | 46 | 47 | def log(from_: str, to: str = "master", where: str = ".") -> Sequence[str]: 48 | return subprocess.check_output(["git", "log", f"{from_}..{to}", where]).decode( 49 | "utf-8" 50 | ) 51 | 52 | 53 | def diff(from_: str, to: str = "master", where: str = ".") -> Sequence[str]: 54 | return subprocess.check_output( 55 | ["git", "diff", f"{from_}..{to}", "--", where] 56 | ).decode("utf-8") 57 | 58 | 59 | def checkout_create_branch(branch_name: str, base: str = "master") -> None: 60 | subprocess.check_output(["git", "checkout", "-b", branch_name, base]) 61 | 62 | 63 | def checkout_branch(branch_name: str) -> None: 64 | subprocess.check_output(["git", "checkout", branch_name]) 65 | 66 | 67 | def commit(files: Sequence[str], message: str) -> None: 68 | """Create a release commit.""" 69 | subprocess.check_output(["git", "add"] + list(files)) 70 | subprocess.check_output(["git", "commit", "-m", message]) 71 | 72 | 73 | def push(branch: str, remote: str = "origin") -> None: 74 | """Push the release branch to the remote.""" 75 | subprocess.check_output(["git", "push", "-u", remote, branch]) 76 | 77 | 78 | def get_config() -> Dict[str, str]: 79 | output = subprocess.check_output(["git", "config", "--list"]).decode("utf-8") 80 | 81 | lines = [line for line in output.split("\n") if line] 82 | pairs = [line.split("=", 1) for line in lines] 83 | config = {key: value for key, value in pairs} 84 | 85 | return config 86 | 87 | 88 | def get_remotes() -> Dict[str, str]: 89 | config = get_config() 90 | 91 | remote_names = [] 92 | 93 | for key in config.keys(): 94 | match = re.match(r"remote\.(?P.+?)\.url", key) 95 | if match: 96 | remote_names.append(match.group("name")) 97 | 98 | remotes = {name: config[f"remote.{name}.url"] for name in remote_names} 99 | return remotes 100 | 101 | 102 | def get_github_remotes() -> Dict[str, str]: 103 | """Returns a dictionary mapping remote names to the appropriate github 104 | owner/repo string.""" 105 | remotes = get_remotes() 106 | 107 | github_repos = {} 108 | 109 | for name, url in remotes.items(): 110 | # Match SSH or HTTP URLs, like: 111 | # git@github.com:GoogleCloudPlatform/google-cloud-python.git 112 | # https://github.com/GoogleCloudPlatform/google-cloud-python.git 113 | match = re.match(r"^git@github.com:(?P.+?)(\.git$|$)", url) 114 | if match: 115 | github_repos[name] = match.group("name") 116 | continue 117 | 118 | match = re.match(r"^https://(.+?)?github.com/(?P.+?)(\.git$|$)", url) 119 | if match: 120 | github_repos[name] = match.group("name") 121 | continue 122 | 123 | return github_repos 124 | 125 | 126 | def current_branch() -> str: 127 | """Returns the name of the current working branch.""" 128 | return ( 129 | subprocess.check_output(["git", "rev-parse", "--abbrev-ref", "HEAD"]) 130 | .strip() 131 | .decode("utf-8") 132 | ) 133 | -------------------------------------------------------------------------------- /releasetool/secrets.py: -------------------------------------------------------------------------------- 1 | # Copyright 2018 Google LLC 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # https://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | import click 16 | import keyring 17 | 18 | _SERVICE = "com.google.cloud.devrel.releasetool" 19 | 20 | 21 | def get_password(name): 22 | return keyring.get_password(_SERVICE, name) 23 | 24 | 25 | def set_password(name, password): 26 | """Ensure we have a github username and token.""" 27 | keyring.set_password(_SERVICE, "github", password) 28 | 29 | 30 | def delete_password(): 31 | keyring.delete_password(_SERVICE, "github") 32 | 33 | 34 | def ensure_password(name, prompt): 35 | password = get_password(name) 36 | 37 | if not password: 38 | password = click.prompt(prompt) 39 | set_password(name, password) 40 | 41 | return password 42 | -------------------------------------------------------------------------------- /releasetool/update_check.py: -------------------------------------------------------------------------------- 1 | # Copyright 2018 Google LLC 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # https://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | import time 16 | import pathlib 17 | 18 | import packaging.version 19 | import requests 20 | 21 | import importlib.metadata as metadata 22 | 23 | 24 | def _get_pypi_version(package_name: str) -> str: 25 | r = requests.get(f"https://pypi.org/pypi/{package_name}/json") 26 | r.raise_for_status() 27 | 28 | return r.json()["info"]["version"] 29 | 30 | 31 | def _only_once_pls(package_name: str) -> bool: 32 | flag = pathlib.Path.home() / ".cache" / f"update-check-{package_name}" 33 | 34 | if not flag.exists(): 35 | flag.parent.mkdir(parents=True, exist_ok=True) 36 | flag.touch() 37 | return True 38 | 39 | last_check = flag.stat().st_mtime 40 | one_day_in_seconds = 60 * 60 * 24 41 | 42 | if last_check < time.time() - one_day_in_seconds: 43 | flag.touch() 44 | return True 45 | else: 46 | return False 47 | 48 | 49 | def check_for_updates(package_name: str, print=print) -> None: 50 | if not _only_once_pls(package_name): 51 | return 52 | 53 | current_version = packaging.version.Version( 54 | metadata.distribution(package_name).version 55 | ) 56 | 57 | pypi_version = packaging.version.Version(_get_pypi_version(package_name)) 58 | 59 | if current_version >= pypi_version: 60 | return 61 | 62 | print( 63 | f"{package_name} has a newer version available. Current version is " 64 | f"{current_version}, newest is {pypi_version}. Run `python3 -m pip " 65 | f"install --upgrade {package_name}` to update." 66 | ) 67 | -------------------------------------------------------------------------------- /requirements-dev.in: -------------------------------------------------------------------------------- 1 | nox==2023.4.22 2 | pytest==7.4.4 3 | requests_mock==1.11.0 4 | -------------------------------------------------------------------------------- /requirements.in: -------------------------------------------------------------------------------- 1 | attrs>=20.1.0 2 | bleach==4.1.0 3 | cachetools==4.2.4 4 | docutils==0.18.1 5 | click>=8.0.4, <8.1.0 6 | cryptography>=42 7 | google-auth>=2.22.0 8 | jaraco.classes==3.2.1 9 | jeepney==0.7.1 10 | jinja2==3.1.4 11 | keyring==21.8.0 12 | markupsafe==2.0.1 13 | protobuf>=4.21.6 14 | packaging>=20.0 15 | pyjwt>=2.0.0 16 | pyperclip>=1.8.0 17 | python-dateutil>=2.8.1 18 | readme-renderer==34.0 19 | requests>=2.31.0 20 | rfc3986==1.5.0 21 | twine==3.8.0 22 | wheel 23 | zipp==3.19.1 24 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | # Copyright 2018 Google LLC 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # https://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | import setuptools 16 | 17 | name = 'gcp-releasetool' 18 | description = '' 19 | version = "2.6.0" 20 | release_status = 'Development Status :: 3 - Alpha' 21 | dependencies = [ 22 | "requests>=2.31.0", 23 | "attrs>=20.1.0", 24 | "click>=8.0.4, <8.1.0", 25 | "cryptography>=42", 26 | "google-auth>=2.22.0", 27 | "jinja2>=3.1.3", 28 | "keyring>=21.8.0", 29 | "packaging>=20.0", 30 | "protobuf>=4.21.6", 31 | "pyjwt>=2.0.0", 32 | "pyperclip>=1.8.0", 33 | "python-dateutil>=2.8.1", 34 | ] 35 | 36 | packages = setuptools.find_packages() 37 | scripts = [ 38 | 'releasetool=releasetool.__main__:main' 39 | ] 40 | 41 | 42 | setuptools.setup( 43 | name=name, 44 | version=version, 45 | description=description, 46 | author='Google LLC', 47 | author_email='theaflowers@google.com', 48 | license='Apache 2.0', 49 | url='', 50 | classifiers=[ 51 | release_status, 52 | 'Intended Audience :: Developers', 53 | 'License :: OSI Approved :: Apache Software License', 54 | 'Programming Language :: Python', 55 | 'Programming Language :: Python :: 3.8', 56 | 'Programming Language :: Python :: 3.9', 57 | 'Programming Language :: Python :: 3.10', 58 | 'Programming Language :: Python :: 3.11', 59 | 'Programming Language :: Python :: 3.12', 60 | 'Operating System :: OS Independent', 61 | 'Topic :: Internet', 62 | ], 63 | platforms='Posix; MacOS X; Windows', 64 | packages=packages, 65 | python_requires='>=3.8', 66 | install_requires=dependencies, 67 | include_package_data=True, 68 | zip_safe=False, 69 | entry_points={ 70 | 'console_scripts': scripts, 71 | }, 72 | package_data={ 73 | 'autorelease': ['*.j2'] 74 | }, 75 | ) 76 | -------------------------------------------------------------------------------- /testing/constraints-3.10.txt: -------------------------------------------------------------------------------- 1 | # Copyright 2024 Google LLC 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | -------------------------------------------------------------------------------- /testing/constraints-3.11.txt: -------------------------------------------------------------------------------- 1 | # Copyright 2024 Google LLC 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | -------------------------------------------------------------------------------- /testing/constraints-3.12.txt: -------------------------------------------------------------------------------- 1 | # Copyright 2024 Google LLC 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | -------------------------------------------------------------------------------- /testing/constraints-3.8.txt: -------------------------------------------------------------------------------- 1 | # Copyright 2024 Google LLC 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | # This constraints file is used to check that lower bounds 15 | # are correct in setup.py 16 | # List all library dependencies and extras in this file. 17 | # Pin the version to the lower bound. 18 | # e.g., if setup.py has "google-cloud-foo >= 1.14.0, < 2.0.0dev", 19 | # Then this file should have google-cloud-foo==1.14.0 20 | attrs==20.1.0 21 | google-auth==2.22.0 22 | jinja2==3.1.4 23 | keyring==21.8.0 24 | packaging==20.0 25 | pyjwt==2.0.0 26 | pyperclip==1.8.0 27 | python-dateutil==2.8.1 28 | requests==2.32.2 29 | click==8.0.4 30 | cryptography==43.0.1 31 | protobuf==4.21.6 32 | importlib-metadata==4.8.3 33 | -------------------------------------------------------------------------------- /testing/constraints-3.9.txt: -------------------------------------------------------------------------------- 1 | # Copyright 2024 Google LLC 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/googleapis/releasetool/4a6e25c4a0cb6b6db1815f578012edb8cb884595/tests/__init__.py -------------------------------------------------------------------------------- /tests/commands/start/test_python.py: -------------------------------------------------------------------------------- 1 | # Copyright 2019 Google LLC 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # https://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | from unittest import mock 16 | 17 | import pytest 18 | 19 | 20 | @pytest.fixture 21 | def mut(): 22 | from releasetool.commands.start import python 23 | 24 | return python 25 | 26 | 27 | @pytest.mark.parametrize( 28 | "setup_py_contents,release_version,expected", 29 | [ 30 | ("version = '1.0.0'\n", "1.1.0", "version = '1.1.0'\n"), 31 | ('version = "1.0.0"\n', "1.1.0", 'version = "1.1.0"\n'), 32 | ], 33 | ) 34 | def test_update_setup_py_sets_version( 35 | mut, setup_py_contents, release_version, expected 36 | ): 37 | context = mut.Context() 38 | context.release_version = release_version 39 | 40 | with mock.patch( 41 | "builtins.open", mock.mock_open(read_data=setup_py_contents) 42 | ) as mock_open: 43 | mut.update_setup_py(context) 44 | mock_file = mock_open() 45 | mock_file.write.assert_called_once_with(expected) 46 | 47 | 48 | @pytest.mark.parametrize( 49 | "tags,package_name,expected", 50 | [ 51 | ( 52 | ["bonustag", "bigquery-1.3.0", "bigquery-1.2.0", "bigquery-1.0.0"], 53 | "bigquery", 54 | "bigquery-1.3.0", 55 | ), 56 | ( 57 | [ 58 | "bonustag", 59 | "bigquery-1.3.0", 60 | "bigquery-1.2.0", 61 | "bigquery_storage-0.2.0", 62 | "bigquery-1.0.0", 63 | "bigquery_datatransfer-0.3.0", 64 | "bigquery_datatransfer-0.2.0", 65 | "bigquery_storage-0.1.1", 66 | "bigquery_datatransfer-0.1.1", 67 | "bigquery_storage-0.1.0", 68 | ], 69 | "bigquery_storage", 70 | "bigquery_storage-0.2.0", 71 | ), 72 | ( 73 | [ 74 | "bonustag", 75 | "bigquery_datatransfer-0.3.0", 76 | "bigquery_storage-0.2.0", 77 | "bigquery-1.3.0", 78 | "bigquery-1.2.0", 79 | "bigquery-1.0.0", 80 | "bigquery_datatransfer-0.2.0", 81 | "bigquery_datatransfer-0.1.1", 82 | "bigquery_storage-0.1.1", 83 | "bigquery_storage-0.1.0", 84 | ], 85 | "bigquery", 86 | "bigquery-1.3.0", 87 | ), 88 | (["mypackage-1.0.0", "bonustag", "mypackage-0.9.0"], "myotherpackage", None), 89 | ], 90 | ) 91 | def find_last_release_tag(mut, tags, package_name, expected): 92 | candidate = mut.find_last_release_tag(tags, package_name) 93 | assert candidate == expected 94 | -------------------------------------------------------------------------------- /tests/commands/start/test_ruby.py: -------------------------------------------------------------------------------- 1 | # Copyright 2019 Google LLC 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # https://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | import pytest 16 | 17 | 18 | @pytest.fixture 19 | def mut(): 20 | from releasetool.commands.start import ruby 21 | 22 | return ruby 23 | 24 | 25 | def test_determine_last_release(mut): 26 | context = mut.Context() 27 | context.tags = ["google-cloud-spanner/v1.1.1", "google-cloud/v2.2.2"] 28 | context.package_name = "google-cloud" 29 | 30 | mut.determine_last_release(context) 31 | assert context.last_release_committish == "google-cloud/v2.2.2" 32 | assert context.last_release_version == "2.2.2" 33 | -------------------------------------------------------------------------------- /tests/commands/tag/test_tag_cpp.py: -------------------------------------------------------------------------------- 1 | # Copyright 2023 Google LLC 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # https://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | from releasetool.commands.tag.cpp import kokoro_job_name, package_name 16 | 17 | 18 | def test_kokoro_job_name(): 19 | job_name = kokoro_job_name("upstream-owner/upstream-repo", "some-package-name") 20 | assert job_name == "cloud-devrel/client-libraries/cpp/upstream-repo/release/publish" 21 | 22 | 23 | def test_package_name(): 24 | name = package_name({"head": {"ref": "release-storage-v1.2.3"}}) 25 | assert name is None 26 | -------------------------------------------------------------------------------- /tests/commands/tag/test_tag_dotnet.py: -------------------------------------------------------------------------------- 1 | # Copyright 2020, Google LLC 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # https://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | import pytest 16 | import re 17 | 18 | from releasetool.commands.tag.dotnet import ( 19 | RELEASE_LINE_PATTERN, 20 | kokoro_job_name, 21 | package_name, 22 | ) 23 | 24 | 25 | release_triggering_lines = [ 26 | ("Release Google.LongRunning version 1.2.3", "Google.LongRunning", "1.2.3"), 27 | ( 28 | "Release Google.LongRunning version 1.2.3-beta01", 29 | "Google.LongRunning", 30 | "1.2.3-beta01", 31 | ), 32 | ("- Release Google.LongRunning version 1.2.3", "Google.LongRunning", "1.2.3"), 33 | ] 34 | 35 | non_release_triggering_lines = [ 36 | ("Release new version of all OsLogin packages"), 37 | ("Release all OsLogin packages version 1.2.3"), 38 | ("Release Google.LongRunning version 1.0"), 39 | ("Release Google.LongRunning version 1.2.3 and 1.2.4"), 40 | ] 41 | 42 | 43 | @pytest.mark.parametrize("line,package,version", release_triggering_lines) 44 | def test_release_line_regex_matching(line, package, version): 45 | """ 46 | The regex can extract a well-formatted package and version 47 | """ 48 | match = re.search(RELEASE_LINE_PATTERN, line) 49 | assert match is not None 50 | assert match.group(2) == package 51 | assert match.group(4) == version 52 | 53 | 54 | @pytest.mark.parametrize("line", non_release_triggering_lines) 55 | def test_release_line_regex_not_matching(line): 56 | """ 57 | The regex is strict enough not to match other lines. 58 | """ 59 | match = re.search(RELEASE_LINE_PATTERN, line) 60 | assert match is None 61 | 62 | 63 | def test_kokoro_job_name(): 64 | job_name = kokoro_job_name("upstream-owner/upstream-repo", "some-package-name") 65 | assert job_name == "cloud-sharp/upstream-repo/gcp_windows/autorelease" 66 | 67 | 68 | def test_package_name(): 69 | name = package_name({"head": {"ref": "release-storage-v1.2.3"}}) 70 | assert name is None 71 | -------------------------------------------------------------------------------- /tests/commands/tag/test_tag_go.py: -------------------------------------------------------------------------------- 1 | # Copyright 2023 Google LLC 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # https://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | from releasetool.commands.tag.go import kokoro_job_name, package_name 16 | 17 | 18 | def test_kokoro_job_name(): 19 | job_name = kokoro_job_name("upstream-owner/upstream-repo", "some-package-name") 20 | assert job_name == "cloud-devrel/client-libraries/go/upstream-repo/release" 21 | 22 | 23 | def test_package_name(): 24 | name = package_name({"head": {"ref": "release-storage-v1.2.3"}}) 25 | assert name is None 26 | -------------------------------------------------------------------------------- /tests/commands/tag/test_tag_java.py: -------------------------------------------------------------------------------- 1 | # Copyright 2019 Google LLC 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # https://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | from releasetool.commands.tag.java import ( 16 | _parse_release_tag, 17 | kokoro_job_name, 18 | package_name, 19 | ) 20 | 21 | RELEASE_PLEASE_OUTPUT = """ 22 | ✔ creating release v1.20.0 23 | ✔ Created release: https://github.com/googleapis/java-bigtable/releases/tag/v1.20.0. 24 | ✔ adding comment to https://github.com/googleapis/java-bigtable/issue/610 25 | ✔ adding label autorelease: tagged to https://github.com/googleapis/java-bigtable/pull/610 26 | ✔ removing label autorelease: pending from 610 27 | """ 28 | 29 | 30 | def test_releasetool_release_tag(): 31 | expected = "v1.20.0" 32 | assert _parse_release_tag(RELEASE_PLEASE_OUTPUT) == expected 33 | 34 | 35 | def test_kokoro_job_name(): 36 | job_name = kokoro_job_name("upstream-owner/upstream-repo", "some-package-name") 37 | assert job_name == "cloud-devrel/client-libraries/java/upstream-repo/release/stage" 38 | 39 | 40 | def test_package_name(): 41 | name = package_name({"head": {"ref": "release-storage-v1.2.3"}}) 42 | assert name is None 43 | -------------------------------------------------------------------------------- /tests/commands/tag/test_tag_nodejs.py: -------------------------------------------------------------------------------- 1 | # Copyright 2019 Google LLC 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # https://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | from releasetool.commands.tag.nodejs import ( 16 | _get_latest_release_notes, 17 | kokoro_job_name, 18 | package_name, 19 | ) 20 | from releasetool.commands.common import TagContext 21 | 22 | fixture_old_style_changelog = """ 23 | # Changelog 24 | 25 | [npm history][1] 26 | 27 | [1]: https://www.npmjs.com/package/dialogflow?activeTab=versions 28 | 29 | ## v0.8.2 30 | 31 | 03-13-2019 16:30 PDT 32 | 33 | ### Bug Fixes 34 | - fix: throw on invalid credentials ([#281](https://github.com/googleapis/nodejs-dialogflow/pull/281)) 35 | 36 | ## v0.8.1 37 | 38 | 01-28-2019 13:24 PST 39 | 40 | ### Documentation 41 | - fix(docs): dialogflow inn't published under @google-cloud scope ([#266](https://github.com/googleapis/nodejs-dialogflow/pull/266)) 42 | 43 | ## v0.8.0 44 | """ 45 | 46 | fixture_new_and_old_style_changelog = """ 47 | # Changelog 48 | 49 | [npm history][1] 50 | 51 | [1]: https://www.npmjs.com/package/@google-cloud/os-login?activeTab=versions 52 | 53 | ### [0.3.3](https://www.github.com/googleapis/nodejs-os-login/compare/v0.3.2...v0.3.3) (2019-04-30) 54 | 55 | 56 | ### Bug Fixes 57 | 58 | * include 'x-goog-request-params' header in requests ([#167](https://www.github.com/googleapis/nodejs-os-login/issues/167)) ([074051d](https://www.github.com/googleapis/nodejs-os-login/commit/074051d)) 59 | 60 | ## v0.3.2 61 | 62 | 03-18-2019 13:47 PDT 63 | 64 | ### Implementation Changes 65 | - refactor: update json import paths ([#156](https://github.com/googleapis/nodejs-os-login/pull/156)) 66 | - fix: throw on invalid credentials 67 | """ 68 | 69 | fixture_new_style_changelog = """ 70 | # Change Log 71 | 72 | All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines. 73 | 74 | ## [2.0.0](https://www.github.com/bcoe/examples-conventional-commits/compare/v1.3.0...v2.0.0) (2019-04-29) 75 | 76 | 77 | ### Features 78 | 79 | * added the most amazing feature ever ([42f90e2](https://www.github.com/bcoe/examples-conventional-commits/commit/42f90e2)) 80 | * adds a fancy new feature ([c46bfa3](https://www.github.com/bcoe/examples-conventional-commits/commit/c46bfa3)) 81 | 82 | 83 | ### BREAKING CHANGES 84 | 85 | * this fancy new feature breaks things 86 | * disclaimer breaks everything 87 | 88 | ## [1.3.0](https://github.com/bcoe/examples-conventional-commits/compare/v1.2.1...v1.3.0) (2018-11-03) 89 | """ 90 | 91 | fixture_new_style_patch = """ 92 | # Changelog 93 | 94 | All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines. 95 | 96 | ## [5.0.0](https://www.github.com/bcoe/c8/compare/v4.1.5...v5.0.0) (2019-05-20) 97 | 98 | 99 | ### ⚠ BREAKING CHANGES 100 | 101 | * temp directory now defaults to setting for report directory 102 | 103 | ### Features 104 | 105 | * default temp directory to report directory ([#102](https://www.github.com/bcoe/c8/issues/102)) ([8602f4a](https://www.github.com/bcoe/c8/commit/8602f4a)) 106 | * load .nycrc/.nycrc.json to simplify migration ([#100](https://www.github.com/bcoe/c8/issues/100)) ([bd7484f](https://www.github.com/bcoe/c8/commit/bd7484f)) 107 | 108 | ### [4.1.5](https://github.com/bcoe/c8/compare/v4.1.4...v4.1.5) (2019-05-11) 109 | 110 | 111 | ### Bug Fixes 112 | 113 | * exit with code 1 when report output fails ([#92](https://github.com/bcoe/c8/issues/92)) ([a27b694](https://github.com/bcoe/c8/commit/a27b694)) 114 | * remove the unmaintained mkdirp dependency ([#91](https://github.com/bcoe/c8/issues/91)) ([a465b65](https://github.com/bcoe/c8/commit/a465b65)) 115 | 116 | 117 | 118 | ## [4.1.4](https://github.com/bcoe/c8/compare/v4.1.3...v4.1.4) (2019-05-03) 119 | 120 | 121 | ### Bug Fixes 122 | 123 | * we were not exiting with 1 if mkdir failed ([#89](https://github.com/bcoe/c8/issues/89)) ([fb02ed6](https://github.com/bcoe/c8/commit/fb02ed6)) 124 | """ 125 | 126 | 127 | def test_old_style_release_notes(): 128 | """ 129 | Our old CHANGELOG template does not make the version header a link and 130 | always uses H2 headers. 131 | """ 132 | expected = """03-13-2019 16:30 PDT 133 | 134 | ### Bug Fixes 135 | - fix: throw on invalid credentials ([#281](https://github.com/googleapis/nodejs-dialogflow/pull/281))""" 136 | ctx = TagContext(release_version="v0.8.2") 137 | _get_latest_release_notes(ctx, fixture_old_style_changelog) 138 | assert ctx.release_notes == expected 139 | 140 | 141 | def test_new_style_release_notes_patch(): 142 | """ 143 | In the conventional-commits template (see: https://github.com/conventional-changelog/conventional-changelog), 144 | patches are an H3 header and are linked to the underlying issue that created the release. 145 | """ 146 | expected = """### Bug Fixes 147 | 148 | * include 'x-goog-request-params' header in requests ([#167](https://www.github.com/googleapis/nodejs-os-login/issues/167)) ([074051d](https://www.github.com/googleapis/nodejs-os-login/commit/074051d))""" 149 | ctx = TagContext(release_version="v0.3.3") 150 | _get_latest_release_notes(ctx, fixture_new_and_old_style_changelog) 151 | assert ctx.release_notes == expected 152 | 153 | 154 | def test_new_style_release_notes_breaking(): 155 | """ 156 | in the conventional-commits template, features/breaking-changes use an H2 header. 157 | """ 158 | expected = """### Features 159 | 160 | * added the most amazing feature ever ([42f90e2](https://www.github.com/bcoe/examples-conventional-commits/commit/42f90e2)) 161 | * adds a fancy new feature ([c46bfa3](https://www.github.com/bcoe/examples-conventional-commits/commit/c46bfa3)) 162 | 163 | 164 | ### BREAKING CHANGES 165 | 166 | * this fancy new feature breaks things 167 | * disclaimer breaks everything""" 168 | ctx = TagContext(release_version="v2.0.0") 169 | _get_latest_release_notes(ctx, fixture_new_style_changelog) 170 | assert ctx.release_notes == expected 171 | 172 | 173 | def test_extracts_appropriate_release_notes_when_prior_release_is_patch(): 174 | """ 175 | see: https://github.com/googleapis/release-please/issues/140 176 | """ 177 | ctx = TagContext(release_version="v5.0.0") 178 | _get_latest_release_notes(ctx, fixture_new_style_patch) 179 | expected = """### ⚠ BREAKING CHANGES 180 | 181 | * temp directory now defaults to setting for report directory 182 | 183 | ### Features 184 | 185 | * default temp directory to report directory ([#102](https://www.github.com/bcoe/c8/issues/102)) ([8602f4a](https://www.github.com/bcoe/c8/commit/8602f4a)) 186 | * load .nycrc/.nycrc.json to simplify migration ([#100](https://www.github.com/bcoe/c8/issues/100)) ([bd7484f](https://www.github.com/bcoe/c8/commit/bd7484f))""" 187 | assert ctx.release_notes == expected 188 | 189 | 190 | def test_kokoro_job_name(): 191 | job_name = kokoro_job_name("upstream-owner/upstream-repo", "some-package-name") 192 | assert ( 193 | job_name 194 | == "cloud-devrel/client-libraries/nodejs/release/upstream-owner/upstream-repo/publish" 195 | ) 196 | 197 | 198 | def test_package_name(): 199 | name = package_name({"head": {"ref": "release-storage-v1.2.3"}}) 200 | assert name is None 201 | -------------------------------------------------------------------------------- /tests/commands/tag/test_tag_php.py: -------------------------------------------------------------------------------- 1 | # Copyright 2021 Google LLC 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # https://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | from releasetool.commands.tag.php import kokoro_job_name, package_name 16 | 17 | 18 | def test_kokoro_job_name(): 19 | job_name = kokoro_job_name("upstream-owner/upstream-repo", "some-package-name") 20 | assert ( 21 | job_name 22 | == "cloud-devrel/client-libraries/php/upstream-owner/upstream-repo/release" 23 | ) 24 | job_name = kokoro_job_name("upstream-owner/google-cloud-php", "some-package-name") 25 | assert job_name == "cloud-devrel/client-libraries/php/google-cloud-php/docs/docs" 26 | 27 | 28 | def test_package_name(): 29 | name = package_name({"head": {"ref": "release-storage-v1.2.3"}}) 30 | assert name is None 31 | -------------------------------------------------------------------------------- /tests/commands/tag/test_tag_python.py: -------------------------------------------------------------------------------- 1 | # Copyright 2021 Google LLC 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # https://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | from releasetool.commands.tag.python import kokoro_job_name, package_name 16 | 17 | 18 | def test_kokoro_job_name(): 19 | job_name = kokoro_job_name("upstream-owner/upstream-repo", "some-package-name") 20 | assert ( 21 | job_name 22 | == "cloud-devrel/client-libraries/python/upstream-owner/upstream-repo/release/release" 23 | ) 24 | 25 | 26 | def test_package_name(): 27 | name = package_name({"head": {"ref": "release-storage-v1.2.3"}}) 28 | assert name is None 29 | -------------------------------------------------------------------------------- /tests/commands/tag/test_tag_python_tool.py: -------------------------------------------------------------------------------- 1 | # Copyright 2021 Google LLC 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # https://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | from releasetool.commands.tag.python_tool import kokoro_job_name, package_name 16 | 17 | 18 | def test_kokoro_job_name(): 19 | job_name = kokoro_job_name("upstream-owner/upstream-repo", "some-package-name") 20 | assert job_name == "cloud-devrel/client-libraries/some-package-name/release" 21 | 22 | 23 | def test_package_name(): 24 | name = package_name({"head": {"ref": "release-storage-v1.2.3"}}) 25 | assert name is None 26 | -------------------------------------------------------------------------------- /tests/commands/tag/test_tag_ruby.py: -------------------------------------------------------------------------------- 1 | # Copyright 2021 Google LLC 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # https://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | from unittest import mock 16 | from releasetool.github import GitHub 17 | from releasetool.commands.common import TagContext 18 | from releasetool.commands.tag.ruby import ( 19 | kokoro_job_name, 20 | package_name, 21 | get_release_notes, 22 | ) 23 | 24 | 25 | def test_kokoro_job_name(): 26 | job_name = kokoro_job_name("upstream-owner/upstream-repo", "some-package-name") 27 | assert job_name == "cloud-devrel/client-libraries/some-package-name/release" 28 | 29 | 30 | def test_kokoro_job_name_gapic(): 31 | job_name = kokoro_job_name( 32 | "googleapis/google-cloud-ruby", "google-cloud-video-intelligence" 33 | ) 34 | assert job_name == "cloud-devrel/client-libraries/google-cloud-ruby/release" 35 | 36 | 37 | def test_kokoro_job_name_functions_framework(): 38 | job_name = kokoro_job_name( 39 | "GoogleCloud/functions-framework-ruby", "functions_framework" 40 | ) 41 | assert job_name == "cloud-devrel/ruby/functions-framework-ruby/release" 42 | 43 | 44 | def test_kokoro_job_name_apiary(): 45 | job_name = kokoro_job_name("googleapis/google-api-ruby-client", "youtube") 46 | assert job_name == "cloud-devrel/client-libraries/google-api-ruby-client/release" 47 | 48 | 49 | def test_kokoro_job_name_adapter(): 50 | job_name = kokoro_job_name( 51 | "googleapis/ruby-spanner-activerecord", "ruby-spanner-activerecord" 52 | ) 53 | assert job_name == "cloud-devrel/client-libraries/ruby-spanner-activerecord/release" 54 | 55 | 56 | def test_package_name(): 57 | name = package_name({"head": {"ref": "release-storage-v1.2.3"}}) 58 | assert name == "storage" 59 | 60 | 61 | def test_get_release_notes_monorepo(): 62 | ctx = TagContext() 63 | ctx.package_name = "google-cloud-spanner" 64 | ctx.upstream_name = "origin" 65 | ctx.upstream_repo = "googleapis/ruby-spanner" 66 | ctx.release_version = "1.2.3" 67 | ctx.release_pr = {"merge_commit_sha": "abc123"} 68 | ctx.github = mock.Mock(autospec=GitHub) 69 | 70 | contents = "### 1.2.3 (2022-12-08)\n\n#### Features\n\n* something\n" 71 | ctx.github.get_contents.return_value = contents.encode("utf-8") 72 | 73 | get_release_notes(ctx) 74 | 75 | assert ctx.release_notes == "#### Features\n\n* something" 76 | 77 | ctx.github.get_contents.assert_called_once_with( 78 | "googleapis/ruby-spanner", "google-cloud-spanner/CHANGELOG.md", ref="abc123" 79 | ) 80 | 81 | 82 | def test_get_release_notes_non_monorepo(): 83 | ctx = TagContext() 84 | ctx.package_name = "ruby-spanner-activerecord" 85 | ctx.upstream_name = "origin" 86 | ctx.upstream_repo = "googleapis/ruby-spanner-activerecord" 87 | ctx.release_version = "1.2.3" 88 | ctx.release_pr = {"merge_commit_sha": "abc123"} 89 | ctx.github = mock.Mock(autospec=GitHub) 90 | 91 | contents = "### 1.2.3 (2022-12-08)\n\n#### Features\n\n* something\n" 92 | ctx.github.get_contents.return_value = contents.encode("utf-8") 93 | 94 | get_release_notes(ctx) 95 | 96 | assert ctx.release_notes == "#### Features\n\n* something" 97 | 98 | ctx.github.get_contents.assert_called_once_with( 99 | "googleapis/ruby-spanner-activerecord", "CHANGELOG.md", ref="abc123" 100 | ) 101 | -------------------------------------------------------------------------------- /tests/test_autorelease_tag.py: -------------------------------------------------------------------------------- 1 | # Copyright 2021 Google LLC 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # https://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | import requests_mock 16 | from unittest.mock import patch, Mock 17 | 18 | from autorelease import tag 19 | 20 | 21 | @patch("autorelease.tag.process_issue") 22 | @patch("autorelease.github.GitHub.list_org_issues") 23 | @patch("autorelease.kokoro.make_authorized_session") 24 | def test_no_issues(make_authorized_session, list_org_issues, process_issue): 25 | list_org_issues.return_value = [] 26 | 27 | tag.main("github-token", "kokoro-credentials") 28 | make_authorized_session.assert_called_once() 29 | process_issue.assert_not_called() 30 | 31 | 32 | @patch("autorelease.tag.process_issue") 33 | @patch("autorelease.github.GitHub.list_org_issues") 34 | @patch("autorelease.kokoro.make_authorized_session") 35 | def test_processes_issues(make_authorized_session, list_org_issues, process_issue): 36 | pr1 = { 37 | "base": {"ref": "abc123", "repo": {"full_name": "googleapis/java-asset"}}, 38 | "pull_request": {"html_url": "https://github.com/googleapis/java-asset"}, 39 | "title": "chore: release 1.2.3", 40 | } 41 | pr2 = { 42 | "base": {"ref": "def456", "repo": {"full_name": "googleapis/nodejs-container"}}, 43 | "pull_request": {"html_url": "https://github.com/nodejs/java-container"}, 44 | "title": "chore: release 1.0.0", 45 | } 46 | list_org_issues.side_effect = [[pr1, pr2]] 47 | tag.main("github-token", "kokoro-credentials") 48 | list_org_issues.assert_any_call( 49 | org="googleapis", state="closed", labels="autorelease: pending" 50 | ) 51 | list_org_issues.assert_any_call( 52 | org="GoogleCloudPlatform", state="closed", labels="autorelease: pending" 53 | ) 54 | assert process_issue.call_count == 2 55 | 56 | 57 | @patch("releasetool.commands.tag.java.tag") 58 | def test_run_releasetool_tag_delegates(tag_mock): 59 | github = Mock() 60 | github.token = "github-token" 61 | context = Mock() 62 | tag_mock.return_value = context 63 | pull = {"base": {"ref": "abc123", "repo": {"full_name": "googleapis/java-asset"}}} 64 | ctx = tag.run_releasetool_tag("java", github, pull) 65 | assert ctx == context 66 | 67 | 68 | @patch("autorelease.tag.LANGUAGE_ALLOWLIST", ["java"]) 69 | @patch("autorelease.tag.run_releasetool_tag") 70 | def test_process_issue_skips_non_merged(run_releasetool_tag): 71 | github = Mock() 72 | github.update_pull_labels = Mock() 73 | github.get_url.return_value = { 74 | "merged_at": None, 75 | "base": {"repo": {"full_name": "googleapis/java-asset"}}, 76 | } 77 | issue = { 78 | "pull_request": {"url": "https://api.github.com/googleapis/java-asset/pull/5"} 79 | } 80 | tag.process_issue(Mock(), github, issue, Mock()) 81 | github.update_pull_labels.assert_called_once() 82 | run_releasetool_tag.assert_not_called() 83 | 84 | 85 | @patch("autorelease.tag.LANGUAGE_ALLOWLIST", ["java"]) 86 | @patch("autorelease.kokoro.trigger_build") 87 | @patch("autorelease.tag.run_releasetool_tag") 88 | def test_process_issue_triggers_kokoro(run_releasetool_tag, trigger_build): 89 | github = Mock() 90 | github.get_url.return_value = { 91 | "merged_at": "2021-01-01T09:00:00.000Z", 92 | "base": {"repo": {"full_name": "googleapis/java-asset"}}, 93 | "html_url": "https://github.com/googleapis/java-asset/pulls/5", 94 | } 95 | context = Mock() 96 | context.kokoro_job_name = "kokoro-job-name" 97 | context.release_tag = "v1.2.3" 98 | run_releasetool_tag.return_value = context 99 | issue = { 100 | "pull_request": {"url": "https://api.github.com/googleapis/java-asset/pull/5"}, 101 | "merged_at": "2021-01-01T09:00:00.000Z", 102 | } 103 | tag.process_issue(Mock(), github, issue, Mock()) 104 | run_releasetool_tag.assert_called_once() 105 | trigger_build.assert_called_once() 106 | 107 | 108 | @patch("autorelease.tag.LANGUAGE_ALLOWLIST", ["java"]) 109 | @patch("autorelease.kokoro.trigger_build") 110 | @patch("autorelease.tag.run_releasetool_tag") 111 | def test_process_issue_skips_kokoro_if_no_job_name(run_releasetool_tag, trigger_build): 112 | github = Mock() 113 | github.get_url.return_value = { 114 | "merged_at": "2021-01-01T09:00:00.000Z", 115 | "base": {"repo": {"full_name": "googleapis/java-asset"}}, 116 | "html_url": "https://github.com/googleapis/java-asset/pulls/5", 117 | } 118 | context = Mock() 119 | context.kokoro_job_name = None 120 | context.release_tag = None 121 | run_releasetool_tag.return_value = context 122 | issue = { 123 | "pull_request": {"url": "https://api.github.com/googleapis/java-asset/pull/5"}, 124 | "merged_at": "2021-01-01T09:00:00.000Z", 125 | } 126 | tag.process_issue(Mock(), github, issue, Mock()) 127 | run_releasetool_tag.assert_called_once() 128 | trigger_build.assert_not_called() 129 | 130 | 131 | @patch("autorelease.tag.LANGUAGE_ALLOWLIST", ["java"]) 132 | @patch("autorelease.tag.run_releasetool_tag") 133 | @patch("autorelease.github.GitHub.list_org_issues") 134 | @patch("autorelease.kokoro.make_authorized_session") 135 | def test_respects_allowlist( 136 | make_authorized_session, list_org_issues, run_releasetool_tag 137 | ): 138 | pr1 = { 139 | "base": {"ref": "abc123", "repo": {"full_name": "googleapis/java-asset"}}, 140 | "pull_request": { 141 | "url": "https://api.github.com/repos/googleapis/java-asset/pull/123", 142 | "html_url": "https://github.com/googleapis/java-asset", 143 | }, 144 | "title": "chore: release 1.2.3", 145 | } 146 | pr2 = { 147 | "base": {"ref": "def456", "repo": {"full_name": "googleapis/nodejs-container"}}, 148 | "pull_request": { 149 | "url": "https://api.github.com/repos/googleapis/nodejs-container/pull/234", 150 | "html_url": "https://github.com/nodejs/nodejs-container", 151 | }, 152 | "title": "chore: release 1.0.0", 153 | } 154 | list_org_issues.side_effect = [[pr1, pr2]] 155 | with requests_mock.Mocker() as m: 156 | m.get( 157 | "https://api.github.com/repos/googleapis/java-asset/pull/123", 158 | json={ 159 | "merged_at": "2021-01-01T09:00:00.000Z", 160 | "base": {"repo": {"full_name": "googleapis/java-asset"}}, 161 | }, 162 | ) 163 | m.get( 164 | "https://api.github.com/repos/googleapis/nodejs-container/pull/234", 165 | json={ 166 | "merged_at": "2021-01-01T09:00:00.000Z", 167 | "base": {"repo": {"full_name": "googleapis/nodejs-container"}}, 168 | }, 169 | ) 170 | tag.main("github-token", "kokoro-credentials") 171 | list_org_issues.assert_any_call( 172 | org="googleapis", state="closed", labels="autorelease: pending" 173 | ) 174 | list_org_issues.assert_any_call( 175 | org="GoogleCloudPlatform", state="closed", labels="autorelease: pending" 176 | ) 177 | assert run_releasetool_tag.call_count == 1 178 | -------------------------------------------------------------------------------- /tests/test_dummy.py: -------------------------------------------------------------------------------- 1 | def test_dummy(): 2 | assert True 3 | -------------------------------------------------------------------------------- /tests/test_github.py: -------------------------------------------------------------------------------- 1 | # Copyright 2023 Google LLC 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # https://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | 16 | from releasetool import github 17 | import pathlib 18 | import requests_mock 19 | 20 | 21 | def test_app_credentials(): 22 | with requests_mock.Mocker() as m: 23 | m.post( 24 | "https://api.github.com/app/installations/my-installation-id/access_tokens", 25 | status_code=201, 26 | json={ 27 | "token": "remote-access-token", 28 | }, 29 | ) 30 | 31 | private_key = ( 32 | pathlib.Path(__file__).parent / "testdata" / "fake-private-key.pem" 33 | ).read_text() 34 | token = github.get_installation_access_token( 35 | "my-app-id", "my-installation-id", private_key 36 | ) 37 | assert token == "remote-access-token" 38 | -------------------------------------------------------------------------------- /tests/test_publish_reporter.py: -------------------------------------------------------------------------------- 1 | # Copyright 2020 Google LLC 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # https://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | import os 16 | import pytest 17 | import unittest 18 | from unittest.mock import patch 19 | import releasetool.commands.publish_reporter 20 | 21 | 22 | class PublishReporter(unittest.TestCase): 23 | def test_publish_reporter_start_devrel_api_key(self): 24 | with patch.dict(os.environ, {"KOKORO_KEYSTORE_DIR": "./"}): 25 | with pytest.raises(Exception) as err: 26 | releasetool.commands.publish_reporter.start( 27 | "abc123", "http://example.com" 28 | ) 29 | assert "magic github proxy api key is required" in str(err.value) 30 | 31 | def test_publish_reporter_finish_devrel_api_key(self): 32 | with patch.dict(os.environ, {"KOKORO_KEYSTORE_DIR": "./"}): 33 | with pytest.raises(Exception) as err: 34 | releasetool.commands.publish_reporter.finish( 35 | "abc123", "http://example.com", True, "" 36 | ) 37 | assert "magic github proxy api key is required" in str(err.value) 38 | 39 | # TODO: use requests_mock to flesh out more thorough tests 40 | # see: https://github.com/googleapis/synthtool/blob/8cf6d2834ad14318e64429c3b94f6443ae83daf9/tests/test_language_java.py#L69-L76 41 | -------------------------------------------------------------------------------- /tests/testdata/fake-private-key.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN RSA PRIVATE KEY----- 2 | MIICWgIBAAKBgGCmk6xUrFM3e8qS3gph31HusC/JmSf7+7+sG/8POpg/8gzBVq2m 3 | zcEGubHJWwWZHKxrDVKkyZc3G3u7XSD1+N+hh+LDf1Kt4JccDV5OJXZ6tCedjH6Z 4 | Ty1qX8nxb6SF8GZctypTzA5lZk2VVVeR5ficnKDGCngReUOYXCmXoRplAgMBAAEC 5 | gYBWE0QlD+vA2QL4cEArQureTxK+HG63+2RDWYY9a1Slzx1EWtNVJ97Kb7DlMwxL 6 | OgcdTuG4nmWitENXuI/CEQ2pEKNmUAKMqorhSHqL5mFJi7Oe5m8guNqM4ClvJlCS 7 | UKgj6v6B7uEPDsMEojvNllJElyBcw2ld4Ji6VN4LxsleKQJBALjICFD514yYdjtg 8 | 3n8gJpZI9vIFTIwDnLcClIzZAfJwXU1hyaY7jVQKif9VOjUDZr3DGek8tPNeqIVb 9 | Xa9aH4MCQQCF5uK0YSk1yxBBzzoS+5fAAkNI0qYwBNPAoFhwZ+TT0y0S3enRI+m6 10 | 5iYKOwef7E2QVYXUEOhvUIiKUAQFBxH3AkBRcxr3VqnEt4+mLNTmhG194Tu5Aszz 11 | CsSRhvmj/CP3kcAO1APm2mk5mkup2Q+HPrCTBOTvAmtgu2DdJ6DsInWxAkBptNi5 12 | n45h6hm+ajKlc7rbmK23WpxZgiYMhkjrDAmoc6i8oTWJpjlJE5FqOCmPxYOB8xIA 13 | VQy5e7Eex4Y01d0HAkA9c6tb9jFxFHQdcSSM02UuZAjA+YiboO5znl4FCC6lpgBd 14 | irnvYZ5sD2Ba6baUW2/IqQwuTL/t1QJpqbwwuJi5 15 | -----END RSA PRIVATE KEY----- --------------------------------------------------------------------------------