Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .kokoro/release.cfg
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# Format: //devtools/kokoro/config/proto/build.proto

build_file: "github/google-ads-python/.kokoro/release.sh"

# Default to DRY_RUN=true for safety; can be overridden via API or UI
env_vars: {
key: "DRY_RUN"
value: "true"
}

container_properties {
docker_image: "us-central1-docker.pkg.dev/kokoro-container-bakery/kokoro/ubuntu/ubuntu2204/full:current"
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: consider retaining the built distributions (and the manifest, if moved to KOKORO_ARTIFACTS_DIR) as Kokoro artifacts so there's an auditable copy of exactly what was staged, plus an explicit timeout so a hung auth prompt can't run the default:

Suggested change
}
}
timeout_mins: 30
action {
define_artifacts {
regex: "github/google-ads-python/dist/*"
}
}

108 changes: 108 additions & 0 deletions .kokoro/release.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
#!/bin/bash
set -euo pipefail

# Default to dry-run for safety unless DRY_RUN=false is explicitly passed.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Worth being explicit in the header that DRY_RUN=true is not side-effect free: it still deletes any existing google-ads package in the Exit Gate staging repo and re-uploads. Someone reading "dry run" will assume read-only.

Also consider normalizing case so a True from the Kokoro UI doesn't hard-fail:

Suggested change
# Default to dry-run for safety unless DRY_RUN=false is explicitly passed.
# Default to dry-run for safety unless DRY_RUN=false is explicitly passed.
# NOTE: DRY_RUN=true still builds, wipes the staging package in Artifact
# Registry, and re-uploads; it only skips the manifest upload that triggers
# the external publish.
DRY_RUN="${DRY_RUN:-true}"
DRY_RUN="${DRY_RUN,,}"

DRY_RUN="${DRY_RUN:-true}"
if [[ "${DRY_RUN}" != "true" && "${DRY_RUN}" != "false" ]]; then
echo "ERROR: DRY_RUN must be 'true' or 'false' (got '${DRY_RUN}')." >&2
exit 1
fi

# -----------------------------------------------------------------------------
# 1. Workspace & Directory Resolution
# -----------------------------------------------------------------------------
REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "${REPO_DIR}"

echo "=== Building and Releasing from: ${REPO_DIR} (DRY_RUN=${DRY_RUN}) ==="

# -----------------------------------------------------------------------------
# 2. Environment & Tooling Setup
# -----------------------------------------------------------------------------
PYENV_VER="3.12"

echo "=== Installing pyenv Python version (if needed): ${PYENV_VER} ==="
pyenv install -s "${PYENV_VER}"

echo "=== Activating pyenv Python version: ${PYENV_VER} ==="

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: pyenv global mutates container-wide state. export PYENV_VERSION="${PYENV_VER}" (or pyenv shell) scopes it to this process and is one less thing to reason about if the image is ever reused.

Also note pyenv install -s 3.12 only resolves the bare 3.12 prefix on pyenv ≥ 2.3.x; fine for ubuntu2204/full:current today, but if this ever fails with "definition not found" that's why.

pyenv global "${PYENV_VER}"

python3 -m venv .venv

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: creating the venv inside the repo root means it's sitting next to pyproject.toml when build runs. setuptools won't pick it up for the wheel, but keeping it out of the tree avoids any surprises with sdist/egg-info globbing:

Suggested change
python3 -m venv .venv
VENV_DIR="${KOKORO_ARTIFACTS_DIR:-/tmp}/release-venv"
python3 -m venv "${VENV_DIR}"
source "${VENV_DIR}/bin/activate"

source ./.venv/bin/activate

python3 -m pip install --require-hashes -r .kokoro/requirements.txt

# -----------------------------------------------------------------------------
# 3. Clean and Build Distribution Packages
# -----------------------------------------------------------------------------
echo "=== Cleaning previous build artifacts ==="
rm -rf dist/ build/ *.egg-info

echo "=== Building sdist and wheel ==="
python3 -m build

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This undercuts --require-hashes above. python -m build defaults to an isolated build env and installs whatever pyproject.toml lists in [build-system].requires — which is an unpinned setuptools — fresh from PyPI with no hash check. So the one dependency that actually produces the wheel is the one that isn't pinned.

Suggest building in the already-verified venv instead, and adding setuptools (+ wheel) to requirements.in so they're hash-locked too:

Suggested change
python3 -m build
# --no-isolation: build with the hash-verified setuptools from .kokoro/requirements.txt
# rather than letting `build` fetch an unpinned copy from PyPI.
python3 -m build --no-isolation

While here, it'd be useful to capture the version once so the later steps can use it (see manifest comment below). Python 3.12 has tomllib in the stdlib:

VERSION="$(python3 -c 'import tomllib; print(tomllib.load(open("pyproject.toml", "rb"))["project"]["version"])')"
echo "=== Building google-ads ${VERSION} ==="


