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
133 changes: 133 additions & 0 deletions .claude/skills/release/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
---
name: release
description: >
Automates the pipelex-sdk-python release workflow: bumps the version in pyproject.toml, finalizes the CHANGELOG.md Unreleased section, runs quality checks, regenerates uv.lock, creates a release/vX.Y.Z branch, commits, pushes, and opens a PR to main. Use when user says "release", "cut a release", "bump version", "prepare a release", "make a release", "ship it", "create release branch", or any variation of shipping a new version of the pipelex-sdk Python package. The user can optionally provide changelog content inline when invoking the skill (e.g. "/release Added the storage routes"), which will be used as the changelog entry for this version.
---

# pipelex-sdk-python Release Workflow

This skill handles the full release cycle for the `pipelex-sdk` Python package (import package `pipelex_sdk`, the `pipelex-sdk-python` repo). A release is a `release/vX.Y.Z` branch that PRs into `main`; merging to `main` triggers `publish.yml`, which builds the wheel, publishes it to PyPI as `pipelex-sdk` via Trusted Publishing (OIDC, no token), and creates a Sigstore-signed GitHub release from the changelog notes.

## Files touched

- **`pyproject.toml`** — the `version` field (line 3, under `[project]`)
- **`CHANGELOG.md`** — add `## [vX.Y.Z] - YYYY-MM-DD` entry (convert the `## [Unreleased]` section if present)
- **`uv.lock`** — regenerated via `make li` (lock + install)

## Workflow

### 1. Pre-flight checks

- Read the current version from `pyproject.toml`.
- Read `CHANGELOG.md` to understand the current state (this repo keeps a `## [Unreleased]` section at the top).
- Run `git status` and `git log origin/main..HEAD` to assess the working tree:
- If there are **uncommitted changes** (staged or unstaged), warn the user and ask whether to commit them as part of the release, stash them, or abort.
- If there are **unpushed commits** on the current branch, list them so the user is aware — these will be included in the release branch.

### 2. Determine the bump type

Ask the user which kind of version bump they want — **patch**, **minor**, or **major** — unless they already specified it. Show the current version and what the new version would be for each option so the choice is concrete.

While the package is pre-1.0 (`0.y.z`), treat the `0.MINOR.PATCH` segments the way the project has been using them: a breaking change bumps the minor, a backward-compatible feature or fix bumps the patch. If the changelog for this release contains a `### Breaking Changes` section (or otherwise describes a breaking change), steer the user toward at least a minor bump — this matches the repo's "pre-1.0 breaking changes → minor version bump" rule.

### 3. Run quality checks

Run `make agent-check`. This is the gate — if it fails, stop and report the errors so they can be fixed before retrying. Do not proceed past this step on failure.

### 4. Ensure we're on the right branch

The release branch must be named `release/vX.Y.Z` where X.Y.Z is the **new** version. The CI guards in this repo are strict about this:

- `guard-branches.yml` (`gate-main`) rejects any source branch other than `release/vX.Y.Z` merging into `main`.
- `version-check.yml` rejects a mismatch between the branch name and the `pyproject.toml` version.

Both guards match the **exact** regex `release/v[0-9]+\.[0-9]+\.[0-9]+` (strict three-segment semver, no suffix). All file modifications (changelog, version bump, lock) must happen on this branch.

- If already on `release/vX.Y.Z` matching the new version, stay on it.
- If on `dev`, `main`, or any other branch, create and switch to `release/vX.Y.Z` from the current HEAD.
- If on a `release/` branch for a **different** version, warn the user and ask how to proceed.

### 5. Finalize the changelog

Add a new version entry for the release. This repo uses the workspace-wide `## [vX.Y.Z]` header convention (the changelog and publish workflows key off it).

