diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 841ea806..acfc4829 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,8 +1,7 @@ name: CI (unit) # pull_request only. A push trigger alongside it ran the whole suite twice on the same # SHA, and the duplicate legs raced each other against the Stream app several SDK repos -# share. main and *.x are covered by release.yml, which runs this same reusable workflow -# as the gate before tagging. +# share. A merge to main runs nothing here. on: pull_request: branches: [ "**" ] @@ -15,43 +14,105 @@ jobs: # Skipped on a Release PR: it only bumps the version and rewrites the changelog. Not # keyed on github.actor, which is the pusher: clicking Update branch reattributes the # merge commit to whoever clicked, and the skip would stop firing on the normal release - # path. A human commit pushed onto a Release PR is therefore untested until release.yml - # runs the lane on the merge commit. - unit: + # path. + # The skip also requires the diff to be only what release-please writes: the changelog, the manifest, and in each version file nothing but the version line. A hand-pushed code or dependency change, an unexpected file or a failed lookup runs the unit lane. + release-only: if: >- - ${{ !(github.event.pull_request.user.login == 'github-actions[bot]' + ${{ github.event.pull_request.user.login == 'github-actions[bot]' && github.event.pull_request.head.repo.full_name == github.repository - && startsWith(github.head_ref, 'release-please--')) }} + && startsWith(github.head_ref, 'release-please--') }} + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + contents: read + pull-requests: read + outputs: + skip: ${{ steps.files.outputs.skip }} + steps: + - id: files + env: + GH_TOKEN: ${{ github.token }} + PR: ${{ github.event.pull_request.number }} + VERSION_FILES: pyproject.toml uv.lock + run: | + export FILES="$RUNNER_TEMP/pr-files.jsonl" + if ! gh api "repos/${GITHUB_REPOSITORY}/pulls/${PR}/files" --paginate --jq '.[] | @json' > "$FILES"; then + echo "::warning::Could not list this PR's files; running the unit lane." + echo "skip=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + python3 - <<'PY' + import json, os, re + + files = [json.loads(line) for line in open(os.environ["FILES"]) if line.strip()] + version_files = set(os.environ["VERSION_FILES"].split()) + free = {"CHANGELOG.md", ".release-please-manifest.json"} + version_token = re.compile(r"v?\d+(?:\.\d+)+(?:-[0-9A-Za-z.]+)?") + + + def lines(f, sign): + return [l[1:] for l in (f.get("patch") or "").split("\n") if l.startswith(sign)] + + + def decide(): + manifest = next((f for f in files if f["filename"] == ".release-please-manifest.json"), None) + new = re.findall(r'"\.":\s*"([^"]+)"', "\n".join(lines(manifest, "+"))) if manifest else [] + if len(new) != 1: + return "the manifest diff is not a single version bump" + has_new = re.compile(r"(?- - ${{ github.event.pull_request.user.login == 'github-actions[bot]' - && github.event.pull_request.head.repo.full_name == github.repository - && startsWith(github.head_ref, 'release-please--') }} + SKIP: ${{ needs.release-only.outputs.skip }} run: | case "$RESULT" in success) echo "unit lane passed" ;; skipped) - if [ "$RELEASE_PR" = "true" ]; then + if [ "$SKIP" = "true" ]; then echo "release pr: unit lane skipped by design" else - echo "::error::the unit lane was skipped on a PR that is not a Release PR" + echo "::error::the unit lane was skipped without release-only confirming a release-please-only diff" exit 1 fi ;; diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c511f5ac..c3a95c16 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -44,11 +44,7 @@ jobs: target-branch: ${{ github.ref_name }} skip-github-release: true - # Tagging and the GitHub Release are irreversible, so the suite has to run before - # them, which means knowing a release is pending before the suite starts. The tag - # lands on the merged Release PR's merge commit while the suite runs on this - # workflow's own commit, so `ready` also requires those to be the same commit. They - # are, on the path that matters: the push of that merge. + # Tagging and the GitHub Release are irreversible, so they run only on the push that merged the Release PR: `ready` requires the pending release's merge commit to be this run's commit. A release from the default branch has no test run, because the Release PR adds only the version bump and changelog to already-tested code; a hotfix release from `N.x` runs the unit lane first, because hotfix commits are pushed without a PR. detect: name: Detect pending release if: >- @@ -123,8 +119,7 @@ jobs: echo "No pending release on ${BASE}." elif [ "$sha" != "$HEAD_SHA" ]; then # Reached when an earlier release run failed after the Release PR merged. - # Tagging $sha here would tag a tree this run never tested, and failing - # would redden every later push, so stand down and say why. + # Finishing it from a later push would retry a deterministic failure (a refused major tag, a broken build) on every push, so stand down and say why. pending=true echo "::warning::Release PR #${num} is still pending at ${sha}, which is not this run's commit ${HEAD_SHA}. Re-run the workflow run for ${sha} to finish that release." { @@ -159,17 +154,19 @@ jobs: echo "ready=${ready}" } >> "$GITHUB_OUTPUT" - test-unit: - name: Test (unit) + tests: + name: Tests (hotfix only) needs: detect - if: needs.detect.outputs.ready == 'true' + if: needs.detect.outputs.ready == 'true' && github.ref_name != github.event.repository.default_branch uses: ./.github/workflows/run_tests.yml # Irreversible half. release: name: 🚀 Tag and release - needs: [detect, test-unit] - if: needs.detect.outputs.ready == 'true' + needs: [detect, tests] + if: >- + ${{ !cancelled() && needs.detect.result == 'success' && needs.detect.outputs.ready == 'true' + && (needs.tests.result == 'success' || needs.tests.result == 'skipped') }} runs-on: ubuntu-latest timeout-minutes: 5 permissions: diff --git a/.github/workflows/run_integration.yml b/.github/workflows/run_integration.yml index 15154d4e..8526e419 100644 --- a/.github/workflows/run_integration.yml +++ b/.github/workflows/run_integration.yml @@ -16,8 +16,7 @@ env: permissions: contents: read -# The only place `-m integration` runs: daily and before a tag, never as a status check on a -# pull request. See DEVELOPMENT.md for why it gates nothing. +# The only place `-m integration` runs: daily, never as a status check on a pull request. See DEVELOPMENT.md for why it gates nothing. jobs: integration-non-video: name: 🧪 Non-video integration (${{ matrix.python-version }}) diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index aa40b09c..8362b5f3 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -44,7 +44,7 @@ make test-prometheus # requires getstream[telemetry] deps | --- | --- | --- | | Pull request | `run_tests.yml`: ruff, ty, and `-m "not integration"` on five Python versions | yes, `🧪 Tests` is the required check | | Daily at 09:00 UTC | `run_integration.yml`: `-m integration`, both credential sets | no | -| Push to `main` with a release pending | the unit lane | yes, it gates the tag | +| Release PR merged | nothing on the default branch, the unit lane on `N.x` | `N.x` only | `@pytest.mark.integration` means one thing: the test talks to a live Stream app. The unit lane therefore runs with no credentials, no `environment:` and no `STREAM_*`. Keep it that @@ -56,8 +56,7 @@ another repo's run or a backend regression can redden it with nothing wrong here run before a tag either. A red daily run opens an issue titled "Daily integration run is red". Fix it, do not route around it. -A Release PR skips the lane and `🧪 Tests` passes in seconds on a `skipped` result. The -merge commit still runs it before the tag. +A Release PR skips the lane and `🧪 Tests` passes in seconds on a `skipped` result. The skip only applies while the diff is nothing but what release-please writes, down to the version line in each version file, so a code or dependency change pushed onto a Release PR by hand runs the unit lane like any other PR. Merging it tags and publishes with no further test run: it adds only the version bump and changelog to an already-tested `main`. A hotfix release from `N.x` runs the unit lane first, since its commits were pushed without a PR. ### Linting and type checking diff --git a/README.md b/README.md index 51a38acf..71f8765e 100644 --- a/README.md +++ b/README.md @@ -268,17 +268,11 @@ Releases are driven by [release-please](https://github.com/googleapis/release-pl - release-please keeps a Release PR open with the version bump in `pyproject.toml`, `uv.lock` and `CHANGELOG.md`. It is opened by `github-actions[bot]`, so approve it and run its held checks like any other PR. -- Merging the Release PR runs lint, type-check and the unit and integration matrix on - that merge commit, which is the commit the tag will point at. Only if that is green - does the workflow create the tag and the GitHub Release and publish to PyPI via - Trusted Publishing (OIDC). The order matters: a tag and a GitHub Release cannot be - withdrawn, a failed publish can be retried. If the commit waiting to be tagged is not - the one this run tested, the workflow stands down instead of tagging it, and says so in - the run summary. +- Merging the Release PR creates the tag and the GitHub Release on that merge commit and publishes to PyPI via Trusted Publishing (OIDC), with no further test run: the Release PR adds only the version bump and changelog to an already-tested `main`. A hotfix release from `N.x` runs the unit lane first, since its commits were pushed without a PR. A tag and a GitHub Release cannot be withdrawn, a failed publish can be retried. If the commit waiting to be tagged is not this run's commit, the workflow stands down instead of tagging it, and says so in the run summary. While a merged Release PR is waiting to be tagged, no new Release PR is opened or -refreshed, so that release-please has a release commit to stop its walk at. If the suite -failed on that merge commit, use "Re-run failed jobs" on its `Release` run. If the commit +refreshed, so that release-please has a release commit to stop its walk at. If the release +job failed on that merge commit, use "Re-run failed jobs" on its `Release` run. If the commit is genuinely broken, remove the `autorelease: pending` label from the merged Release PR by hand, then release forward; nothing clears that state automatically.