Skip to content
Merged
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
95 changes: 78 additions & 17 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -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: [ "**" ]
Expand All @@ -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"(?<![\w.])v?" + re.escape(new[0]) + r"(?![\w.])")
for f in files:
name = f["filename"]
if name in free:
continue
if name not in version_files:
return f"{name} is not a file release-please writes"
if f.get("patch") is None:
return f"GitHub returned no diff for {name}"
added, removed = lines(f, "+"), lines(f, "-")
stray = next((l for l in added if not has_new.search(l)), None)
if stray is not None:
return f"{name} adds a line without the new version: {stray.strip()[:120]}"
# Masking versions, what was removed must be exactly what was added back.
if sorted(version_token.sub("V", l) for l in removed) != sorted(version_token.sub("V", l) for l in added):
return f"{name} changes more than its version line"
return None


reason = decide()
with open(os.environ.get("GITHUB_OUTPUT", "/dev/stdout"), "a") as out:
out.write(f"skip={'false' if reason else 'true'}\n")
if reason:
print(f"::notice::Running the unit lane: {reason}")
PY

unit:
needs: release-only
if: ${{ !cancelled() && needs.release-only.outputs.skip != 'true' }}
uses: ./.github/workflows/run_tests.yml

# The one required status check on main, and it carries no matrix on purpose: a matrix job
# skipped by `if:` publishes a single check run with the template unexpanded, so per-leg
# contexts could never be satisfied on a Release PR. `skipped` is accepted only when the
# condition above holds, repeated here because Actions cannot share an expression; if the
# two drift this fails, which is the safe direction.
# contexts could never be satisfied on a Release PR. `skipped` is accepted only when release-only confirmed a release-please-only diff.
tests-passed:
name: 🧪 Tests
needs: unit
needs: [release-only, unit]
if: ${{ !cancelled() }}
runs-on: ubuntu-latest
steps:
- name: Check the unit lane
env:
RESULT: ${{ needs.unit.result }}
RELEASE_PR: >-
${{ 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
;;
Expand Down
21 changes: 9 additions & 12 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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: >-
Expand Down Expand Up @@ -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."
{
Expand Down Expand Up @@ -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:
Expand Down
3 changes: 1 addition & 2 deletions .github/workflows/run_integration.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }})
Expand Down
5 changes: 2 additions & 3 deletions DEVELOPMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand Down
12 changes: 3 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
Loading