1. If there is an `## [Unreleased]` section, **convert it**: remove the `## [Unreleased]` heading (and any blank lines that immediately follow it) and replace it with the new `## [vX.Y.Z] - YYYY-MM-DD` heading. Any content that was under `[Unreleased]` becomes the content of the new version.
2. If there is no `[Unreleased]` section, insert the new version heading directly after the `# Changelog` intro block.
3. **Never recreate an `[Unreleased]` heading.** After a release the changelog should contain only concrete version entries — the next change adds a fresh `## [Unreleased]` section organically when someone starts the next cycle.
4. If the user provided changelog content when invoking the skill (e.g. `/release Added the storage routes`), **merge** that content with any existing `[Unreleased]` content (do not discard either source). Format the combined content under the appropriate headings — this repo uses `### Breaking Changes`, `### Added`, `### Changed`, `### Fixed`, `### Removed` — inferring headings from the content when possible.
5. If the release has no changelog content yet (neither from an `[Unreleased]` section nor from inline user input), ask the user what to include before proceeding.
6. The result should look like:

```markdown
# Changelog

All notable changes to `pipelex-sdk` are documented here. ...

## [vX.Y.Z] - YYYY-MM-DD

### Changed
- ...

## [vPREVIOUS] - PREVIOUS-DATE
...
```

### 6. Bump the version in pyproject.toml

Edit `pyproject.toml` line 3 (`version = "..."` under `[project]`) to the new version string. Only change the version field — don't touch anything else.

### 7. Lock dependencies

Run `make li` to regenerate `uv.lock` and reinstall. This ensures the lockfile reflects the new version in `pyproject.toml`. The `package-check.yml` CI job runs `uv lock --locked` and fails the PR if `uv.lock` is out of sync, so this step is not optional. If it fails, stop and report the error.

### 8. Commit and push

Stage all release-related changes. This includes at minimum `pyproject.toml`, `CHANGELOG.md`, and `uv.lock`, plus any other files the user chose to include in step 1 (e.g. previously uncommitted work that belongs in this release).

Commit with the message:

```
Release vX.Y.Z
```

Push the branch to origin with `-u` to set up tracking.

### 9. Open a PR

Create a pull request targeting `main` with:

- **Title:** `Release vX.Y.Z`
- **Body:** Include:
- The changelog entries for this version (copied from CHANGELOG.md)
- A note about the version bump from old to new

Use this format for the PR body:

```markdown
## Release vX.Y.Z

Bumps version from `A.B.C` to `X.Y.Z`.

### Changelog

<paste the changelog entries for this version here>
```

Report the PR URL back to the user, and remind them that **merging the PR into `main` is what publishes** — `publish.yml` builds the wheel, pushes it to PyPI as `pipelex-sdk` (Trusted Publishing), and cuts the Sigstore-signed GitHub release automatically. Nothing publishes until the PR is merged.

## Important details

- The version follows semver: `MAJOR.MINOR.PATCH`.
- Always confirm the bump type with the user before making changes.
- If `make agent-check` fails, the release is blocked — help the user fix the issues rather than skipping the checks.
- The CI gates a `release/vX.Y.Z` → `main` PR with:
- `version-check.yml` — the `pyproject.toml` version must match the `release/vX.Y.Z` branch name.
- `changelog-check.yml` — `CHANGELOG.md` must contain a `## [vX.Y.Z] -` entry for the new version.
- `package-check.yml` — `uv.lock` must be in sync with `pyproject.toml` (`uv lock --locked`).
- `tests-check.yml` — the test matrix must pass on every supported Python version (3.10 through 3.14).
- `lint-check.yml` — ruff format, ruff lint, pyright, and mypy merge checks across the same Python matrix (the same gates as `make agent-check`).
- `guard-branches.yml` — only `release/vX.Y.Z` branches may target `main`.
- `cla.yml` — the PR author must have signed the Pipelex CLA (maintainers are allow-listed; an external first-time author will be prompted to sign before the PR can merge).
- All checks must pass for the PR to be mergeable, so getting the changelog, version, and lockfile right is critical.
- **Pre-release versions are not supported through this flow.** Unlike `mthds-python`, this repo's `guard-branches.yml` (`gate-main`) and `version-check.yml` both match the exact regex `release/v[0-9]+\.[0-9]+\.[0-9]+` — a PEP 440 suffix (`a`/`b`/`rc`, e.g. `0.2.0rc1`) on a `release/v0.2.0rc1` branch would be **rejected** by the branch guard even though `publish.yml` can detect pre-releases. Stick to strict three-segment versions for the `release/vX.Y.Z` → `main` flow; raise it with the user if they ask for a pre-release.
- Today's date for the changelog entry: use the current date in `YYYY-MM-DD` format.
32 changes: 32 additions & 0 deletions .github/workflows/changelog-check.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
name: Changelog Version Check

