From e740f6296f64e3ed06f0845250186d9f3a8b53cd Mon Sep 17 00:00:00 2001 From: Louis Choquel Date: Fri, 25 Sep 2026 12:34:46 +0200 Subject: [PATCH 1/2] =?UTF-8?q?feature/Methods-release-skill=20=C2=B7=20L-?= =?UTF-8?q?260925-27652a=20(#10)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The method library gets a /release skill in the shape of the workspace release play, plus what it needs to exist: a CHANGELOG.md seeded with v0.1.0 to v0.1.2, a github-release.yml that creates the annotated tag and the GitHub Release on the release pull request's merge commit, and pull-request checks holding every METHODS.toml and the changelog to the release branch's version. Until now each tag was made by hand after the merge, although the tag is what every pinned address resolves against. docs/releasing.md explains the scheme and why the workflows are built as they are, and the skill's address check validates each package at the new tag on the hosted API. Closes L-260925-27652a šŸ¤– Generated with [Claude Code](https://claude.com/claude-code) --- ## Summary by cubic Automates releasing the methods library: merging a `release/vX.Y.Z` pull request into `main` now creates the annotated tag and the GitHub Release that every pinned address resolves against, instead of tagging by hand after each merge. - Adds a `/release` skill that bumps every `methods/*/METHODS.toml` in lockstep and runs the format, lint and validation gates. - Adds `github-release.yml`, which reads the version every manifest declares and tags only the release pull request's merge commit, refusing to tag when the manifests disagree, the changelog lacks an entry, or no release PR merged. - Adds `version-check.yml` and `changelog-check.yml` to hold release pull requests to their branch's version, and `scripts/check-addresses.sh` to validate every package at the new tag on the hosted API, reporting a manifest with no name or address instead of dying silently. - Seeds `CHANGELOG.md` with `v0.1.0` to `v0.1.2`, documents the scheme in `docs/releasing.md`, and points the README at it. Closes L-260925-27652a. Written for commit c6531259f6168de854bea1dc8e9fbf740a4d1dcc. Summary will update on new commits. Review in cubic --------- Co-authored-by: Claude Opus 5.5 --- .claude/skills/release/SKILL.md | 77 +++++++++ .../skills/release/scripts/check-addresses.sh | 86 ++++++++++ .github/workflows/changelog-check.yml | 55 ++++++ .github/workflows/github-release.yml | 158 ++++++++++++++++++ .github/workflows/version-check.yml | 51 ++++++ CHANGELOG.md | 39 +++++ README.md | 6 +- docs/releasing.md | 67 ++++++++ 8 files changed, 534 insertions(+), 5 deletions(-) create mode 100644 .claude/skills/release/SKILL.md create mode 100755 .claude/skills/release/scripts/check-addresses.sh create mode 100644 .github/workflows/changelog-check.yml create mode 100644 .github/workflows/github-release.yml create mode 100644 .github/workflows/version-check.yml create mode 100644 CHANGELOG.md create mode 100644 docs/releasing.md diff --git a/.claude/skills/release/SKILL.md b/.claude/skills/release/SKILL.md new file mode 100644 index 0000000..83e55b5 --- /dev/null +++ b/.claude/skills/release/SKILL.md @@ -0,0 +1,77 @@ +--- +name: release +description: > + Cut a release of methods, the public MTHDS method library at + github.com/Pipelex/methods: the release/vX.Y.Z worktree, the lockstep bump of + every methods/*/METHODS.toml, the changelog entry, the format, lint and + validation gates, one commit, and a pull request to main whose merge creates + the annotated vX.Y.Z tag every pinned address resolves against. Use when the + user says "release", "cut a release", "bump version", "prepare a release", + "new version", "make a release", "ship it", "create release branch", + "promote dev to main", "tag the library", "snapshot the library", or wants a + change reachable at a new @vX.Y.Z address. Changelog content passed inline + ("/release Added a contract review method") becomes the entry. The merge is + landed by /ledger-land, never by this skill. +--- + +# Releasing the method library + +The procedure is the workspace release play, [`docs/workspace/releasing.md`](../../../../docs/workspace/releasing.md) at the workspace root — `../docs/workspace/releasing.md` from this repo's own root, which resolves the same from the main checkout and from any worktree. Read it first, then run it with what follows. The repo key is `methods`, the base is `dev`, and the pull request targets `main`. The release worktree is `_methods--release`, made with `wt add methods release --branch release/vX.Y.Z`. The repo declares no `.worktree.toml`, no `.worktreeinclude` and no Makefile, so `wt` resolves the base from `origin/dev` and provisions nothing: the gates run with the `pipelex` and `plxt` installed on the machine. The repo's own account of the scheme, the workflows and why they are built as they are is [`docs/releasing.md`](../../../docs/releasing.md). + +## What ships + +Nothing is published to a package registry: the repository is the distribution channel, and what the merge to `main` produces is the annotated `vX.Y.Z` tag that every address pinned to the release resolves against, `github.com/Pipelex/methods/@vX.Y.Z`, with a GitHub Release beside it. Both are created by `.github/workflows/github-release.yml`, which fires on the push to `main`, and on a `workflow_dispatch` for a re-run, which it refuses from any branch but `main`: + +- It reads the version every `methods/*/METHODS.toml` declares, and fails when they do not all declare the same one, so a manifest left behind tags nothing. +- It is guarded on what already exists, because `main` moves between releases without a version bump: with the tag and the Release both there it reports that the push carried no bump and stops, and with the tag there and no Release it creates only the Release. +- It tags the release pull request's merge commit, which it asks GitHub for as the pull request from `release/vX.Y.Z` merged into `main`, never the commit its own run checked out. So a release run cancelled while pending behind another push, a failed run followed by a routine push, and a re-dispatch after `main` moved all tag the same commit. It refuses when no such pull request was merged. +- It refuses to tag when `CHANGELOG.md` at that commit has no `## [vX.Y.Z] - ` heading. Otherwise it creates the tag with `git tag -a` on the merge commit, message `Library snapshot vX.Y.Z`, pushes it, and calls `gh release create --verify-tag` with the notes sliced from the changelog: everything between the version's heading and the next `## [v…] - ` heading. Never create the tag by hand first; the workflow tags. + +The landing verifies the publish from the run, the tag and the Release: + +```bash +gh run list --workflow=github-release.yml --branch main --limit 3 --json conclusion,headSha,url # the run whose headSha is the merge SHA: success, or a later one if it was cancelled +git -C
fetch --tags --prune origin && git -C
rev-list -n 1 vX.Y.Z # the tag, on the merge SHA +gh release view vX.Y.Z # the Release and its notes +``` + +Once the tag exists, the landing proves what the release is for: every package runs by its address at the tag. From `
`, with `PIPELEX_API_KEY` set, run `.claude/skills/release/scripts/check-addresses.sh vX.Y.Z`. It lists the packages the tag itself carries, asks the hosted API's `POST /v1/validate` to fetch and validate each one at `
/@vX.Y.Z`, and spends no inference. Every line must read `āœ“`. A `āœ—` is a release that did not do its job, since the hosted fetch refuses that package at its own tag: report it with the line the script printed, and file the fix against `methods` as a bug discovered from the release item. + +If the run failed, read its log before anything else. Its deliberate refusals, manifests that disagree and a missing changelog entry, are also what the pull request's checks assert, so either one reaching `main` means a check was bypassed. A failure from outside the repository is re-run with `gh run rerun --failed`, or, once `main` has moved on, with `gh workflow run github-release.yml --ref main`. The guards make either safe, since the tag lands on the merge commit whichever run creates it. + +## Version files and the lock + +- **Every `methods/*/METHODS.toml`** — the `[package]` table's `version`, with no `v` prefix. The library versions in lockstep: a package's version is the library's version, so every manifest moves with every release, including the manifests of packages the release did not touch. Each manifest opens exactly one line with `version = `, so one command sets them all: `perl -pi -e 's/^version = ".*"/version = "X.Y.Z"/' methods/*/METHODS.toml`, using perl because the in-place flag of `sed -i` differs between macOS and GNU. `mthds_version`, the version of the standard a package requires, is a different field and never moves with a release. +- **No lock.** Nothing is installed, and nothing records the version a second time. +- **Also stamped:** nothing. There is no `VERSION` file, no badge and no version literal. The README's example addresses illustrate the grammar and stay as they are, and a sample `inputs.json` pins the cookbook's tags, never this repo's. + +## Gates + +Run at the root of the worktree, in this order. Every one is blocking. + +1. **Lockstep** — `grep -h '^version = ' methods/*/METHODS.toml | sort -u` must print exactly one line. Before the bump that line is the previous release's version, and a second line means a package reached `dev` declaring another one; the bump cures it, since it rewrites every manifest. **After the bump** it runs again and must print exactly `version = "X.Y.Z"`. +2. **Format and lint** — `plxt fmt --check`, then `plxt lint`, over every `.mthds` bundle and `METHODS.toml`. The repo carries no `plxt` configuration, so `plxt` reads the machine's own, `~/.pipelex/plxt.toml` when there is one. A red format check is cured by `plxt fmt`, which rewrites, and whatever it touched joins the release commit. A red lint is a bundle to fix on `dev` through an ordinary branch before the release is cut again. +3. **Every package validates** — `for d in methods/*/; do pipelex validate bundle "$d" >/dev/null || echo "āœ— $d"; done` prints nothing when every package passes; re-run a failing one without the redirect to read why. It is the static validation and dry run the README asks of every contribution, and it spends no inference. A red package is fixed on `dev` through an ordinary branch, never inside the release commit. + +## The release commit + +`CHANGELOG.md` and every `methods/*/METHODS.toml`, plus each file `plxt fmt` rewrote, staged by name: `git add CHANGELOG.md methods/*/METHODS.toml`, the shell spelling the glob out into names. + +## CI on the release pull request + +- `version-check.yml` — every `methods/*/METHODS.toml` declares the version in the `release/vX.Y.Z` branch name, and the failure names each manifest that does not. +- `changelog-check.yml` — `CHANGELOG.md` carries `## [vX.Y.Z] - …` for that version, and no `[Unreleased]` heading survives. + +Both act only on a head matching `^release/v([0-9]+\.[0-9]+\.[0-9]+)$` and pass trivially on any other pull request into `main`. Nothing in CI formats, lints or validates a bundle, or checks a sample link: the gates above run here and nowhere else. + +## Particulars + +- **`main` is the default branch, and it moves between releases.** An address without a tag runs `main` at its head, so `main` is fast-forwarded to `dev` when a change should reach those callers before a tag does, and `origin/main..dev` can then be empty while work is still unreleased. What a release carries is everything since the last tag: in the play's step 1, read `git -C
log $(git -C
describe --tags --abbrev=0)..dev --oneline` rather than `origin/main..dev`. The release pull request still targets `main` and still merges with a merge commit, whether or not `main` had already caught up. +- **What counts as breaking.** The bump follows the play's pre-1.0 rule, read for a method library: a change that breaks a caller moving an address from the previous tag to this one is a **minor** — a package renamed or removed, an exported pipe renamed, removed or no longer exported, a `main_pipe` changed, or the inputs or output concept of an exported pipe changed. Everything else is a **patch**: a new package, a new pipe, a better prompt, a model moved to a deck alias, a sample fixed. `v0.1.1`, which added `text_stats`, was a patch. +- **The changelog entry speaks to a caller who pins a tag.** Name the package and the pipe or the sample, and say what running it at the new tag changes. When a change makes something stop resolving at older tags, such as a sample link that moved in `v0.1.2`, say so and name the tag to run instead. The headings carry the `v`, `## [vX.Y.Z] - YYYY-MM-DD`, which is what `changelog-check.yml` greps for and what `github-release.yml` slices the Release notes from. +- **No pre-release form.** Both pull-request checks skip a head like `release/v0.2.0-rc.1` rather than failing it, and `github-release.yml` tags whatever the manifests declare, so an `rc` would ship as a tag any address could pin. Ship a plain `X.Y.Z`, which is also the tag form the address grammar recommends. +- **The tags are annotated.** `v0.1.0` to `v0.1.2` were created by hand, each with a one-line summary as its message; from the release that first carries `github-release.yml`, the workflow creates them with the message `Library snapshot vX.Y.Z`. A lightweight `v0.0.1` also exists, on a commit after `v0.1.0`; `git describe --tags` from `dev` answers the nearest release tag and is not misled by it. +- **The pull request body names the manifests.** The repo has no single version file, so the play's "Bumps version" line reads "Bumps every package manifest from `A.B.C` to `X.Y.Z`." +- **Consumers pin library tags, and a release moves none of them.** The cookbook, both starters, the method-app template, the plugins and the MCP name addresses at tags of this library in their docs, samples and tests. When a release fixes something one of them pins, as `v0.1.2` did for `table_extraction`'s sample, file the move against that repo; whether and when it moves is that repo's call. +- **The skill, the docs page and the workflows move together.** This skill, `docs/releasing.md` and the workflows in `.github/workflows/` state the same rules; a change to one of them changes the others in the same commit. +- **No release follow-ups are armed.** `ledger.toml` declares no `release_followups` for `methods`, so filing the release item materializes no blocked tasks; anything a release owes another repo is filed by hand beside it. diff --git a/.claude/skills/release/scripts/check-addresses.sh b/.claude/skills/release/scripts/check-addresses.sh new file mode 100755 index 0000000..a9f7983 --- /dev/null +++ b/.claude/skills/release/scripts/check-addresses.sh @@ -0,0 +1,86 @@ +#!/usr/bin/env bash +# Validate every package's address at a release tag on the hosted API. +# +# check-addresses.sh vX.Y.Z +# +# Run from any checkout of the repository. It fetches the tags, lists the +# packages the tag itself carries — so a package added on `dev` after the cut +# is not asked for — and for each METHODS.toml reads the manifest's `address` +# and `name`, which are the package's identity, then asks POST /v1/validate to +# fetch
/@vX.Y.Z and validate it. No call spends inference. +# Prints one line per package and exits non-zero when any address is not both +# valid and runnable, or when the API answers without a verdict. +# +# Needs git, curl, jq and PIPELEX_API_KEY; PIPELEX_BASE_URL overrides the API host. + +set -euo pipefail + +TAG="${1:-}" +if [[ ! "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "usage: $0 vX.Y.Z" >&2 + exit 2 +fi +if [ -z "${PIPELEX_API_KEY:-}" ]; then + echo "PIPELEX_API_KEY is not set: the check validates on the hosted API, so it needs a key (create one at https://app.pipelex.com)" >&2 + exit 2 +fi +for tool in git curl jq; do + command -v "$tool" >/dev/null || { echo "$tool is required" >&2; exit 2; } +done + +BASE_URL="${PIPELEX_BASE_URL:-https://api.pipelex.com}" +failures=0 + +# The first ` = "…"` (or '…') line of a manifest, or nothing. It must not +# fail when the key is absent: `name` is optional in the MTHDS schema, and a +# package without one is exactly what this check has to report. +field() { + printf '%s\n' "$2" | sed -nE "/^$1 = /{s/^$1 = [\"']([^\"']*)[\"'].*/\1/p;q;}" +} + +git fetch --quiet --tags origin +if ! git rev-parse -q --verify "refs/tags/$TAG" >/dev/null; then + echo "$TAG is not a tag on origin yet: the release workflow has not tagged it" >&2 + exit 1 +fi + +manifests=$(git ls-tree -r --name-only "$TAG" -- methods/ | grep '/METHODS\.toml$') +for manifest in $manifests; do + text=$(git show "$TAG:$manifest") + name=$(field name "$text") + address=$(field address "$text") + if [ -z "$name" ] || [ -z "$address" ]; then + echo "āœ— $manifest — declares no name or no address, so no address reaches the package" + failures=$((failures + 1)) + continue + fi + method_ref="$address/$name@$TAG" + body=$(jq -n --arg ref "$method_ref" '{method_ref: $ref}') + response=$(curl -sS -X POST "$BASE_URL/v1/validate" \ + -H "Authorization: Bearer $PIPELEX_API_KEY" \ + -H 'Content-Type: application/json' \ + -w '\n%{http_code}' \ + -d "$body") || { echo "āœ— $method_ref — the API could not be reached"; failures=$((failures + 1)); continue; } + status=$(printf '%s' "$response" | tail -n 1) + verdict=$(printf '%s' "$response" | sed '$d') + if [ "$status" != "200" ]; then + detail=$(printf '%s' "$verdict" | jq -r '.detail // .title // empty' 2>/dev/null || true) + echo "āœ— $method_ref — HTTP $status: ${detail:-$(printf '%s' "$verdict" | head -c 300)}" + failures=$((failures + 1)) + continue + fi + valid=$(printf '%s' "$verdict" | jq -r '.is_valid' 2>/dev/null || true) + runnable=$(printf '%s' "$verdict" | jq -r '.is_runnable' 2>/dev/null || true) + if [ "$valid" = "true" ] && [ "$runnable" = "true" ]; then + echo "āœ“ $method_ref" + else + message=$(printf '%s' "$verdict" | jq -r '.message // "no message"' 2>/dev/null || echo "an answer that is not JSON") + echo "āœ— $method_ref — is_valid ${valid:-?}, is_runnable ${runnable:-?}: $message" + failures=$((failures + 1)) + fi +done + +if [ "$failures" -ne 0 ]; then + echo "$failures address(es) at $TAG failed validation" >&2 + exit 1 +fi diff --git a/.github/workflows/changelog-check.yml b/.github/workflows/changelog-check.yml new file mode 100644 index 0000000..8611686 --- /dev/null +++ b/.github/workflows/changelog-check.yml @@ -0,0 +1,55 @@ +name: Changelog check + +# On a `release/vX.Y.Z` → `main` PR, assert CHANGELOG.md has the matching entry +# and no `[Unreleased]` heading survives. The post-merge tagger refuses to tag a +# version with no changelog entry, so catching it here keeps `main` from landing +# a bump that cannot be released. + +on: + pull_request: + branches: + - main + types: [opened, synchronize, reopened] + +jobs: + changelog-check: + name: Changelog entry for release version + runs-on: ubuntu-latest + steps: + - name: Identify the source branch + id: branch_info + env: + SOURCE_BRANCH: ${{ github.event.pull_request.head.ref }} + run: | + echo "Source branch: $SOURCE_BRANCH" + + if [[ "$SOURCE_BRANCH" =~ ^release/v([0-9]+\.[0-9]+\.[0-9]+)$ ]]; then + echo "is_release=true" >> "$GITHUB_OUTPUT" + echo "release_version=${BASH_REMATCH[1]}" >> "$GITHUB_OUTPUT" + else + echo "is_release=false" >> "$GITHUB_OUTPUT" + echo "Not a release branch — nothing to check." + fi + + - name: Checkout repo + if: steps.branch_info.outputs.is_release == 'true' + uses: actions/checkout@v4 + + - name: Check changelog entry + if: steps.branch_info.outputs.is_release == 'true' + run: | + VERSION="${{ steps.branch_info.outputs.release_version }}" + + if ! grep -q "^## \[v$VERSION\] - " CHANGELOG.md; then + echo "::error::No changelog entry found for v$VERSION" + echo "Versions currently in the changelog:" + grep -E "^## \[v[0-9]+\.[0-9]+\.[0-9]+\]" CHANGELOG.md || true + exit 1 + fi + echo "āœ… Changelog entry found for v$VERSION" + + if grep -q "^## \[Unreleased\]" CHANGELOG.md; then + echo "::error::CHANGELOG.md still has an [Unreleased] heading — fold it into the v$VERSION entry." + exit 1 + fi + echo "āœ… No [Unreleased] heading left" diff --git a/.github/workflows/github-release.yml b/.github/workflows/github-release.yml new file mode 100644 index 0000000..3beee94 --- /dev/null +++ b/.github/workflows/github-release.yml @@ -0,0 +1,158 @@ +name: Create release + +# Tags and releases the method library when a version bump lands on `main`. +# +# The library publishes no package: the repository is the distribution channel, +# and an address pinned to a tag (`github.com/Pipelex/methods/@vX.Y.Z`) +# resolves against the annotated `vX.Y.Z` tag. That tag IS the release artifact, +# which is why this workflow creates it with `git tag -a` and then passes +# `--verify-tag` to `gh release create`: letting `gh` create the tag implicitly +# would produce a *lightweight* tag. +# +# The version is the one every `methods/*/METHODS.toml` declares, in lockstep, +# so a manifest left behind fails this workflow before anything is tagged. +# +# Pushes to `main` that carry no version bump are normal here — `main` is the +# default branch, which an address without a tag runs, and it is moved forward +# between releases — so every step is guarded on "does this tag / release +# already exist" rather than assuming the push is a release. +# +# The tag goes on the release pull request's merge commit, looked up from the +# merged pull request, never on the commit this run happens to be on. A release +# run can be cancelled while pending (a concurrency group keeps one pending run +# and cancels it when another push queues), can fail before tagging, or can be +# re-dispatched after `main` has moved; in each case the run that does the +# tagging is on a later commit, which may carry work the release does not. + +on: + push: + branches: + - main + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }} + cancel-in-progress: false + +jobs: + github-release: + name: Tag and release + runs-on: ubuntu-latest + permissions: + contents: write # mandatory for pushing tags and making GitHub Releases + pull-requests: read # to find the release pull request's merge commit + + steps: + - name: Refuse a ref other than main + if: github.ref != 'refs/heads/main' + run: | + echo "::error::This workflow releases what main carries. Dispatch it from main: a run on $GITHUB_REF could tag a release branch before its merge, and the merge would then find the tag taken." + exit 1 + + - uses: actions/checkout@v4 + with: + fetch-depth: 0 # need the tag list to know what has already shipped + + - name: Read the version every manifest declares + run: | + VERSIONS=$(grep -h '^version = ' methods/*/METHODS.toml | sort -u) + if [ "$(printf '%s\n' "$VERSIONS" | grep -c .)" -ne 1 ]; then + echo "::error::The manifests disagree on the version, so there is no single version to release:" + grep -H '^version = ' methods/*/METHODS.toml + exit 1 + fi + VERSION=$(printf '%s' "$VERSIONS" | cut -d '"' -f 2) + if [ -z "$VERSION" ]; then + echo "::error::Could not read a version from methods/*/METHODS.toml" + exit 1 + fi + echo "VERSION=$VERSION" >> "$GITHUB_ENV" + echo "Version on main: $VERSION" + + - name: Check what already exists + env: + GITHUB_TOKEN: ${{ github.token }} + run: | + if git rev-parse -q --verify "refs/tags/v$VERSION" >/dev/null; then + echo "TAG_EXISTS=1" >> "$GITHUB_ENV" + echo "Tag v$VERSION already exists." + fi + if gh release view "v$VERSION" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then + echo "RELEASE_EXISTS=1" >> "$GITHUB_ENV" + echo "Release v$VERSION already exists." + fi + + - name: Nothing to do + if: env.TAG_EXISTS == '1' && env.RELEASE_EXISTS == '1' + run: echo "v$VERSION is already tagged and released — this push carried no version bump." + + - name: Resolve the release commit + if: env.TAG_EXISTS != '1' || env.RELEASE_EXISTS != '1' + env: + GITHUB_TOKEN: ${{ github.token }} + run: | + if [ "$TAG_EXISTS" = "1" ]; then + TARGET=$(git rev-list -n 1 "v$VERSION") + echo "v$VERSION is already on $TARGET; only the Release is missing." + else + TARGET=$(gh pr list --repo "$GITHUB_REPOSITORY" --state merged --base main --head "release/v$VERSION" \ + --json mergeCommit --jq '.[0].mergeCommit.oid // empty') + if [ -z "$TARGET" ]; then + echo "::error::The manifests declare $VERSION, which is not tagged, but no pull request from release/v$VERSION was merged into main — refusing to tag a commit no release pull request produced." + exit 1 + fi + if ! git merge-base --is-ancestor "$TARGET" HEAD; then + echo "::error::The release pull request's merge commit $TARGET is not in main's history at $(git rev-parse HEAD) — refusing to tag." + exit 1 + fi + echo "The release pull request for v$VERSION merged as $TARGET." + fi + echo "TARGET=$TARGET" >> "$GITHUB_ENV" + + - name: Verify changelog entry exists + if: env.TAG_EXISTS != '1' || env.RELEASE_EXISTS != '1' + run: | + if ! git show "$TARGET:CHANGELOG.md" | grep -q "^## \[v$VERSION\] - "; then + echo "::error::No changelog entry for v$VERSION in CHANGELOG.md at $TARGET — refusing to tag." + exit 1 + fi + + - name: Create and push annotated tag + if: env.TAG_EXISTS != '1' + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git tag -a "v$VERSION" "$TARGET" -m "Library snapshot v$VERSION" + git push origin "v$VERSION" + echo "Tagged $(git rev-parse --short "$TARGET") as v$VERSION" + + - name: Extract changelog notes for this version + if: env.RELEASE_EXISTS != '1' + run: | + git show "$TARGET:CHANGELOG.md" > "$RUNNER_TEMP/CHANGELOG.md" + CHANGELOG="$RUNNER_TEMP/CHANGELOG.md" + START_LINE=$(grep -n "^## \[v$VERSION\] - " "$CHANGELOG" | cut -d: -f1) + NEXT_VERSION_LINE=$(tail -n +$((START_LINE + 1)) "$CHANGELOG" | grep -n "^## \[v.*\] - " | head -1 | cut -d: -f1) + + if [ -z "$NEXT_VERSION_LINE" ]; then + NOTES=$(tail -n +$((START_LINE + 1)) "$CHANGELOG") + else + NOTES=$(sed -n "$((START_LINE + 1)),$((START_LINE + NEXT_VERSION_LINE - 1))p" "$CHANGELOG") + fi + + { + echo "CHANGELOG_NOTES<> "$GITHUB_ENV" + + - name: Create GitHub Release + if: env.RELEASE_EXISTS != '1' + env: + GITHUB_TOKEN: ${{ github.token }} + run: | + gh release create "v$VERSION" \ + --repo "$GITHUB_REPOSITORY" \ + --title "v$VERSION" \ + --verify-tag \ + --notes "$CHANGELOG_NOTES" diff --git a/.github/workflows/version-check.yml b/.github/workflows/version-check.yml new file mode 100644 index 0000000..0579a3f --- /dev/null +++ b/.github/workflows/version-check.yml @@ -0,0 +1,51 @@ +name: Version check + +# On a `release/vX.Y.Z` → `main` PR, assert that every `methods/*/METHODS.toml` +# declares the version in the branch name. The library versions in lockstep — +# a package's version is the library's version — so one manifest left behind +# fails the check. Other PRs into `main` pass trivially. + +on: + pull_request: + branches: + - main + +jobs: + version-check: + name: Every manifest matches release branch + runs-on: ubuntu-latest + steps: + - name: Identify the source branch + id: branch_info + env: + SOURCE_BRANCH: ${{ github.event.pull_request.head.ref }} + run: | + echo "Source branch: $SOURCE_BRANCH" + + if [[ "$SOURCE_BRANCH" =~ ^release/v([0-9]+\.[0-9]+\.[0-9]+)$ ]]; then + echo "is_release=true" >> "$GITHUB_OUTPUT" + echo "release_version=${BASH_REMATCH[1]}" >> "$GITHUB_OUTPUT" + else + echo "is_release=false" >> "$GITHUB_OUTPUT" + echo "Not a release branch — nothing to check." + fi + + - name: Checkout repo + if: steps.branch_info.outputs.is_release == 'true' + uses: actions/checkout@v4 + + - name: Check every manifest matches release branch + if: steps.branch_info.outputs.is_release == 'true' + run: | + RELEASE_VERSION="${{ steps.branch_info.outputs.release_version }}" + echo "Release branch version: $RELEASE_VERSION" + + BEHIND=$(grep -L "^version = \"$RELEASE_VERSION\"$" methods/*/METHODS.toml || true) + if [ -n "$BEHIND" ]; then + echo "::error::These manifests do not declare version $RELEASE_VERSION:" + for MANIFEST in $BEHIND; do + echo " $MANIFEST: $(grep '^version = ' "$MANIFEST" || echo 'no version line')" + done + exit 1 + fi + echo "āœ… Every manifest declares $RELEASE_VERSION" diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..4436020 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,39 @@ +# Changelog + +## [Unreleased] + +### Added + +- **`CHANGELOG.md`**: the library's release history, from `v0.1.0` on. Each GitHub Release's notes are its version's entry here. + +### Changed + +- **Every model goes through the standard model deck**: `image_generation`'s image pipes use `@default-general` instead of `nano-banana-2`, `slide_designer`'s mockup renderer uses `@default-premium` instead of `nano-banana-pro`, and `documents`' markdown extraction uses `@default-extract-document` instead of `azure-document-intelligence`, so no method in the library names a model outright. + +## [v0.1.2] - 2026-09-25 + +### Changed + +- **`invoice_extraction`'s sample invoice is pinned**: its `inputs.json` links the invoice at the cookbook's `v0.18.0` tag rather than its `main`, so the sample cannot change under a library tag. + +### Fixed + +- **`table_extraction`'s sample table image**: its `inputs.json` links the image under the cookbook's `assets/` at the cookbook's `v0.18.0` tag. The old link, under the cookbook's `examples/` tree, stops resolving once the cookbook removes that tree from its `main`, so from then on the sample does not resolve at `v0.1.1` and earlier: run it at `@v0.1.2` or later. +- **Every package manifest states the library's version**: each `METHODS.toml` declares `0.1.2`, where the manifests at `v0.1.1` still declared `0.1.0` or `0.2.0`. A package's version is the library's version, and every manifest moves with each release. + +## [v0.1.1] - 2026-08-29 + +### Added + +- **`text_stats`**: deterministic text statistics computed by a sandboxed Python function, with no LLM: counts, vocabulary richness, the most frequent words, and estimated reading and speaking times, as a Markdown report. It is the library's first package carrying a PipeFunc. + +## [v0.1.0] - 2026-08-29 + +### Highlights + +**The first curated snapshot of the public method library.** Every package is self-contained and runs on the hosted API by its address, `github.com/Pipelex/methods/@v0.1.0`, and every manifest follows the MTHDS packaging rules. + +### Added + +- **The first methods**: `documents`, `doc_summarizer`, `cv_analyzer`, `invoice_extraction`, `table_extraction`, `slide_designer`, `image_generation` and `tweet_optimizer`, each a directory of `.mthds` bundles under a `METHODS.toml` manifest, most with a sample `inputs.json` that runs as it is. +- **The library's front page**: the README, which lists the methods, the address grammar, how to run a method by its address and how to contribute one, and the MIT license. diff --git a/README.md b/README.md index 56053da..357e98c 100644 --- a/README.md +++ b/README.md @@ -70,11 +70,7 @@ Per-package tag prefixes — tagging and versioning one package's release indepe ### Cutting a release -Before pushing a tag `vX.Y.Z`: - -1. Set `version = "X.Y.Z"` in **every** `methods/*/METHODS.toml` — all of them, including the packages that did not change in this release. Lockstep means no manifest is left behind. -2. Commit that version sync, and tag **that commit**, so the tag and the manifests it contains agree. -3. Verify before tagging: `grep -h '^version = ' methods/*/METHODS.toml | sort -u` must print exactly one line, and it must be the version you are about to tag. +A release is a `release/vX.Y.Z` branch cut from `dev` that sets the version in **every** `methods/*/METHODS.toml` and turns the `[Unreleased]` section of [`CHANGELOG.md`](CHANGELOG.md) into the release's entry, merged into `main` by pull request. Nobody pushes a tag by hand: the merge creates the annotated tag and the GitHub Release, and refuses to when the manifests disagree or the changelog has no entry. The steps, the checks and the workflows behind them are in [`docs/releasing.md`](docs/releasing.md). ## Contributing a method diff --git a/docs/releasing.md b/docs/releasing.md new file mode 100644 index 0000000..df2bab9 --- /dev/null +++ b/docs/releasing.md @@ -0,0 +1,67 @@ +# Releasing the method library + +This library publishes no package. The repository is the distribution channel, and a release is a git tag: an address pinned to it, `github.com/Pipelex/methods/@vX.Y.Z`, runs every package exactly as it was at that tag. So **the annotated `vX.Y.Z` tag is the release artifact**, and everything on this page exists to make sure a tag is only ever created on a commit whose manifests and changelog agree with it. + +## The scheme + +- **The version lives in every `methods/*/METHODS.toml`**, as the `[package]` table's `version`, with no `v` prefix. The library versions in lockstep: a package's version is the library's version, so every manifest moves with every release, including those of packages the release did not touch. There is no `VERSION` file and no second place the number is written. +- **`CHANGELOG.md`** is the release record, one `## [vX.Y.Z] - YYYY-MM-DD` entry per release, with work in progress gathered under `## [Unreleased]` until it ships. An entry speaks to a caller who pins a tag: which package, pipe or sample changed, and what running it at the new tag changes for them. +- **Annotated `vX.Y.Z` tags on `main`**, created by a workflow on the release pull request's merge commit, never by hand. +- **`main` is the default branch**, and an address without a tag runs it at its head. It can therefore move ahead of the last tag between releases, so what a release carries is everything since the last tag, not the difference between `dev` and `main`. + +The `v` prefix appears in branch names, changelog headings and tags, never in a manifest. + +## What counts as breaking + +The library is pre-1.0, so a breaking change is a **minor** and everything else a **patch**. A change is breaking when it breaks a caller who moves an address from the previous tag to the new one: a package renamed or removed, an exported pipe renamed, removed or no longer exported, a `main_pipe` changed, or the inputs or output concept of an exported pipe changed. A new package, a new pipe, a better prompt, a model moved to a deck alias or a fixed sample is a patch. Pre-release forms (`-rc.1`) are not used: a tag is something any address can pin. + +## Cutting a release + +A release is a `release/vX.Y.Z` branch cut from `dev` and merged into `main` by pull request, with a merge commit. + +1. On the release branch, set the version in every manifest: `perl -pi -e 's/^version = ".*"/version = "X.Y.Z"/' methods/*/METHODS.toml`. Then `grep -h '^version = ' methods/*/METHODS.toml | sort -u` must print exactly one line, the version being released. +2. In the same commit, turn the `## [Unreleased]` section of `CHANGELOG.md` into the release's entry, `## [vX.Y.Z] - YYYY-MM-DD`, leaving no `[Unreleased]` heading behind. +3. Before committing, check the bundles: `plxt fmt --check` and `plxt lint` over every `.mthds` and `METHODS.toml`, and `pipelex validate bundle methods//` for every package. +4. Open the pull request into `main`. Its checks refuse a manifest that does not declare the branch's version, and a changelog without the entry. +5. The merge creates the tag and the GitHub Release. Once the tag exists, check that every package runs by its address at it: `.claude/skills/release/scripts/check-addresses.sh vX.Y.Z`, with `PIPELEX_API_KEY` set, asks the hosted API to fetch and validate each package at the tag, spends no inference, and must print `āœ“` for every one. + +The Pipelex team runs these steps with the repository's `/release` skill for Claude Code, [`.claude/skills/release/SKILL.md`](../.claude/skills/release/SKILL.md), inside the workspace release play that every Pipelex repository shares. + +## The workflows + +| Workflow | Fires on | Enforces | +|---|---|---| +| `version-check.yml` | pull request → `main` | every `methods/*/METHODS.toml` declares the version in the `release/vX.Y.Z` branch name | +| `changelog-check.yml` | pull request → `main` | `CHANGELOG.md` has `## [vX.Y.Z] - …` for that version, and no `[Unreleased]` heading survives | +| `github-release.yml` | push to `main`, and `workflow_dispatch` from `main` | creates the annotated tag on the release pull request's merge commit, and the GitHub Release | + +The pull-request checks act only on a head matching `release/vX.Y.Z` exactly and pass trivially on anything else. They read the branch name from an environment variable rather than splicing it into their script, since in a public repository anyone can open a pull request from a branch named with shell syntax. Nothing in CI formats, lints or validates a bundle: step 3 above runs on the releaser's machine and nowhere else. + +### Why the tagger creates the tag itself + +`gh release create vX.Y.Z` creates a *lightweight* tag when the tag does not exist yet. Because the tag here is the artifact rather than a pointer at a published package, `github-release.yml` creates it with `git tag -a` (message `Library snapshot vX.Y.Z`) on the merge commit and pushes it, then calls `gh release create --verify-tag`, which aborts rather than substituting a lightweight tag if the annotated one is somehow missing. + +### Which commit the tag goes on + +The tag goes on the release pull request's merge commit, which the workflow finds by asking GitHub for the pull request from `release/vX.Y.Z` merged into `main`, and never on the commit its own run checked out. The two are the same when the run is the merge's own. They differ when the merge's run was cancelled while pending, since a concurrency group keeps a single pending run and cancels it when another push queues; when the merge's run failed before tagging and a later push to `main` ran; and when the workflow was dispatched again after `main` moved on. In each case, tagging the run's own commit would pin a commit carrying work the release does not. The workflow refuses to tag when no such pull request was merged or when its merge commit is not in `main`'s history, and it reads the changelog entry at that commit, so the Release's notes are the ones the tag carries. + +A dispatch from any branch other than `main` is refused before anything runs. On a release branch it would otherwise tag the branch's head before the merge, and the merge's own run would then find the tag taken and leave it where it was. + +### Idempotency + +Pushes to `main` that carry no version bump are routine, since `main` moves ahead between releases, so every step is guarded on what already exists: + +- The version is read from the manifests first, and the workflow fails when they do not all declare the same one, so a manifest left behind tags nothing. +- Tag and Release both exist: the workflow reports that the push carried no bump and stops. +- Neither exists: it finds the release pull request's merge commit, verifies the changelog entry there, tags that commit, then releases. +- The tag exists and the Release does not, after a partial earlier run: it leaves the tag alone and creates only the Release. + +The changelog check runs before any tag is written, so a version bump that reached `main` without a changelog entry fails the workflow instead of producing a tag with no release notes. A failure from outside the repository is re-run with `gh run rerun --failed`, or by dispatching the workflow from `main`, and these guards make either safe: the tag lands on the merge commit however far `main` has moved since. + +### Release notes + +A Release's body is the changelog entry for its version: everything between its `## [vX.Y.Z] - …` heading and the next `## [v…] - …` heading, heading excluded. Nothing is generated from commit messages, so the changelog is the single source of release notes. + +## History + +`v0.1.0`, `v0.1.1` and `v0.1.2` were tagged and released by hand, before the changelog and the workflows existed; their tag messages carry a one-line summary of each release, and their changelog entries were written afterwards from those tags and the `v0.1.2` Release. A lightweight `v0.0.1` tag also exists, on a commit after `v0.1.0`. It marks no release, and `git describe --tags` from `dev` answers the nearest release tag regardless. From 8e37c93ac84cfac2fa9dcc863b93fd364e735188 Mon Sep 17 00:00:00 2001 From: Louis Choquel Date: Fri, 25 Sep 2026 13:09:44 +0200 Subject: [PATCH 2/2] Release v0.1.3 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01NwdDDZ65AEf2wg7E5xZp6H --- CHANGELOG.md | 4 ++-- methods/cv_analyzer/METHODS.toml | 2 +- methods/doc_summarizer/METHODS.toml | 2 +- methods/documents/METHODS.toml | 2 +- methods/image_generation/METHODS.toml | 2 +- methods/invoice_extraction/METHODS.toml | 2 +- methods/slide_designer/METHODS.toml | 2 +- methods/table_extraction/METHODS.toml | 2 +- methods/text_stats/METHODS.toml | 2 +- methods/tweet_optimizer/METHODS.toml | 2 +- 10 files changed, 11 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4436020..a22be68 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## [Unreleased] +## [v0.1.3] - 2026-09-25 ### Added @@ -8,7 +8,7 @@ ### Changed -- **Every model goes through the standard model deck**: `image_generation`'s image pipes use `@default-general` instead of `nano-banana-2`, `slide_designer`'s mockup renderer uses `@default-premium` instead of `nano-banana-pro`, and `documents`' markdown extraction uses `@default-extract-document` instead of `azure-document-intelligence`, so no method in the library names a model outright. +- **Every model goes through the standard model deck**: `image_generation`'s image pipes use `@default-general` instead of `nano-banana-2`, `slide_designer`'s mockup renderer uses `@default-premium` instead of `nano-banana-pro`, and `documents`' markdown extraction uses `@default-extract-document` instead of `azure-document-intelligence`, so no method in the library names a model outright. Run at `@v0.1.3`, these pipes use whichever model the deck assigns to each alias, which can differ from the model they named at `v0.1.2`. ## [v0.1.2] - 2026-09-25 diff --git a/methods/cv_analyzer/METHODS.toml b/methods/cv_analyzer/METHODS.toml index d93fccd..1b3d83a 100644 --- a/methods/cv_analyzer/METHODS.toml +++ b/methods/cv_analyzer/METHODS.toml @@ -2,7 +2,7 @@ name = "cv_analyzer" display_name = "CV Analyzer" address = "github.com/Pipelex/methods" -version = "0.1.2" +version = "0.1.3" description = "End-to-end candidate screening: extract a CV and a job offer, analyze the match, then either generate tailored interview questions or draft a courteous refusal email." authors = ["Evotis S.A.S"] license = "MIT" diff --git a/methods/doc_summarizer/METHODS.toml b/methods/doc_summarizer/METHODS.toml index a1da0f0..5482a92 100644 --- a/methods/doc_summarizer/METHODS.toml +++ b/methods/doc_summarizer/METHODS.toml @@ -2,7 +2,7 @@ name = "doc_summarizer" display_name = "Document Summarizer" address = "github.com/Pipelex/methods" -version = "0.1.2" +version = "0.1.3" description = "Deep document summarization: profile the document and extract importance-ranked key points in parallel, then synthesize a structured summary with themes and open questions." authors = ["Evotis S.A.S"] license = "MIT" diff --git a/methods/documents/METHODS.toml b/methods/documents/METHODS.toml index 014e1b9..f604b58 100644 --- a/methods/documents/METHODS.toml +++ b/methods/documents/METHODS.toml @@ -2,7 +2,7 @@ name = "documents" display_name = "Documents" address = "github.com/Pipelex/methods" -version = "0.1.2" +version = "0.1.3" description = "Document extraction methods for text, images, and page views." authors = ["Evotis S.A.S"] license = "MIT" diff --git a/methods/image_generation/METHODS.toml b/methods/image_generation/METHODS.toml index 4dddf92..80f984b 100644 --- a/methods/image_generation/METHODS.toml +++ b/methods/image_generation/METHODS.toml @@ -2,7 +2,7 @@ name = "image_generation" display_name = "Image Generation" address = "github.com/Pipelex/methods" -version = "0.1.2" +version = "0.1.3" description = "Image generation methods: render a description directly, or refine it into an optimized image prompt first." authors = ["Evotis S.A.S"] license = "MIT" diff --git a/methods/invoice_extraction/METHODS.toml b/methods/invoice_extraction/METHODS.toml index d5312ff..69f008f 100644 --- a/methods/invoice_extraction/METHODS.toml +++ b/methods/invoice_extraction/METHODS.toml @@ -2,7 +2,7 @@ name = "invoice_extraction" display_name = "Invoice Extraction" address = "github.com/Pipelex/methods" -version = "0.1.2" +version = "0.1.3" description = "Extract structured invoice data from a document: classify each page as bill or receipt, then extract amounts, VAT, vendor and buyer details using both the OCR text and the page view." authors = ["Evotis S.A.S"] license = "MIT" diff --git a/methods/slide_designer/METHODS.toml b/methods/slide_designer/METHODS.toml index da1fb9b..d6a8e2a 100644 --- a/methods/slide_designer/METHODS.toml +++ b/methods/slide_designer/METHODS.toml @@ -2,7 +2,7 @@ name = "slide_designer" display_name = "Slide Designer" address = "github.com/Pipelex/methods" -version = "0.1.2" +version = "0.1.3" description = "Turn a rough slide-deck brief into design proposals: polish the brief, generate multiple visual themes, render a mockup image for each, and compose an HTML report presenting them all." authors = ["Evotis S.A.S"] license = "MIT" diff --git a/methods/table_extraction/METHODS.toml b/methods/table_extraction/METHODS.toml index f165347..998fe38 100644 --- a/methods/table_extraction/METHODS.toml +++ b/methods/table_extraction/METHODS.toml @@ -2,7 +2,7 @@ name = "table_extraction" display_name = "Table Extraction" address = "github.com/Pipelex/methods" -version = "0.1.2" +version = "0.1.3" description = "Extract a data table from a screenshot into faithful HTML, then review the result against the image to correct text and formatting." authors = ["Evotis S.A.S"] license = "MIT" diff --git a/methods/text_stats/METHODS.toml b/methods/text_stats/METHODS.toml index 7df8b57..760d9b3 100644 --- a/methods/text_stats/METHODS.toml +++ b/methods/text_stats/METHODS.toml @@ -2,7 +2,7 @@ name = "text_stats" display_name = "Text Stats" address = "github.com/Pipelex/methods" -version = "0.1.2" +version = "0.1.3" description = "Deterministic text statistics computed in pure Python: character, word, sentence and paragraph counts, vocabulary richness, most frequent words, and estimated reading and speaking times, reported as Markdown." authors = ["Evotis S.A.S"] license = "MIT" diff --git a/methods/tweet_optimizer/METHODS.toml b/methods/tweet_optimizer/METHODS.toml index 68fe9b7..15e9fe6 100644 --- a/methods/tweet_optimizer/METHODS.toml +++ b/methods/tweet_optimizer/METHODS.toml @@ -2,7 +2,7 @@ name = "tweet_optimizer" display_name = "Tweet Optimizer" address = "github.com/Pipelex/methods" -version = "0.1.2" +version = "0.1.3" description = "Optimize a tech tweet: score the draft for fluffiness, cringiness, humblebragging and vagueness, then rewrite it in your own writing style following Twitter/X best practices." authors = ["Evotis S.A.S"] license = "MIT"