feat: add scaffolding to migrate subpackages to the new layout#64
feat: add scaffolding to migrate subpackages to the new layout#64avinash2692 wants to merge 16 commits into
Conversation
Adds a workflow that listens for a repository_dispatch event (event_type=mellea-released) and bumps every contribs pyproject.toml's version + mellea>= constraint to the released version. Refreshes every uv.lock and opens a PR. Uses the default GITHUB_TOKEN. The helper script leaves == exact pins alone — subpackages opting into exact pins own their own bumps — and is idempotent. The mellea-side dispatcher follows in a separate PR. Assisted-by: Claude Opus 4.7 (1M context) Signed-off-by: Avinash Balakrishnan <avinash.bala@us.ibm.com>
Replaces the previous behavior of bumping both `[project] version` and `mellea>=` lines. The receiver now bumps only `version`; each subpackage owns its `mellea>=` floor and only raises it when CI proves something below it breaks. The script also errors out on `mellea` dependency lines that lack an explicit version constraint — bare `mellea`, `mellea[extras]` without operator, and `mellea @ git+...` are rejected. The receiver cannot reason about those forms and silently skipping them would let incompatibilities through. Acceptable forms remain `mellea>=X.Y.Z` and `mellea==X.Y.Z` (with or without extras). The current contribs repo uses these exclusively; the new check is a forward-looking guard. Assisted-by: Claude Opus 4.7 (1M context) Signed-off-by: Avinash Balakrishnan <avinash.bala@us.ibm.com>
Replaces the single bump PR with one PR per subpackage so owners can merge independently — slow movers no longer block fast movers, and CI signals compatibility per package. PRs open in dependency tiers: - Tier 1: _integration_core (consumed by frameworks). - Tier 2: dspy, langchain, crewai, tools (depend on tier 1). - Tier 3: legal-reqs, python-imports, grounding-context (leaves). The workflow re-runs when a sync-mellea-* PR merges, advancing to the next tier automatically. Subpackages whose bump PR doesn't merge before the next coordinated contribs release are left at the old version and ship in the following release. open_per_package_bump_prs.py owns the tier detection and PR opening; the workflow itself just wires the trigger events (repository_dispatch, pull_request closed-merged on sync-mellea-* branches, manual dispatch). Assisted-by: Claude Opus 4.7 (1M context) Signed-off-by: Avinash Balakrishnan <avinash.bala@us.ibm.com>
59c7313 to
c63bab8
Compare
The previous pattern `"mellea(\[[^\]]+\])?(?P<spec>[^"]*)"` matched sibling distributions like `mellea-contribs-integration-core` and `mellea-tools` because anything after `mellea` was eaten by the optional extras group + spec capture. The receiver then treated those sibling lines as malformed mellea deps and errored out. A negative lookahead `(?![a-zA-Z0-9_-])` after `mellea` rules out the prefix-of-a-longer-name case while still allowing `mellea>=`, `mellea==`, `mellea[extras]>=`, and bare `mellea`. Assisted-by: Claude Opus 4.7 (1M context) Signed-off-by: Avinash Balakrishnan <avinash.bala@us.ibm.com>
Adds an empty meta-package at the repo root with no runtime dependencies and a dev-only [dependency-groups] for the tooling that runs across the whole repo (ruff, mypy, actionlint-py, cookiecutter, pyyaml, pre-commit, pytest). `uv sync` at the root installs only those tools; subpackages stay independent. [project.optional-dependencies] is left empty for now and gets populated incrementally as subpackages migrate. At release time, release.yml rewrites those entries to versioned GitHub Release URLs at build time. Assisted-by: Claude Opus 4.7 (1M context) Signed-off-by: Avinash Balakrishnan <avinash.bala@us.ibm.com>
Generates a subpackage scaffold with the namespace package physically on disk: `<subpkg>/mellea_contribs/<name>/<core mirror>/...`. The wheel layout matches the source layout, no hatch sources remap needed; this is the only shape that works with editable installs (uv sync) when the import path is `mellea_contribs.<name>.<...>`. Includes the empty mirror skeleton (stdlib, backends, formatters, helpers, core), a stub module, smoke test, basic example, OWNERS, README, and a pyproject with a [tool.mellea-contribs.ci] block. The pre-gen hook validates snake_case name and rejects unknown core_path values against core_paths.json — a snapshot of valid mellea core paths regenerated by the upstream release sync. The post-gen hook creates the inner directory chain under mellea_contribs/<name>/, writes a hello() stub, and runs `uv lock` best-effort. Assisted-by: Claude Opus 4.7 (1M context) Signed-off-by: Avinash Balakrishnan <avinash.bala@us.ibm.com>
Adds a validator that walks every subpackage at the repo root and asserts the structural contract: required files (pyproject.toml, OWNERS, README.md), required dirs (tests/, examples/), non-empty OWNERS, [tool.hatch.build.targets.wheel].packages = ["mellea_contribs"], the namespace dir mellea_contribs/<name>/ with __init__.py, only known mirrors under it, and every nested mirror dir resolving to a known dotted path in cookiecutter/core_paths.json. Subpackages whose dependencies list `mellea` without an explicit version constraint (bare `mellea`, `mellea[extras]` without operator, `mellea @ git+...`) are rejected — the receiver workflow can't reason about those forms and silently skipping them would let incompatibilities through. Distribution-name uniqueness is checked across the whole repo. A grandfather list at .github/scripts/grandfather_legacy.json holds the six legacy subpackages under mellea_contribs/ that pre-date the restructure; each migration shrinks the list. The workflow runs on every PR and on push to main. Assisted-by: Claude Opus 4.7 (1M context) Signed-off-by: Avinash Balakrishnan <avinash.bala@us.ibm.com>
Replaces the old ci.yml with a scoped-discovery + matrix-dispatch pair. The new ci.yml walks the diff against base, classifies the PR (docs-only, cookiecutter-only, root-pyproject, workflow, subpackage, union, or stacked-PR), and dispatches package-ci.yml per touched subpackage. Stacked PRs (base_ref != main) promote to all packages so downstream branches see the cumulative effect of their parent. package-ci.yml is a workflow_call template that reads each subpackage's [tool.mellea-contribs.ci] table for skip_ollama, timeout_minutes, and python_versions, replacing the hardcoded path-string special-cases the previous ci.yml had to carry. The previous ci.yml is preserved as legacy-ci.yml, scoped to mellea_contribs/** so it stops firing once those paths are removed. Assisted-by: Claude Opus 4.7 (1M context) Signed-off-by: Avinash Balakrishnan <avinash.bala@us.ibm.com>
Adds a daily smoke workflow that runs each opted-in subpackage's tests against mellea@main, plus an auto-issue bot that opens a tracking issue after two consecutive reds, comments on recovery, and applies a 21-day archival timeline driven by the contribs-broken label. The smoke matrix in .github/smoke-matrix.json starts empty so the smoke job is skipped until the first subpackage opts in. The bot ships in two modes: an in-memory fake used by the unit tests (14 tests covering the failure threshold, recovery, and the day-7 / 14 / 21 milestones) and a PyGithub-backed real mode that persists state on a bot-managed branch. Assisted-by: Claude Opus 4.7 (1M context) Signed-off-by: Avinash Balakrishnan <avinash.bala@us.ibm.com>
c63bab8 to
f11697c
Compare
Adds a section explaining the daily smoke job's gating (.github/smoke-matrix.json), the auto-issue bot's two-consecutive-reds threshold, the contribs-broken label as the source of truth for the 21-day archival timeline, and the day-7 / 14 / 21 milestones. Also shows how to run the bot locally against the in-memory fake. The rest of RELEASING.md still describes the per-subpackage tag release flow that's slated for replacement when the coordinated release pipeline lands; this commit only documents the smoke-bot lifecycle that the foundation work just introduced. Assisted-by: Claude Opus 4.7 (1M context) Signed-off-by: Avinash Balakrishnan <avinash.bala@us.ibm.com>
planetf1
left a comment
There was a problem hiding this comment.
Eight findings inline — see comments. Critical issues around the bot state-branch write permissions and concurrent-write race; fix suggestions included throughout.
| workflow_dispatch: | ||
|
|
||
| permissions: | ||
| contents: read |
There was a problem hiding this comment.
Two related issues here.
Permission: the bot writes the state JSON back to the bot-state branch, so contents: read will 403 on every save — nothing ever persists.
| contents: read | |
| contents: write |
Race condition: before doing that though, it's worth tackling why each leg writes independently. The smoke matrix runs legs in parallel and each one does a read-modify-write against the same JSON blob on the same branch. Concurrent runs either silently clobber each other's counter updates (last-write-wins), or hit a 409 SHA conflict that the except Exception: in _save_persistent_state swallows before trying create_file on an already-existing file — unhandled 422, step crashes.
The cleaner fix for both: have each leg emit its outcome as a job output or artifact, then run the bot once in a post-matrix aggregation job that sees all results together:
aggregate:
needs: smoke
if: always()
permissions:
contents: write
issues: writeSmoke legs stay at contents: read, the bot only ever runs once per workflow invocation, no concurrent writes possible. Needs a small addition to the bot to accept bulk outcomes rather than a single package — but probably less work than making the per-leg writes race-safe.
| workflow_dispatch: | ||
|
|
||
| permissions: | ||
| contents: read |
There was a problem hiding this comment.
Same issue as smoke — the bot writes to the bot-state branch during apply-archival, so this needs contents: write too.
| contents: read | |
| contents: write |
| working-directory: ${{ matrix.package }} | ||
| run: uv run pytest -m "not qualitative and not e2e" | ||
| - name: Record failure with auto-issue bot | ||
| if: failure() && steps.pytest.outcome == 'failure' |
There was a problem hiding this comment.
record-failure only fires when pytest itself reports failure, but if the install step blows up first, pytest never runs — its outcome comes back as skipped and the condition is false. An install break against mellea@main silently goes unreported, which is arguably the most important thing to catch.
failure() alone covers both cases — it fires if any step in the job failed:
| if: failure() && steps.pytest.outcome == 'failure' | |
| if: failure() |
| except Exception: # pragma: no cover - first run / missing branch | ||
| return BotState() |
There was a problem hiding this comment.
The except Exception: here catches everything — a transient 500, a rate-limit 403, a network timeout, malformed JSON — and in all those cases silently returns a fresh empty BotState. That then gets written back, wiping whatever was actually stored.
Same pattern in _save_persistent_state (line 449): a 409 SHA conflict hits the except, tries create_file on an already-existing path, gets a 422, and crashes with no useful message.
Both should only catch the genuine "file doesn't exist" case. Needs a module-scope from github import GithubException, then:
| except Exception: # pragma: no cover - first run / missing branch | |
| return BotState() | |
| except GithubException as e: | |
| if e.status != 404: | |
| raise | |
| return BotState() |
| jobs: | ||
| package-ci: | ||
| runs-on: ubuntu-latest | ||
| timeout-minutes: 30 # Default; subpackages override via [tool.mellea-contribs.ci] |
There was a problem hiding this comment.
timeout_minutes and python_versions are both parsed from [tool.mellea-contribs.ci] and emitted as step outputs, but neither is actually consumed — the timeout is hard-coded here and there's no Python matrix. skip_ollama is wired up, so the gap is pretty visible.
The timeout one matters immediately: crewai_backend runs at 90 minutes in legacy CI and will get silently killed at 30 on migration.
| timeout-minutes: 30 # Default; subpackages override via [tool.mellea-contribs.ci] | |
| timeout-minutes: ${{ fromJson(steps.ci-flags.outputs.timeout_minutes) }} |
python_versions is the same story but needs the job split into two to use a dynamic matrix — that's a reasonable follow-up PR before anyone migrates.
|
|
||
| if [ -z "$CHANGED_SUBPACKAGES" ]; then | ||
| # If no subpackages changed, check if workflow files changed | ||
| WORKFLOW_CHANGED=$(git diff --name-only $BASE_REF HEAD | grep -E "\.github/workflows/(ci\.yml|quality-generic\.yml)" || true) |
There was a problem hiding this comment.
This grep catches changes to ci.yml — but ci.yml is now the new workflow. A change to legacy-ci.yml itself (which does trigger this workflow) won't set WORKFLOW_CHANGED, so the "run all legacy packages" path never fires when you edit the legacy harness.
| WORKFLOW_CHANGED=$(git diff --name-only $BASE_REF HEAD | grep -E "\.github/workflows/(ci\.yml|quality-generic\.yml)" || true) | |
| WORKFLOW_CHANGED=$(git diff --name-only $BASE_REF HEAD | grep -E "\.github/workflows/(legacy-ci\.yml|quality-generic\.yml)" || true) |
| # NOTE: Ollama installation lives outside the matrix (single-version test for v1). | ||
| - name: Install Ollama | ||
| if: steps.ci-flags.outputs.skip_ollama != 'true' | ||
| run: curl -fsSL https://ollama.com/install.sh | sh |
There was a problem hiding this comment.
Pipe-to-shell from an external origin with no checksum — if ollama.com ever serves a bad script (CDN issue, compromised account), every CI run executes it with your GITHUB_TOKEN in the environment. This is the same supply-chain vector as the tj-actions incident earlier this year.
Worth pinning to a specific release and verifying the SHA, or switching to a pinned container image.
| except Exception: # pragma: no cover - first commit on the branch | ||
| repo.create_file( | ||
| path=STATE_FILE_PATH, | ||
| message=commit_message, | ||
| content=payload, | ||
| branch=branch, | ||
| ) |
There was a problem hiding this comment.
Same root cause as the _load_persistent_state issue above — except Exception: here catches a 409 SHA conflict (from the concurrent-write race) and falls through to create_file on an already-existing path, which raises an unhandled 422 and crashes the step. Same fix applies:
| except Exception: # pragma: no cover - first commit on the branch | |
| repo.create_file( | |
| path=STATE_FILE_PATH, | |
| message=commit_message, | |
| content=payload, | |
| branch=branch, | |
| ) | |
| except GithubException as e: | |
| if e.status != 404: | |
| raise | |
| repo.create_file( | |
| path=STATE_FILE_PATH, | |
| message=commit_message, | |
| content=payload, | |
| branch=branch, | |
| ) |
| if [ -z "$BASE_SHA" ] || [ "$BASE_SHA" = "0000000000000000000000000000000000000000" ]; then | ||
| BASE_SHA="HEAD~1" | ||
| fi | ||
| CHANGED=$(git diff --name-only "$BASE_SHA"...HEAD || git ls-files) |
There was a problem hiding this comment.
If the git diff fails (e.g. a shallow clone with a stale base SHA), this falls back to git ls-files — every tracked file in the repo. That trips the root trigger and silently promotes the PR to a full matrix run, with no indication anything went wrong.
Better to fail loudly so the cause is diagnosable:
| CHANGED=$(git diff --name-only "$BASE_SHA"...HEAD || git ls-files) | |
| CHANGED=$(git diff --name-only "$BASE_SHA"...HEAD) |
…low config/ The validate-structure rule that requires explicit mellea>= or mellea== constraints was matching sibling distribution names like mellea-contribs-integration-core and mellea-tools because they start with "mellea". Same prefix-matching bug class as the receiver script's regex; fix is the analogous disambiguation: a hyphen, underscore, letter, or digit immediately after `mellea` means we're looking at a sibling dist, not the upstream package. Also adds `config` to META_DIRS so subpackages that ship YAML configs alongside their code don't trip the unexpected-top-level-directory check. Assisted-by: Claude Opus 4.7 (1M context) Signed-off-by: Avinash Balakrishnan <avinash.bala@us.ibm.com>
Concurrent smoke legs previously raced on a single bot-state JSON file's blob SHA: two legs would load the same SHA, the first update_file call would win, the second would 409, fall through to create_file and 422 unhandled, losing the counter update. With contents:read also missing on smoke, write attempts were silently 403'd inside a broad except, making fresh BotState() reload look like a successful save. Switch storage to one JSON file per subpackage at .github/bot-state/<package>.json. record-failure / record-recovery touch only their own package's path, so legs running in parallel write disjoint paths and never share a SHA. apply-archival lists the dir, loads each file, runs the timeline, and saves each back. Exception handling is narrowed to GithubException with explicit status checks (404 = missing -> create; anything else propagates) instead of swallowing every error. All 14 existing tests pass unchanged. Assisted-by: Claude Code Signed-off-by: Avinash Balakrishnan <avinash.bala@us.ibm.com>
Both smoke-against-mellea-main and auto-issue-archival call auto_issue_bot.py, which now commits per-package state files to .github/bot-state/ on the default branch. With contents:read these writes 403 silently (the bot's wrapper used to swallow the exception and re-create a fresh BotState, masking the failure). After commit 1 the wrapper raises on permission errors, so without this elevation both workflows would start failing loudly. Assisted-by: Claude Code Signed-off-by: Avinash Balakrishnan <avinash.bala@us.ibm.com>
The smoke job's failure-recording step gated on `steps.pytest.outcome == 'failure'`, but that output is unset when an earlier step (e.g. `uv sync` or the mellea@main pip install) crashed before pytest ran. Those breakages would surface only as red workflow runs with no auto-issue created. Drop the pytest-only gate; rely on `if: failure()` so the bot fires for any failure in the leg. Assisted-by: Claude Code Signed-off-by: Avinash Balakrishnan <avinash.bala@us.ibm.com>
ci.yml: drop the `|| git ls-files` fallback on the base-ref diff. If $BASE_SHA is wrong or unfetched, the fallback silently runs the full matrix on every PR, hiding the underlying issue. Let the step fail loudly instead. legacy-ci.yml: the workflow-touched fallback grepped for `ci.yml`, which is now a separate file under the new structure. Update the pattern to `legacy-ci.yml` so changes to this workflow correctly trigger the full subpackage matrix. Assisted-by: Claude Code Signed-off-by: Avinash Balakrishnan <avinash.bala@us.ibm.com>
`timeout-minutes` was hardcoded to 30 with a comment claiming subpackages override via [tool.mellea-contribs.ci.timeout_minutes], but the toml read ran inside the same job — `timeout-minutes` is resolved when the job starts, so the step output never reached it. Hoist the toml read into a prerequisite `read-config` job and let `package-ci` consume its outputs. Also drop `curl https://ollama.com/install.sh | sh` in favour of a pinned release tarball (currently v0.5.7). Piping the upstream script directly to sh is the supply-chain shape we want to avoid in CI; pinning the release artifact gives us a deterministic install with a versionable upgrade path. Assisted-by: Claude Code Signed-off-by: Avinash Balakrishnan <avinash.bala@us.ibm.com>
|
Thanks @planetf1, pushed 5 commits. The race was the interesting one. After tracing it, the legs were actually writing disjoint keys ( Rest of the findings:
|
Foundation work for the contribs repo restructure: a root meta-package,
the cookiecutter template that generates new subpackages in their
target shape, the structural-validation gate, a new CI workflow that
scopes the matrix to PR-touched packages, and a daily smoke against
mellea@main with an auto-issue bot for the 21-day archival lifecycle.
No subpackages migrate in this PR; all six legacy subpackages keep
running through
legacy-ci.ymluntil each one is ported in a later PR.Landed in this PR
uv.lock(ruff, mypy, cookiecutter,pre-commit, pytest, PyGithub)
<subpkg>/mellea_contribs/<name>/<core mirror>/...with
[tool.mellea-contribs.ci]defaults and core-path validationvalidate-structurescript + workflow: required files/dirs,hatch wheel
packages = ["mellea_contribs"], namespace packageshape, explicit
melleaversion constraints, distribution-nameuniqueness; legacy subpackages grandfathered
ci.ymlwith PR-scoped discovery (docs-only,cookiecutter-only, root, subpackage, union, stacked-PR), reusable
package-ci.ymlreading[tool.mellea-contribs.ci]flagsci.ymlpreserved aslegacy-ci.yml, scoped tomellea_contribs/**smoke-against-mellea-main.yml+.github/smoke-matrix.jsongating file (starts empty; subpackages opt in as they migrate)
auto_issue_bot.py) with PyGithub real modeand an in-memory fake for tests; tracks the 2-consecutive-reds
threshold, recovery comments, and the day-7 / 14 / 21
archival-label timeline (14 unit tests)
auto-issue-archival.ymlworkflow that runs the bot's archivalpass once daily
Followups (separate PRs)
flow, per-package bump tier flow, straggler-exclusion policy,
post-merge tag push that fires
release.yml)through the cookiecutter template end-to-end (command invocation,
generated file tour, where implementation code goes, how to run
tests locally, what CI runs on the resulting PR)
How to verify locally