on:
pull_request:
branches:
- main
types: [opened, synchronize, reopened]

jobs:
check-changelog:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- name: Check Changelog Version
run: |
# Get version from pyproject.toml
VERSION=$(grep -m 1 'version = ' pyproject.toml | cut -d '"' -f 2)
echo "Version from pyproject.toml: $VERSION"

# Look for the version in the changelog
if ! grep -q "## \[v$VERSION\] -" CHANGELOG.md; then
echo "❌ Error: No changelog entry found for version v$VERSION"
echo ""
echo "The following versions are in the changelog:"
grep -E "^## \[v[0-9]+" CHANGELOG.md | head -10
echo ""
echo "Please add a changelog entry: ## [v$VERSION] - YYYY-MM-DD"
exit 1
else
echo "✅ Changelog entry found for version v$VERSION"
fi
42 changes: 42 additions & 0 deletions .github/workflows/cla.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
name: "CLA Assistant bot"

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2: Pin third-party actions that receive secrets to full-length commit SHAs instead of mutable tags to prevent tag-retargeting or compromised-release takeover.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At .github/workflows/cla.yml, line 20:

<comment>Pin third-party actions that receive secrets to full-length commit SHAs instead of mutable tags to prevent tag-retargeting or compromised-release takeover.</comment>

<file context>
@@ -0,0 +1,42 @@
+    steps:
+      - name: Get GitHub App token
+        id: app-token
+        uses: actions/create-github-app-token@v3
+        with:
+          app-id: ${{ secrets.CLA_GH_APP_ID }}
</file context>

on:
issue_comment:
types: [created]
pull_request_target:
types: [opened, closed, synchronize]

permissions:
actions: write
contents: read
pull-requests: write
statuses: write

jobs:
CLAAssistant:
runs-on: ubuntu-latest
steps:
- name: Get GitHub App token
id: app-token
uses: actions/create-github-app-token@v3
with:
app-id: ${{ secrets.CLA_GH_APP_ID }}
private-key: ${{ secrets.CLA_GH_APP_PRIVATE_KEY }}
owner: Pipelex
repositories: |
cla-signatures
pipelex-sdk-python

- name: "CLA Assistant"
if: (github.event.comment.body == 'recheck' || github.event.comment.body == 'I have read the CLA Document and I hereby sign the CLA') || github.event_name == 'pull_request_target'
uses: contributor-assistant/github-action@v2.6.1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PERSONAL_ACCESS_TOKEN: ${{ steps.app-token.outputs.token }}
with:
path-to-signatures: "signatures/version1/cla.json"
path-to-document: "https://github.com/Pipelex/pipelex-sdk-python/blob/main/CLA.md"
branch: main
allowlist: lchoquel,thomashebrard,bot*
remote-organization-name: Pipelex
remote-repository-name: cla-signatures
signed-commit-message: "$contributorName has signed the CLA in $owner/$repo#$pullRequestNo"
100 changes: 100 additions & 0 deletions .github/workflows/guard-branches.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
name: Guard branch flow
on:
pull_request_target:
types: [opened, edited, synchronize, reopened]

