diff --git a/.github/actions/create-draft-release/action.yml b/.github/actions/create-draft-release/action.yml new file mode 100644 index 0000000..5367443 --- /dev/null +++ b/.github/actions/create-draft-release/action.yml @@ -0,0 +1,91 @@ +name: Create draft GitHub Release +description: >- + Write an authorized-publication record (who released what, with SHA-256 of + every artifact), then create or update a draft GitHub Release for a pushed + tag and attach the distributions, their detached .sig signatures, and the + record. Requires release-setup to have run first. Mirrors the + authorized-pub action plus the draft-release step of + mongodb-labs/drivers-github-tools/python/post-publish@v3. + +inputs: + tag: + description: Git tag to release. It must already exist on origin unless dry_run is "true". + required: true + title: + description: Release title. + required: true + product_name: + description: Product name recorded in the authorized-publication record. + required: true + dist_path: + description: Directory holding the distribution files that were published. + required: true + token: + description: GitHub token with contents:write on this repository. + required: true + dry_run: + description: When "true", build the asset set and summary but do not create a release. + default: "false" + +runs: + using: composite + steps: + - name: Write authorized-publication record + shell: bash + env: + GH_TOKEN: ${{ inputs.token }} + PRODUCT_NAME: ${{ inputs.product_name }} + TAG: ${{ inputs.tag }} + DIST_PATH: ${{ inputs.dist_path }} + run: | + set -eu + : "${RELEASE_ASSETS:?run release-setup before create-draft-release}" + releaser="$(gh api "users/${GITHUB_ACTOR}" --jq '.name // .login')" + commit="$(git rev-parse HEAD 2>/dev/null || echo "$GITHUB_SHA")" + report="$RELEASE_ASSETS/authorized-publication.txt" + { + echo "Product: $PRODUCT_NAME" + echo "Version: $TAG" + echo "Commit: $commit" + echo "Releaser: $releaser" + echo "Build Source: GitHub Actions" + echo "Build Number: $GITHUB_RUN_ID" + for f in "$DIST_PATH"/*; do + [ -f "$f" ] || continue + echo "Filename: $(basename "$f")" + echo "Shasum: $(sha256sum "$f" | awk '{print $1}')" + done + } > "$report" + cat "$report" + + - name: Create or update draft release + shell: bash + env: + GH_TOKEN: ${{ inputs.token }} + TAG: ${{ inputs.tag }} + TITLE: ${{ inputs.title }} + DIST_PATH: ${{ inputs.dist_path }} + DRY_RUN: ${{ inputs.dry_run }} + run: | + set -eu + if [ "$DRY_RUN" = "true" ]; then + { + echo "### Dry run: release $TAG not created" + echo '```' + ls -l "$RELEASE_ASSETS" "$DIST_PATH" + echo '```' + } >> "$GITHUB_STEP_SUMMARY" + exit 0 + fi + + if ! gh release view "$TAG" > /dev/null 2>&1; then + gh release create "$TAG" --draft --verify-tag --title "$TITLE" \ + --notes "Draft release for $TAG. Each .sig file is a detached, armored GPG signature over the artifact of the same name; see authorized-publication.txt for SHA-256 sums." + fi + gh release upload "$TAG" "$RELEASE_ASSETS"/* "$DIST_PATH"/* --clobber + + { + echo "### $TITLE" + gh release view "$TAG" --json url,tagName,isDraft,assets \ + --jq '"URL: \(.url)\nTag: \(.tagName)\nDraft: \(.isDraft)\nAssets:\n" + (.assets | map("- " + .name) | join("\n"))' + } >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/actions/garasign-git/action.yml b/.github/actions/garasign-git/action.yml new file mode 100644 index 0000000..18c83e3 --- /dev/null +++ b/.github/actions/garasign-git/action.yml @@ -0,0 +1,37 @@ +name: Run git inside the Garasign signing container +description: >- + Run one git command inside the Garasign container with the team GPG key + loaded, so tags (and commits) can be created with -s / -S. Requires + release-setup to have run first. Mirrors + mongodb-labs/drivers-github-tools/git-sign@v3. + +inputs: + command: + description: The git command to run, as a single string. + required: true + ecr_registry: + description: ECR registry that hosts the Garasign images. + default: "901841024863.dkr.ecr.us-east-1.amazonaws.com" + ecr_repository: + description: Garasign git image repository. + default: release-infrastructure/garasign-git + +runs: + using: composite + steps: + - name: Run signed git command + shell: bash + env: + ECR_REGISTRY: ${{ inputs.ecr_registry }} + ECR_REPOSITORY: ${{ inputs.ecr_repository }} + COMMAND: ${{ inputs.command }} + run: | + set -eu + : "${GARASIGN_ENVFILE:?run release-setup before garasign-git}" + WORKDIR=/home/git-checkout + docker run --rm \ + --env-file="$GARASIGN_ENVFILE" \ + -v "$(pwd):$WORKDIR" \ + -w "$WORKDIR" \ + "${ECR_REGISTRY}/${ECR_REPOSITORY}" \ + /bin/bash -c "git config --global --add safe.directory $WORKDIR && gpgloader && ${COMMAND}" diff --git a/.github/actions/release-setup/action.yml b/.github/actions/release-setup/action.yml new file mode 100644 index 0000000..09bd9f9 --- /dev/null +++ b/.github/actions/release-setup/action.yml @@ -0,0 +1,101 @@ +name: Release setup +description: >- + Assume the AWS release role through GitHub OIDC, log in to the Garasign ECR + registry, load signing secrets from AWS Secrets Manager, and export the + environment variables used by the garasign-git, sign-artifacts, and + create-draft-release actions. Mirrors mongodb-labs/drivers-github-tools/setup@v3. + +inputs: + aws_role_arn: + description: AWS IAM role (trusting this repository's OIDC subject) that can read the release secret. + required: true + aws_region: + description: AWS region of the release role and Secrets Manager secret. + required: true + aws_secret_id: + description: >- + AWS Secrets Manager secret id. Its JSON must contain garasign-username, + garasign-password, and gpg-key-id; gpg-public-url is optional. + required: true + ecr_registry_id: + description: AWS account id of the ECR registry that hosts the Garasign images. + default: "901841024863" + ecr_role_arn: + description: Read-only role used to pull the Garasign images from ECR. + default: "arn:aws:iam::901841024863:role/ecr-role-gha-ro" + ecr_region: + description: Region of the ECR registry. + default: us-east-1 + +runs: + using: composite + steps: + # ECR login happens first so the Docker credential survives the second + # role assumption below. + - name: Assume ECR read-only role + uses: aws-actions/configure-aws-credentials@e6de054238d6b7531b4efff3b6587d9aade6a06c # v6.2.3 + with: + role-to-assume: ${{ inputs.ecr_role_arn }} + role-session-name: release-ecr + aws-region: ${{ inputs.ecr_region }} + + - name: Log in to the Garasign ECR registry + uses: aws-actions/amazon-ecr-login@03f1aad4c6c7ffd436567f42f9384779290529bd # v2 + with: + registries: ${{ inputs.ecr_registry_id }} + + - name: Assume release secrets role + uses: aws-actions/configure-aws-credentials@e6de054238d6b7531b4efff3b6587d9aade6a06c # v6.2.3 + with: + role-to-assume: ${{ inputs.aws_role_arn }} + role-session-name: release-secrets + aws-region: ${{ inputs.aws_region }} + + - name: Load release secrets into the environment + uses: aws-actions/aws-secretsmanager-get-secrets@2cb1a461cbd4865ac4299648312e4704c646cd53 # v3.0.1 + with: + secret-ids: ${{ inputs.aws_secret_id }} + parse-json-secrets: true + + - name: Export Garasign, GPG, and release-asset variables + shell: bash + env: + AWS_SECRET_ID: ${{ inputs.aws_secret_id }} + run: | + set -eu + # aws-secretsmanager-get-secrets exports each JSON key as + # _, upper-cased with "-" and "/" folded to "_". + # Strip that prefix so the keys become GARASIGN_USERNAME, GPG_KEY_ID, ... + prefix="$(echo "$AWS_SECRET_ID" | tr '[:lower:]' '[:upper:]' | sed -r 's/[-/]+/_/g')_" + for var in $(compgen -A variable | grep "^${prefix}" || true); do + declare "${var#"$prefix"}=${!var}" + done + : "${GARASIGN_USERNAME:?missing garasign-username in $AWS_SECRET_ID}" + : "${GARASIGN_PASSWORD:?missing garasign-password in $AWS_SECRET_ID}" + : "${GPG_KEY_ID:?missing gpg-key-id in $AWS_SECRET_ID}" + + umask 077 + GARASIGN_ENVFILE=/tmp/garasign-envfile + cat > "$GARASIGN_ENVFILE" < "$RELEASE_ASSETS/release_run_id.txt" + + { + echo "GARASIGN_ENVFILE=$GARASIGN_ENVFILE" + echo "GPG_KEY_ID=$GPG_KEY_ID" + echo "GPG_PUBLIC_URL=${GPG_PUBLIC_URL:-}" + echo "RELEASE_ASSETS=$RELEASE_ASSETS" + } >> "$GITHUB_ENV" + + # Local (not --global) so the mounted checkout carries the identity into + # the Garasign container when it creates the annotated tag. + - name: Configure git identity for release automation + shell: bash + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" diff --git a/.github/actions/sign-artifacts/action.yml b/.github/actions/sign-artifacts/action.yml new file mode 100644 index 0000000..b68e03c --- /dev/null +++ b/.github/actions/sign-artifacts/action.yml @@ -0,0 +1,58 @@ +name: Sign artifacts with Garasign +description: >- + Create an armored, detached GPG signature (.sig) for each artifact + using the Garasign container and move the signatures into $RELEASE_ASSETS. + Requires release-setup to have run first. Mirrors + mongodb-labs/drivers-github-tools/gpg-sign@v3. + +inputs: + filenames: + description: Space-separated file paths or glob patterns to sign (for example "dist/*"). + required: true + ecr_registry: + description: ECR registry that hosts the Garasign images. + default: "901841024863.dkr.ecr.us-east-1.amazonaws.com" + ecr_repository: + description: Garasign git image repository (it also carries gpg). + default: release-infrastructure/garasign-git + +runs: + using: composite + steps: + - name: Create detached signatures + shell: bash + env: + ECR_REGISTRY: ${{ inputs.ecr_registry }} + ECR_REPOSITORY: ${{ inputs.ecr_repository }} + FILENAMES: ${{ inputs.filenames }} + run: | + set -eu + : "${GARASIGN_ENVFILE:?run release-setup before sign-artifacts}" + : "${RELEASE_ASSETS:?run release-setup before sign-artifacts}" + + # Expand globs on the host so the container receives concrete paths. + files=() + for pattern in $FILENAMES; do + for f in $pattern; do + if [ -f "$f" ]; then + files+=("$f") + fi + done + done + if [ "${#files[@]}" -eq 0 ]; then + echo "::error::No files matched '$FILENAMES'" + exit 1 + fi + + docker run --rm \ + --env-file="$GARASIGN_ENVFILE" \ + -v "$(pwd):$(pwd)" \ + -w "$(pwd)" \ + "${ECR_REGISTRY}/${ECR_REPOSITORY}" \ + /bin/bash -c "gpgloader && for f in ${files[*]}; do gpg --detach-sign --armor --output \"\$f.sig\" \"\$f\"; done" + + echo "### Signed artifacts" >> "$GITHUB_STEP_SUMMARY" + for f in "${files[@]}"; do + mv "$f.sig" "$RELEASE_ASSETS/" + echo "- $(basename "$f") -> $(basename "$f").sig" >> "$GITHUB_STEP_SUMMARY" + done diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..87d7094 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,261 @@ +# Signed release pipeline +# +# Manual-only. Reads the version already committed in /pyproject.toml, +# creates a GPG-signed tag with Garasign, builds sdist + wheel, publishes to PyPI +# through OIDC trusted publishing (no stored token), GPG-signs the published +# files, and opens a draft GitHub Release carrying the signatures. +# +# To reuse in another repository, change the three values under `env:` and +# complete the setup in docs/release/signed-release-pipeline.md. +# +# Composite actions used (all under .github/actions/, mirrored from +# mongodb-labs/drivers-github-tools@v3): +# release-setup AWS OIDC -> ECR login -> Secrets Manager -> Garasign env +# garasign-git run `git tag -s` inside the Garasign container +# sign-artifacts detached .sig for every dist file +# create-draft-release authorized-publication record + draft release + +name: Signed release + +on: + workflow_dispatch: + inputs: + ref: + description: Branch, tag, or SHA to release. Its pyproject.toml must already carry the release version. + default: main + type: string + dry_run: + description: Dry run - build, sign, and verify, but do not push a tag, upload to PyPI, or create a release. + default: true + type: boolean + publish_to_test_pypi: + description: Also upload to TestPyPI (requires a TestPyPI trusted publisher for this workflow). + default: false + type: boolean + +# Per-repository knobs. +env: + PRODUCT_NAME: agent-framework-mongodb + PACKAGE_DIR: python + TAG_PREFIX: python-v + PYTHON_VERSION: "3.11" + +permissions: + contents: read + +concurrency: + group: signed-release-${{ inputs.ref }} + cancel-in-progress: false + +defaults: + run: + shell: bash -eux {0} + +jobs: + prepare: + name: Resolve version and create signed tag + runs-on: ubuntu-latest + environment: release + permissions: + id-token: write # AWS OIDC for Garasign credentials + contents: write # push the release tag + outputs: + version: ${{ steps.version.outputs.version }} + tag: ${{ steps.version.outputs.tag }} + sha: ${{ steps.version.outputs.sha }} + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + with: + ref: ${{ inputs.ref }} + fetch-depth: 0 + persist-credentials: true # needed for the tag push below + + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 + with: + python-version: ${{ env.PYTHON_VERSION }} + + - name: Read and validate the version in pyproject.toml + id: version + working-directory: ${{ env.PACKAGE_DIR }} + run: | + python -m pip install --disable-pip-version-check "packaging>=24,<26" + version="$(python - <<'PY' + import tomllib + from packaging.version import Version + with open("pyproject.toml", "rb") as f: + raw = tomllib.load(f)["project"]["version"] + parsed = Version(raw) + if str(parsed) != raw: + raise SystemExit(f"{raw!r} is not a canonical PEP 440 version") + print(raw) + PY + )" + { + echo "version=$version" + echo "tag=${TAG_PREFIX}${version}" + echo "sha=$(git rev-parse HEAD)" + } >> "$GITHUB_OUTPUT" + + - name: Refuse to move an existing tag + env: + TAG: ${{ steps.version.outputs.tag }} + SHA: ${{ steps.version.outputs.sha }} + run: | + if git ls-remote --exit-code --tags origin "refs/tags/$TAG" > /dev/null; then + remote="$(git ls-remote --tags origin "refs/tags/$TAG^{}" | cut -f1)" + [ -n "$remote" ] || remote="$(git ls-remote --tags origin "refs/tags/$TAG" | cut -f1)" + if [ "$remote" != "$SHA" ]; then + echo "::error::$TAG already exists at $remote, not $SHA. Bump the version instead of moving the tag." + exit 1 + fi + echo "TAG_EXISTS=true" >> "$GITHUB_ENV" + fi + + - uses: ./.github/actions/release-setup + with: + aws_role_arn: ${{ secrets.AWS_ROLE_ARN }} + aws_region: ${{ secrets.AWS_REGION }} + aws_secret_id: ${{ secrets.AWS_SECRET_ID }} + + - name: Create signed annotated tag + if: env.TAG_EXISTS != 'true' + uses: ./.github/actions/garasign-git + with: + command: >- + git tag -a "${{ steps.version.outputs.tag }}" + -m "Release ${{ env.PRODUCT_NAME }} ${{ steps.version.outputs.version }}" + -s --local-user=${{ env.GPG_KEY_ID }} + + - name: Verify the tag signature against the published public key + if: env.TAG_EXISTS != 'true' && env.GPG_PUBLIC_URL != '' + env: + TAG: ${{ steps.version.outputs.tag }} + run: | + curl -fsSL "$GPG_PUBLIC_URL" | gpg --import + git verify-tag "$TAG" + + - name: Push the tag + if: ${{ !inputs.dry_run && env.TAG_EXISTS != 'true' }} + env: + TAG: ${{ steps.version.outputs.tag }} + run: | + git push origin "refs/tags/$TAG" + echo "Pushed signed tag $TAG" >> "$GITHUB_STEP_SUMMARY" + + build: + name: Build distributions + needs: prepare + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + with: + ref: ${{ needs.prepare.outputs.sha }} + persist-credentials: false + + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 + with: + python-version: ${{ env.PYTHON_VERSION }} + + - name: Build sdist and wheel + working-directory: ${{ env.PACKAGE_DIR }} + run: | + python -m pip install --disable-pip-version-check "build>=1.2,<2" "twine>=6.1,<7" + rm -rf dist + python -m build --outdir dist + python -m twine check dist/* + + - name: Confirm every artifact carries the release version + working-directory: ${{ env.PACKAGE_DIR }} + env: + VERSION: ${{ needs.prepare.outputs.version }} + run: | + for f in dist/*; do + case "$f" in + *"$VERSION"*) ;; + *) echo "::error::$f does not carry version $VERSION"; exit 1 ;; + esac + done + + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: dist-${{ github.run_id }} + path: ${{ env.PACKAGE_DIR }}/dist/* + if-no-files-found: error + retention-days: 30 + + publish: + name: Publish to PyPI through trusted publishing + needs: [prepare, build] + runs-on: ubuntu-latest + environment: release + permissions: + id-token: write # mint the OIDC token PyPI exchanges for a short-lived upload token + contents: read + attestations: write # record build provenance + steps: + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + with: + name: dist-${{ github.run_id }} + path: dist + + - name: Publish to TestPyPI + if: inputs.publish_to_test_pypi + uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # release/v1 + with: + repository-url: https://test.pypi.org/legacy/ + packages-dir: dist + skip-existing: true + + - name: Publish to PyPI + if: ${{ !inputs.dry_run }} + uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # release/v1 + with: + packages-dir: dist + + - name: Attest build provenance + if: ${{ !inputs.dry_run }} + uses: actions/attest-build-provenance@v4 # TODO pin to a full SHA before merging (repo policy) + with: + subject-path: dist/* + + - name: Dry run summary + if: inputs.dry_run + run: echo "Dry run: skipped PyPI upload and provenance attestation" >> "$GITHUB_STEP_SUMMARY" + + sign-and-release: + name: Sign distributions and draft GitHub Release + needs: [prepare, build, publish] + runs-on: ubuntu-latest + environment: release + permissions: + id-token: write # AWS OIDC for Garasign credentials + contents: write # create the draft release + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + with: + ref: ${{ needs.prepare.outputs.sha }} + persist-credentials: false + + - uses: ./.github/actions/release-setup + with: + aws_role_arn: ${{ secrets.AWS_ROLE_ARN }} + aws_region: ${{ secrets.AWS_REGION }} + aws_secret_id: ${{ secrets.AWS_SECRET_ID }} + + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + with: + name: dist-${{ github.run_id }} + path: dist + + - uses: ./.github/actions/sign-artifacts + with: + filenames: dist/* + + - uses: ./.github/actions/create-draft-release + with: + tag: ${{ needs.prepare.outputs.tag }} + title: ${{ env.PRODUCT_NAME }} ${{ needs.prepare.outputs.version }} + product_name: ${{ env.PRODUCT_NAME }} + dist_path: dist + token: ${{ github.token }} + dry_run: ${{ inputs.dry_run }} diff --git a/docs/release/signed-release-pipeline.md b/docs/release/signed-release-pipeline.md new file mode 100644 index 0000000..e76272b --- /dev/null +++ b/docs/release/signed-release-pipeline.md @@ -0,0 +1,178 @@ +# Signed release pipeline + +`.github/workflows/release.yml` is a manual, drop-in release pipeline that +covers the three controls the ABX launch requires: + +| Control | Where it happens | +| --- | --- | +| Code signing | `prepare` creates a GPG-signed tag; `sign-and-release` writes a detached `.sig` for every distribution. Both use the Garasign container. | +| PyPI through OIDC | `publish` uses `pypa/gh-action-pypi-publish` with trusted publishing. No PyPI token exists anywhere. | +| Signatures on a draft GitHub Release | `sign-and-release` creates the release as a draft with `--verify-tag` and attaches the distributions, their `.sig` files, and `authorized-publication.txt`. | + +It is additive: `release-python.yml` (the manifest-driven coordinator) is +unchanged. Before the first real release, decide which workflow is canonical, +because a PyPI trusted publisher is bound to one workflow filename (you may +register both). + +## Files + +```text +.github/workflows/release.yml orchestrator (4 jobs) +.github/actions/release-setup/action.yml AWS OIDC -> ECR login -> Secrets Manager -> Garasign env +.github/actions/garasign-git/action.yml run a git command inside the Garasign container +.github/actions/sign-artifacts/action.yml detached, armored GPG signature per file +.github/actions/create-draft-release/action.yml authorized-publication record + draft release +``` + +The composite actions mirror `mongodb-labs/drivers-github-tools@v3` +(`setup`, `git-sign`, `gpg-sign`, `authorized-pub`, and the draft-release step +of `python/post-publish`). A repository that is comfortable depending on that +public repo can replace the four local actions with the upstream references. + +## Flow + +```text +workflow_dispatch(ref, dry_run, publish_to_test_pypi) + | + v +prepare read version from /pyproject.toml, require canonical PEP 440 + refuse if exists on another commit + release-setup -> garasign-git: git tag -a -s + verify-tag against gpg-public-url (if set) -> push tag (unless dry_run) + | + v +build checkout the exact SHA -> python -m build -> twine check -> upload dist- + | + v +publish [environment: release] download dist + pypa/gh-action-pypi-publish (OIDC) (unless dry_run) + actions/attest-build-provenance (unless dry_run) + | + v +sign-and- [environment: release] release-setup -> download dist +release sign-artifacts: dist/* -> $RELEASE_ASSETS/*.sig + create-draft-release: authorized-publication.txt + gh release create --draft --verify-tag + gh release upload *.sig dist/* authorized-publication.txt +``` + +Every job after `prepare` checks out `needs.prepare.outputs.sha`, so the tag, +the built files, the PyPI upload, and the signatures all describe one commit. + +## One-time setup + +### 1. Repository secrets + +`Settings > Secrets and variables > Actions > Secrets`: + +| Secret | Value | +| --- | --- | +| `AWS_ROLE_ARN` | IAM role that can read the release secret. Its trust policy must allow `token.actions.githubusercontent.com` with `sub` = `repo:/:environment:release`. | +| `AWS_REGION` | Region of that role and the secret, for example `us-east-1`. | +| `AWS_SECRET_ID` | Secrets Manager secret id, for example `release-secrets/agent-framework-mongodb`. | + +### 2. AWS Secrets Manager secret + +JSON keys read by `release-setup`: + +| Key | Required | Purpose | +| --- | --- | --- | +| `garasign-username` | yes | Garasign service account | +| `garasign-password` | yes | Garasign service account | +| `gpg-key-id` | yes | Key id passed to `git tag --local-user` | +| `gpg-public-url` | no | Public key URL; when set, `prepare` runs `git verify-tag` | + +`release-setup` also pulls `release-infrastructure/garasign-git` from ECR +account `901841024863` using role `ecr-role-gha-ro`. That role's trust policy +must list this repository's OIDC subject. Both the role trust updates and the +Garasign account are owned by the release-infrastructure team; request them +early. Which GPG key signs ABX artifacts (the DBX drivers key or a new one) is +a policy decision this pipeline does not make; the checklist item in +`python-release-checklist.md` stays open until it is recorded. + +### 3. GitHub environment `release` + +`Settings > Environments > New environment > release`: + +- Required reviewers: the release approvers. +- Deployment branches: `main` only (plus `develop` if you dry-run from it). +- No secrets are needed in the environment itself. + +`prepare`, `publish`, and `sign-and-release` each wait for approval. + +### 4. PyPI trusted publisher + +On pypi.org, as the account that owns (or will own) the project: +`Account settings > Publishing > Add a new pending publisher` (or +`Manage project > Publishing` once the project exists): + +| Field | Value | +| --- | --- | +| PyPI project name | `agent-framework-mongodb` | +| Owner | `mongodb-developer` | +| Repository name | `ms-agent-framework-mongodb` | +| Workflow name | `release.yml` | +| Environment name | `release` | + +Repeat on test.pypi.org if you want `publish_to_test_pypi`. + +How the OIDC exchange works at run time: + +1. The `publish` job has `id-token: write`, so the runner can request a GitHub + OIDC token whose claims name this repository, workflow file, environment, + ref, and run. +2. `pypa/gh-action-pypi-publish` requests that token with audience `pypi`, + sends it to PyPI, and PyPI checks the claims against the trusted publisher + row above. +3. PyPI answers with a short-lived (about 15 minutes), project-scoped upload + token; the action uploads the files and discards it. + +Nothing in the repository, its secrets, or the logs ever holds a PyPI +credential, so there is nothing to rotate or leak. + +## Running it + +1. Merge the version you intend to release into the ref you will dispatch + from (`python/pyproject.toml`; keep `api-baseline.json` in step as the + existing checklist requires). +2. `Actions > Signed release > Run workflow` with `dry_run: true`. This + exercises AWS, ECR, Garasign, tag signing, the build, and the signature + step without pushing, uploading, or creating anything. +3. Run again with `dry_run: false`. Approve the three environment gates. +4. Open the draft release, review the assets, add notes, publish it. + +Verify a signature locally: + +```bash +curl -fsSL "$GPG_PUBLIC_URL" | gpg --import +gpg --verify agent_framework_mongodb--py3-none-any.whl.sig \ + agent_framework_mongodb--py3-none-any.whl +sha256sum --check <(grep -A1 'Filename' authorized-publication.txt | paste - - | awk '{print $NF" "$2}') +``` + +## Porting to another repository + +Change the three values under `env:` in `release.yml`: + +```yaml +env: + PRODUCT_NAME: + PACKAGE_DIR: + TAG_PREFIX: <"v", "python-v", or ""> +``` + +Then copy the four `.github/actions/*` directories unchanged and repeat the +one-time setup. The pipeline only assumes a PEP 517 project with a static +`project.version`. + +## What was trimmed from mongo-python-driver's pipeline + +| In mongo-python-driver | Here | Why | +| --- | --- | --- | +| GitHub App checkout (`secure-checkout`) and signed version-bump commits before and after release | Removed; the version must already be committed | Avoids an App installation and a branch-protection bypass. Tags are pushed with `GITHUB_TOKEN`. | +| Nightly scheduled dry run and automatic TestPyPI upload | Manual dispatch; TestPyPI is opt-in | A new project usually has no TestPyPI publisher yet. | +| `dist.yml` cibuildwheel matrix (9 platforms, QEMU) | `python -m build` | Pure-Python wheel. | +| CodeQL job inside the release graph | Not duplicated | `codeql.yml` already runs on PRs, pushes, and weekly. | +| Kondukto SBOM augmentation, SARIF export, SSDLC compliance report, S3 upload | Not included | Those produce the release-tracking evidence bundle. Add `full-report` + `upload-s3-assets` from drivers-github-tools once the ABX S3 bucket and Kondukto project exist; the `authorized-publication.txt` record is kept because it costs nothing. | +| Evergreen project wiring | Not applicable | | +| `attest-sbom` | Not included | Depends on the Kondukto-augmented SBOM above. |