echo "=== Validating built packages with twine ==="
twine --version
twine check dist/*

# -----------------------------------------------------------------------------
# 4. Upload to Internal Exit Gate Artifact Registry
# -----------------------------------------------------------------------------
EXIT_GATE_PROJECT="oss-exit-gate-prod"
EXIT_GATE_LOCATION="us"
EXIT_GATE_REPOSITORY="google-ads-python--pypi"
PACKAGE_NAME="google-ads"

if gcloud artifacts packages describe "${PACKAGE_NAME}" \

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I follow why this is here (AR rejects re-uploading an existing version, and Exit Gate normally deletes the package after a successful publish, so anything present is a leftover from a failed/unpublished run). But a short comment saying exactly that would save the next person from thinking it's gratuitously destructive.

Two smaller points:

  • If twine upload fails after this delete, the staging repo is now empty and a previously-staged-but-unpublished version is gone. Acceptable given it's staging, but worth noting.
  • Once the manifest pins version (below), you could narrow this to gcloud artifacts versions delete "${VERSION}" --package=... and leave other versions alone. Not required — whole-package delete is simpler and matches the Java script — just flagging the option.

--project="${EXIT_GATE_PROJECT}" \
--location="${EXIT_GATE_LOCATION}" \
--repository="${EXIT_GATE_REPOSITORY}" &>/dev/null; then
echo "=== Deleting existing package '${PACKAGE_NAME}' from Exit Gate staging repository ==="
gcloud artifacts packages delete "${PACKAGE_NAME}" \
--project="${EXIT_GATE_PROJECT}" \
--location="${EXIT_GATE_LOCATION}" \
--repository="${EXIT_GATE_REPOSITORY}" \
--quiet
else
echo "=== Skipping deletion. Package '${PACKAGE_NAME}' not found in staging repository. This is normal. ==="
fi

EXIT_GATE_REPO="https://us-python.pkg.dev/oss-exit-gate-prod/google-ads-python--pypi"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: derive this from the variables you already defined so project/location/repo can't drift from the gcloud calls above:

Suggested change
EXIT_GATE_REPO="https://us-python.pkg.dev/oss-exit-gate-prod/google-ads-python--pypi"
EXIT_GATE_REPO="https://${EXIT_GATE_LOCATION}-python.pkg.dev/${EXIT_GATE_PROJECT}/${EXIT_GATE_REPOSITORY}"


echo "=== Uploading packages to Exit Gate staging repository: ${EXIT_GATE_REPO} ==="
twine upload --repository-url "${EXIT_GATE_REPO}" dist/*

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Add --non-interactive so that if the keyrings.google-artifactregistry-auth backend fails to mint a token (no ADC on the runner, wrong SA, etc.) twine errors out immediately instead of blocking on a username prompt until the Kokoro job times out:

Suggested change
twine upload --repository-url "${EXIT_GATE_REPO}" dist/*
twine upload --non-interactive --repository-url "${EXIT_GATE_REPO}" dist/*


# -----------------------------------------------------------------------------
# 5. Trigger Exit Gate Release via GCS Manifest
# -----------------------------------------------------------------------------
if [[ "${DRY_RUN}" == "true" ]]; then
echo "=== DRY_RUN is enabled. Skipping manifest upload to Exit Gate. ==="
echo "Artifacts are staged in Artifact Registry."
exit 0
fi

echo "=== Creating targeted release manifest for google-ads ==="
cat <<EOF > manifest.json

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: write this somewhere other than the repo root so it can't be left behind in the checkout (and so it's retained as a Kokoro artifact for audit):

MANIFEST_PATH="${KOKORO_ARTIFACTS_DIR:-/tmp}/manifest.json"
cat <<EOF > "${MANIFEST_PATH}"

{
"publish_all": false,
"publishing_groups": [
{
"packages": [
{
"name": "google-ads"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please pin the version here. Without "version", Exit Gate publishes the newest version of google-ads in the staging repo. Today that's correct only because step 4 wiped the package first — the publish is implicitly relying on a delete succeeding, and if someone later removes/relaxes that step (or two jobs overlap) you can push the wrong artifact to PyPI, which can't be un-published.

Pinning makes the manifest self-describing and the delete step a cleanliness measure rather than a correctness one (this is also what the gVisor Exit Gate release does):

Suggested change
"name": "google-ads"
"name": "google-ads",
"version": "${VERSION}"

}
]
}
]
}
EOF

EXIT_GATE_BUCKET="gs://oss-exit-gate-prod-projects-bucket/google-ads-python/pypi/manifests"
MANIFEST_NAME="manifest-$(date +%Y%m%d%H%M%S).json"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: include the version (and the Kokoro build ID when present) in the object name so manifests in the bucket are self-identifying and trivially correlated back to a job. Each upload must still be a unique name since every object triggers a new publish:

Suggested change
MANIFEST_NAME="manifest-$(date +%Y%m%d%H%M%S).json"
MANIFEST_NAME="manifest-${VERSION}-${KOKORO_BUILD_ID:-$(date --utc +%Y%m%d%H%M%S)}.json"


echo "=== Uploading manifest to ${EXIT_GATE_BUCKET}/${MANIFEST_NAME} ==="
gcloud storage cp manifest.json "${EXIT_GATE_BUCKET}/${MANIFEST_NAME}"

echo "========================================================================="
echo "Release successfully triggered! Exit Gate will now verify BCID and publish to PyPI."
echo "========================================================================="
4 changes: 4 additions & 0 deletions .kokoro/requirements.in
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
build
twine
keyrings.google-artifactregistry-auth
requests >= 2.33.0

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: a one-line comment on why requests has a floor here (CVE? twine compat?) would help whoever next runs pip-compile. And per the --no-isolation suggestion in release.sh, add the build backend so it's hash-locked too:

Suggested change
requests >= 2.33.0
# Floor for <reason>; everything else is unpinned and resolved by pip-compile.
requests >= 2.33.0
# Build backend from pyproject.toml, pinned here so `build --no-isolation`
# uses a hash-verified copy instead of fetching the latest from PyPI.
setuptools
wheel

Loading
Loading