jobs:
# ───────────────────────────────────────────────────────────────
# 1) Only release/vX.Y.Z → main
# ───────────────────────────────────────────────────────────────
gate-main:
if: github.event.pull_request.base.ref == 'main'
runs-on: ubuntu-latest
steps:
- name: Verify source branch is a Release
env:
HEAD: ${{ github.event.pull_request.head.ref }}
run: |
echo "PR → main from $HEAD"
if [[ ! "$HEAD" =~ ^release\/v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "::error::Only release/vX.Y.Z branches may merge into main."
exit 1
fi

# ───────────────────────────────────────────────────────────────
# 2) Only work-branches → release/vX.Y.Z, pre-release/vX.Y.Z..., or dev
# ───────────────────────────────────────────────────────────────
gate-release:
if: startsWith(github.event.pull_request.base.ref, 'release/v') || startsWith(github.event.pull_request.base.ref, 'pre-release/v') || github.event.pull_request.base.ref == 'dev'
runs-on: ubuntu-latest
steps:
- name: Verify source branch uses allowed prefix
env:
HEAD: ${{ github.event.pull_request.head.ref }}
run: |
echo "PR → ${{ github.event.pull_request.base.ref }} from $HEAD"
if [[ "$HEAD" == "dev" ]]; then
exit 0
fi
if [[ ! "$HEAD" =~ ^(fix|feature|refactor|chore|docs|ci-cd|changelog|codex)\/[A-Za-z0-9._\/\<\>\=\-]+$ ]]; then
echo "::error::Branch must start with fix/, feature/, refactor/, chore/, docs/, or ci-cd/."
exit 1
fi

# ───────────────────────────────────────────────────────────────
# 3) Prevent forks from editing your workflows
# ───────────────────────────────────────────────────────────────
protect-workflows:
runs-on: ubuntu-latest
# Least privilege: querying the author's permission and diffing the head only needs read.
permissions:
contents: read
steps:
# Trust must hinge on the author's EFFECTIVE repository permission, not author_association.
# author_association is a social label: an org MEMBER or a COLLABORATOR can hold read-only
# access, so an association allow-list would let a read-only insider's workflow edits slip
# past this guard. Resolve the real permission and treat only write/maintain/admin as trusted.
- name: Resolve author repository permission
id: perm
uses: actions/github-script@v7
with:
script: |
const username = context.payload.pull_request.user.login;
let data = { permission: 'none', role_name: 'none' };
try {
({ data } = await github.rest.repos.getCollaboratorPermissionLevel({
owner: context.repo.owner,
repo: context.repo.repo,
username,
}));
} catch (error) {
// A 404 means the author is not a resolvable collaborator (deleted/renamed
// account, or no access) — treat as untrusted and let the workflow-diff
// check run. Re-throw anything else so a transient API failure fails closed.
if (error.status !== 404) throw error;
}
// The legacy `permission` field collapses roles: admin → "admin",
// maintain & write → "write", triage & read → "read", none → "none".
const trusted = data.permission === 'admin' || data.permission === 'write';
core.info(`Author ${username}: permission=${data.permission} role=${data.role_name} trusted=${trusted}`);
core.setOutput('trusted', trusted ? 'true' : 'false');

- name: Checkout code
if: steps.perm.outputs.trusted != 'true'
uses: actions/checkout@v4
with:
# In pull_request_target the default checkout is the BASE branch; without this the
# diff below would compare base-against-base and never see the fork's changes. We
# only fetch/diff/grep here — the untrusted head is never executed — so checking
# out the PR head SHA is safe.
ref: ${{ github.event.pull_request.head.sha }}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Avoid checking out fork heads in pull_request_target

For external PRs this job runs under pull_request_target and checks out ${{ github.event.pull_request.head.sha }}; GitHub has announced that actions/checkout will refuse this exact pattern for fork PRs in pull_request_target workflows when the protection is backported to floating major tags like actions/checkout@v4 on July 16, 2026. Because this guard job only runs for non-members, it will start failing before the diff step and block all forked external PRs, not just workflow edits; use an API/list-files approach or another safe diff mechanism instead of checking out the fork head here.

Useful? React with 👍 / 👎.

fetch-depth: 0
- name: Detect workflow changes
if: steps.perm.outputs.trusted != 'true'
run: |
git fetch origin "${{ github.event.pull_request.base.ref }}" --depth=1

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2: Workflow-change detection uses a full tree diff instead of PR delta. This can falsely reject external PRs that did not modify workflows.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At .github/workflows/guard-branches.yml, line 68:

<comment>Workflow-change detection uses a full tree diff instead of PR delta. This can falsely reject external PRs that did not modify workflows.</comment>

<file context>
@@ -0,0 +1,73 @@
+          fetch-depth: 0
+      - name: Detect workflow changes
+        run: |
+          git fetch origin "${{ github.event.pull_request.base.ref }}" --depth=1
+          CHANGED=$(git diff --name-only FETCH_HEAD HEAD | grep -E '^\.github/workflows/.*\.ya?ml$' || true)
+          if [ -n "$CHANGED" ]; then
</file context>

CHANGED=$(git diff --name-only FETCH_HEAD HEAD | grep -E '^\.github/workflows/.*\.ya?ml$' || true)
if [ -n "$CHANGED" ]; then
echo "::error::External contributors may not modify workflow files: $CHANGED"
exit 1
fi
65 changes: 65 additions & 0 deletions .github/workflows/lint-check.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
name: Lint check

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2: Missing least-privilege permissions block. A lint-only workflow needs contents: read at most. The inherited default grants write access to contents, issues, and pull-requests — unnecessary privilege for untrusted PR code.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At .github/workflows/lint-check.yml, line 12:

<comment>Missing least-privilege `permissions` block. A lint-only workflow needs `contents: read` at most. The inherited default grants write access to contents, issues, and pull-requests — unnecessary privilege for untrusted PR code.</comment>

<file context>
@@ -0,0 +1,65 @@
+# --------------------------------------------------------------------------
+  lint:
+    name: Lint (${{ matrix.python-version }})
+    runs-on: ubuntu-latest
+    strategy:
+      fail-fast: false
</file context>


on:

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2: Missing push trigger for main branch. Lint checks only run on PRs, not on pushes to main (e.g., merges). Add push: branches: [main] so the workflow enforces lint after merge commits too.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At .github/workflows/lint-check.yml, line 3:

<comment>Missing `push` trigger for main branch. Lint checks only run on PRs, not on pushes to main (e.g., merges). Add `push: branches: [main]` so the workflow enforces lint after merge commits too.</comment>

<file context>
@@ -0,0 +1,65 @@
+name: Lint check
+
+on:
+  pull_request:
+
</file context>

pull_request:

jobs:
# --------------------------------------------------------------------------
# 1. Matrix job — one runner *per* Python version
# --------------------------------------------------------------------------
lint:
name: Lint (${{ matrix.python-version }})
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"]
env:
VIRTUAL_ENV: ${{ github.workspace }}/.venv

steps:
- uses: actions/checkout@v4

- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v4
with:
python-version: ${{ matrix.python-version }}

- name: Check UV installation
run: make check-uv

- name: Verify UV installation
run: uv --version

- name: Install dependencies
run: PYTHON_VERSION=${{ matrix.python-version }} TEST_PROFILE=ci make install

- name: Run ruff format merge check
run: make merge-check-ruff-format

- name: Run ruff lint merge check
run: make merge-check-ruff-lint

- name: Run pyright merge check
run: make merge-check-pyright

- name: Run mypy merge check
run: make merge-check-mypy

# --------------------------------------------------------------------------
# 2. Aggregator job — the *single* required status check
# --------------------------------------------------------------------------
lint-all:
name: Lint (all versions)
runs-on: ubuntu-latest
needs: lint # wait for every matrix leg
if: always() # run even if one leg already failed

steps:
- name: Fail if any matrix leg failed
run: |
if [ "${{ needs.lint.result }}" != "success" ]; then
echo "::error::At least one Python version failed linting."
exit 1
fi
echo "✅ All Python versions passed lint checks."
Loading
Loading