From 218648293ed79817e86e8a0b1560c437ce6eb060 Mon Sep 17 00:00:00 2001 From: Manasjyoti Sharma Date: Wed, 1 Jul 2026 12:07:16 +0530 Subject: [PATCH] Support PEP 440 SDK release gate and trusted publishing --- .github/workflows/on-rc-tag.yml | 114 ++++++----------- .github/workflows/publish-release.yml | 176 ++++++++++++-------------- 2 files changed, 117 insertions(+), 173 deletions(-) diff --git a/.github/workflows/on-rc-tag.yml b/.github/workflows/on-rc-tag.yml index 2f44afa..3ed7a3c 100644 --- a/.github/workflows/on-rc-tag.yml +++ b/.github/workflows/on-rc-tag.yml @@ -1,109 +1,67 @@ -name: SDK rc tag — dispatch Tier 2 +name: SDK release tag gate -# Triggered by: -# 1. Pushing an rc tag matching v-*-rc.* on this repo. -# 2. workflow_dispatch with the rc_tag input (lets you re-run the gate -# without cutting a new tag). -# -# Body: -# 1. Read the backend release target manifest via the GitHub API. -# 2. Pre-flight: dev.image_tag must equal prod.image_tag. -# If they differ, FAIL FAST with an actionable error message. -# 3. If they match (or prod is in `bootstrap` mode), dispatch the SDK rc -# validation event with rc_tag in the payload. +# Dispatches the private SDK release gate for PEP 440 rc and final tags. +# The private receiver treats the payload as informational, resolves the tag +# against FortifyRoot/ocelle-py, and performs backend target preflight before +# it fetches live-test secrets. on: push: tags: - - 'v-*-rc.*' + - 'v*rc*' + - 'v[0-9]*.[0-9]*.[0-9]*' workflow_dispatch: inputs: + release_tag: + description: 'SDK release tag to validate (e.g. v1.0.0rc1 or v1.0.0)' + required: false rc_tag: - description: 'rc tag to validate (e.g. v-1.2.0-rc.1)' - required: true + description: 'Deprecated alias for release_tag' + required: false permissions: contents: read concurrency: - group: sdk-rc-${{ github.event.inputs.rc_tag || github.ref_name }} + group: sdk-release-gate-${{ inputs.release_tag || inputs.rc_tag || github.ref_name }} cancel-in-progress: false jobs: - preflight-and-dispatch: - name: Preflight (dev==prod) and dispatch Tier 2 + validate-and-dispatch: + name: Validate tag and dispatch release gate runs-on: ubuntu-latest timeout-minutes: 10 steps: - - name: Resolve rc tag + - name: Resolve release tag id: vars run: | set -euo pipefail if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then - RC_TAG="${{ inputs.rc_tag }}" + RELEASE_TAG="${{ inputs.release_tag || inputs.rc_tag }}" else - RC_TAG="${{ github.ref_name }}" + RELEASE_TAG="${{ github.ref_name }}" fi - if [[ ! "$RC_TAG" =~ ^v-.*-rc\..*$ ]]; then - echo "::error::Refusing to run on non-rc tag '$RC_TAG' (expected v-X.Y.Z-rc.N)" + if [[ -z "$RELEASE_TAG" ]]; then + echo "::error::release_tag is required." exit 1 fi - echo "rc_tag=$RC_TAG" >> "$GITHUB_OUTPUT" - - - name: Read backend release target manifest - id: targets - env: - GH_TOKEN: ${{ secrets.BACKEND_TARGETS_READ_TOKEN }} - run: | - set -euo pipefail - if [[ -z "${GH_TOKEN:-}" ]]; then - echo "::error::Repository secret BACKEND_TARGETS_READ_TOKEN is not configured." - exit 1 - fi - # Read from `main` because that is the release target source of truth. - if ! gh api --method GET repos/FortifyRoot/fr-backend/contents/env/targets.yaml \ - -f ref=main --jq '.content' | base64 -d > targets.yaml; then - echo "::error::Could not fetch the backend release target manifest from main." - exit 1 - fi - # `yq` (mikefarah/yq) is preinstalled on GitHub-hosted runners. - # Guard so the failure mode is loud if a future runner image drops it. - command -v yq >/dev/null || { echo "::error::yq is required but not installed on this runner"; exit 1; } - DEV="$(yq -r '.dev.image_tag' targets.yaml)" - PROD="$(yq -r '.prod.image_tag' targets.yaml)" - echo "dev=$DEV" - echo "prod=$PROD" - echo "dev_tag=$DEV" >> "$GITHUB_OUTPUT" - echo "prod_tag=$PROD" >> "$GITHUB_OUTPUT" - - - name: Pre-flight (dev == prod, or prod is bootstrap) - env: - DEV: ${{ steps.targets.outputs.dev_tag }} - PROD: ${{ steps.targets.outputs.prod_tag }} - run: | - set -euo pipefail - if [[ "$PROD" == "bootstrap" ]]; then - echo "Bootstrap mode — prod has no deployed image yet. Proceeding." - exit 0 - fi - if [[ "$DEV" != "$PROD" ]]; then - cat <> "$GITHUB_OUTPUT" + echo "tag_kind=$TAG_KIND" >> "$GITHUB_OUTPUT" - - name: Dispatch SDK rc validation + - name: Dispatch SDK release validation env: GH_TOKEN: ${{ secrets.SYSTEM_TEST_DISPATCH_TOKEN }} - RC_TAG: ${{ steps.vars.outputs.rc_tag }} + RELEASE_TAG: ${{ steps.vars.outputs.release_tag }} + TAG_KIND: ${{ steps.vars.outputs.tag_kind }} run: | set -euo pipefail if [[ -z "${GH_TOKEN:-}" ]]; then @@ -113,6 +71,8 @@ jobs: gh api -X POST \ repos/FortifyRoot/fr-system-tests/dispatches \ -f event_type=sdk_rc \ - -F "client_payload[rc_tag]=${RC_TAG}" \ - -F "client_payload[repo]=${{ github.repository }}" - echo "SDK rc validation dispatched." + -F "client_payload[release_tag]=${RELEASE_TAG}" \ + -F "client_payload[rc_tag]=${RELEASE_TAG}" \ + -F "client_payload[tag_kind]=${TAG_KIND}" \ + -F "client_payload[repo]=FortifyRoot/ocelle-py" + echo "SDK release validation dispatched for ${RELEASE_TAG} (${TAG_KIND})." diff --git a/.github/workflows/publish-release.yml b/.github/workflows/publish-release.yml index 07a9bc9..79698ef 100644 --- a/.github/workflows/publish-release.yml +++ b/.github/workflows/publish-release.yml @@ -1,29 +1,22 @@ name: Publish to PyPI -# Manual workflow_dispatch only. -# -# Body (in safe order — no user-supplied code runs until ALL pre-flight -# checks pass): -# 1. (T1.2) Validate rc_tag matches the strict regex. -# 2. (T1.1) Resolve rc_tag → commit SHA via gh api WITHOUT checkout. -# 3. (T1.1) Verify Tier-2 commit-status is green on that SHA. -# 4. ONLY NOW: actions/checkout the validated ref. -# 5. (T1.3) Pin pip-installed build deps to known-good versions. -# 6. python -m build (runs the package's build hooks — first point at -# which user-controlled code executes on the runner). -# 7. Assume AWS role via OIDC, fetch PyPI token from AWS SM. -# 8. twine upload. +# Manual workflow_dispatch only. The job validates the tag, checks the +# SDK release-gate status on the resolved SHA, then checks out that SHA +# and publishes with PyPI Trusted Publishing. on: workflow_dispatch: inputs: + release_tag: + description: 'SDK release tag that passed the gate (e.g. v1.0.0rc1 or v1.0.0)' + required: false rc_tag: - description: 'rc tag that already passed Tier 2 (e.g. v-1.2.0-rc.1)' - required: true + description: 'Deprecated alias for release_tag' + required: false pypi_repository: - description: 'PyPI repository (pypi or testpypi)' + description: 'PyPI repository (testpypi for rc, pypi for final)' required: false - default: 'pypi' + default: 'testpypi' permissions: id-token: write @@ -31,57 +24,69 @@ permissions: jobs: publish: - name: Publish ${{ inputs.rc_tag }} to ${{ inputs.pypi_repository }} + name: Publish ${{ inputs.release_tag || inputs.rc_tag }} to ${{ inputs.pypi_repository }} runs-on: ubuntu-latest timeout-minutes: 30 - # Requires the configured PyPI release environment approval. - environment: pypi + environment: + name: ${{ inputs.pypi_repository == 'testpypi' && 'testpypi' || 'pypi' }} steps: - # --------------------------------------------------------------- - # T1.2 — Validate the rc_tag input matches a strict pattern. - # Fails fast on typos / accidental misuse before anything else - # runs. Does not stop a determined insider but cuts off a class - # of accidental misuse and adds a defense-in-depth signal. - # --------------------------------------------------------------- - - name: Validate rc_tag pattern (T1.2) + - name: Validate release tag and target repository + id: tag env: - RC_TAG: ${{ inputs.rc_tag }} + RELEASE_TAG: ${{ inputs.release_tag || inputs.rc_tag }} + PYPI_REPOSITORY: ${{ inputs.pypi_repository }} run: | set -euo pipefail - if [[ ! "$RC_TAG" =~ ^v-[0-9]+\.[0-9]+\.[0-9]+-rc\.[0-9]+$ ]]; then - echo "::error::rc_tag '$RC_TAG' does not match the required pattern." - echo "::error::Expected: v-..-rc. (e.g. v-1.2.0-rc.1)" + if [[ -z "$RELEASE_TAG" ]]; then + echo "::error::release_tag is required." + exit 1 + fi + case "$PYPI_REPOSITORY" in + pypi|testpypi) ;; + *) + echo "::error::pypi_repository must be 'pypi' or 'testpypi'." + exit 1 + ;; + esac + if [[ "$RELEASE_TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+rc[0-9]+$ ]]; then + TAG_KIND="rc" + elif [[ "$RELEASE_TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + TAG_KIND="final" + else + echo "::error::Tag '$RELEASE_TAG' must be vX.Y.ZrcN or vX.Y.Z." exit 1 fi - echo "rc_tag pattern OK: $RC_TAG" + if [[ "$TAG_KIND" == "rc" && "$PYPI_REPOSITORY" != "testpypi" ]]; then + echo "::error::RC tags may only publish to TestPyPI." + exit 1 + fi + if [[ "$TAG_KIND" == "final" && "$PYPI_REPOSITORY" != "pypi" ]]; then + echo "::error::Final tags may only publish to PyPI." + exit 1 + fi + VERSION="${RELEASE_TAG#v}" + { + echo "release_tag=$RELEASE_TAG" + echo "tag_kind=$TAG_KIND" + echo "version=$VERSION" + } >> "$GITHUB_OUTPUT" - # --------------------------------------------------------------- - # T1.1 (a) — Resolve rc_tag → commit SHA WITHOUT checkout. - # We use the GitHub API directly so no user-supplied code lands on - # the runner before the Tier-2 status gate has been evaluated. - # /commits/ resolves both lightweight and annotated tags to - # the underlying commit SHA. - # --------------------------------------------------------------- - - name: Resolve rc_tag to commit SHA (T1.1a) + - name: Resolve release tag to commit SHA id: resolve env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - RC_TAG: ${{ inputs.rc_tag }} + RELEASE_TAG: ${{ steps.tag.outputs.release_tag }} run: | set -euo pipefail - SHA="$(gh api "repos/${{ github.repository }}/commits/${RC_TAG}" --jq '.sha' 2>/dev/null || echo "")" + SHA="$(gh api "repos/${{ github.repository }}/commits/${RELEASE_TAG}" --jq '.sha' 2>/dev/null || echo "")" if [[ -z "$SHA" ]]; then - echo "::error::Could not resolve commit SHA for rc tag '$RC_TAG' (does it exist?)." + echo "::error::Could not resolve commit SHA for tag '$RELEASE_TAG'." exit 1 fi - echo "rc_tag $RC_TAG → SHA $SHA" + echo "release tag $RELEASE_TAG resolves to SHA $SHA" echo "sha=$SHA" >> "$GITHUB_OUTPUT" - # --------------------------------------------------------------- - # T1.1 (b) — Verify Tier-2 commit-status is green on that SHA, - # BEFORE checking out any code from the rc_tag ref. - # --------------------------------------------------------------- - - name: Validate Tier-2 commit-status (T1.1b — pre-checkout) + - name: Validate SDK release-gate commit status env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} SHA: ${{ steps.resolve.outputs.sha }} @@ -91,18 +96,12 @@ jobs: state="$(gh api "repos/${{ github.repository }}/commits/${SHA}/statuses" \ --jq "[.[] | select(.context==\"$context\")] | sort_by(.updated_at) | last | .state // empty")" if [[ "$state" != "success" ]]; then - echo "::error::No green Tier 2 status on ${SHA} (got: '$state')." - echo "::error::Re-run on-rc-tag.yml or wait for it to finish first." + echo "::error::No green SDK release-gate status on ${SHA} (got: '${state}')." + echo "::error::Run the SDK release tag gate and wait for it to finish first." exit 1 fi - echo "Tier 2 status: success on $SHA" + echo "SDK release-gate status is success on $SHA" - # --------------------------------------------------------------- - # All gates passed. NOW we can check out the rc_tag's code. - # We use the resolved SHA (not the symbolic tag) so a force-push - # to the tag between the gate check and the checkout cannot - # swap in different code (TOCTOU defense). - # --------------------------------------------------------------- - uses: actions/checkout@v4 with: ref: ${{ steps.resolve.outputs.sha }} @@ -111,50 +110,35 @@ jobs: with: python-version: '3.12' - # --------------------------------------------------------------- - # T1.3 — Pin Python build deps. Removes the "compromised dep on - # PyPI" attack path. Bump deliberately when a new minor lands. - # Also pin pip itself for reproducible resolver behaviour. - # --------------------------------------------------------------- - - name: Install pinned build deps (T1.3) + - name: Validate pyproject version matches tag + env: + EXPECTED_VERSION: ${{ steps.tag.outputs.version }} + run: | + set -euo pipefail + actual="$(python -c 'import pathlib, tomllib; data = tomllib.loads(pathlib.Path("pyproject.toml").read_text()); print(data["tool"]["poetry"]["version"])')" + if [[ "$actual" != "$EXPECTED_VERSION" ]]; then + echo "::error::pyproject.toml version '$actual' does not match tag version '$EXPECTED_VERSION'." + exit 1 + fi + echo "pyproject.toml version matches tag: $actual" + + - name: Install pinned build deps run: | set -euo pipefail python -m pip install --upgrade 'pip==24.3.1' - pip install --no-deps 'build==1.2.2.post1' 'twine==6.1.0' - # Resolve the (pinned) transitive deps in a separate, deps-only step - # — keeps the top-level pin authoritative. - pip install 'build==1.2.2.post1' 'twine==6.1.0' + pip install 'build==1.2.2.post1' - name: Build distributions run: python -m build - - name: Assume AWS role via OIDC for PyPI token - uses: aws-actions/configure-aws-credentials@v4 - with: - role-to-assume: arn:aws:iam::${{ vars.AWS_ACCOUNT_ID }}:role/fortifyroot-sdk-publish-oidc - aws-region: ${{ vars.AWS_REGION || 'us-east-1' }} - - - name: Fetch PyPI token from AWS SM - uses: aws-actions/aws-secretsmanager-get-secrets@v2 + - name: Publish accepted rc to TestPyPI + if: inputs.pypi_repository == 'testpypi' + # pypa/gh-action-pypi-publish release/v1 resolved 2026-06-30. + uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b with: - secret-ids: | - PYPI,fr/release/pypi-token - parse-json-secrets: true + repository-url: https://test.pypi.org/legacy/ - - name: twine upload - env: - TWINE_USERNAME: __token__ - # The aws-secretsmanager-get-secrets action with `parse-json-secrets: - # true` and alias `PYPI,fr/release/pypi-token` exports each JSON key - # of the secret as `PYPI_` in $GITHUB_ENV. So the secret - # JSON `{"PYPI_TOKEN": "..."}` becomes the env var `PYPI_PYPI_TOKEN` - # (same alias-prefix pattern used for `CLERK_CLERK_SECRET_KEY` - # elsewhere). Forward at shell level to keep static linters quiet. - run: | - set -euo pipefail - export TWINE_PASSWORD="$PYPI_PYPI_TOKEN" - if [[ "${{ inputs.pypi_repository }}" == "testpypi" ]]; then - twine upload --repository testpypi dist/* - else - twine upload dist/* - fi + - name: Publish final release to PyPI + if: inputs.pypi_repository != 'testpypi' + # pypa/gh-action-pypi-publish release/v1 resolved 2026-06-30. + uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b