Skip to content

feat: add scaffolding to migrate subpackages to the new layout#64

Open
avinash2692 wants to merge 16 commits into
generative-computing:mainfrom
avinash2692:feat/restructure-foundation
Open

feat: add scaffolding to migrate subpackages to the new layout#64
avinash2692 wants to merge 16 commits into
generative-computing:mainfrom
avinash2692:feat/restructure-foundation

Conversation

@avinash2692

@avinash2692 avinash2692 commented Jun 2, 2026

Copy link
Copy Markdown
Member

Stacked on #63. This PR's diff includes the receiver-workflow
commits from #63 until that PR merges. Review can proceed in
parallel; merge order is #63 first, then this PR.

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.yml until each one is ported in a later PR.

Landed in this PR

  • Root meta-package + dev-only uv.lock (ruff, mypy, cookiecutter,
    pre-commit, pytest, PyGithub)
  • Cookiecutter template that generates <subpkg>/mellea_contribs/<name>/<core mirror>/...
    with [tool.mellea-contribs.ci] defaults and core-path validation
  • validate-structure script + workflow: required files/dirs,
    hatch wheel packages = ["mellea_contribs"], namespace package
    shape, explicit mellea version constraints, distribution-name
    uniqueness; legacy subpackages grandfathered
  • New ci.yml with PR-scoped discovery (docs-only,
    cookiecutter-only, root, subpackage, union, stacked-PR), reusable
    package-ci.yml reading [tool.mellea-contribs.ci] flags
  • Old ci.yml preserved as legacy-ci.yml, scoped to
    mellea_contribs/**
  • Daily smoke-against-mellea-main.yml + .github/smoke-matrix.json
    gating file (starts empty; subpackages opt in as they migrate)
  • Auto-issue bot (auto_issue_bot.py) with PyGithub real mode
    and 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.yml workflow that runs the bot's archival
    pass once daily

Followups (separate PRs)

  • Documentation: contribs release process (coordinated single-tag
    flow, per-package bump tier flow, straggler-exclusion policy,
    post-merge tag push that fires release.yml)
  • Documentation: contributor "create your package" guide walking
    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

# Cookiecutter dry-run (generates a working subpackage):
uv run --with cookiecutter cookiecutter ./cookiecutter \
  --no-input name=demo core_path=stdlib.sampling_algos
cd demo && uv sync && uv run pytest -v   # 1 PASS

# Foundation tests (51 total):
uv run pytest -c pyproject.toml --rootdir=. .github/scripts/ -v
# 14 validate-structure + 8 discover_subpackages + 14 auto-issue bot
# + 8 update_contribs_versions + 7 open_per_package_bump_prs

# validate-structure against the live repo (legacy paths grandfathered):
uv run python .github/scripts/validate_package_contract.py
# validate-structure: PASS - all subpackages conform.

@github-actions github-actions Bot added the enhancement New feature or request label Jun 2, 2026
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>
@avinash2692 avinash2692 force-pushed the feat/restructure-foundation branch from 59c7313 to c63bab8 Compare June 2, 2026 23:24
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>
@avinash2692 avinash2692 force-pushed the feat/restructure-foundation branch from c63bab8 to f11697c Compare June 3, 2026 16:50
@avinash2692 avinash2692 marked this pull request as ready for review June 3, 2026 16:55
@avinash2692 avinash2692 requested a review from a team as a code owner June 3, 2026 16:55
@avinash2692 avinash2692 requested review from akihikokuroda and planetf1 and removed request for a team June 3, 2026 16:55
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 planetf1 left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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.

Suggested change
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: write

Smoke 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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Same issue as smoke — the bot writes to the bot-state branch during apply-archival, so this needs contents: write too.

Suggested change
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'

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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:

Suggested change
if: failure() && steps.pytest.outcome == 'failure'
if: failure()

Comment thread .github/scripts/auto_issue_bot.py Outdated
Comment on lines +425 to +426
except Exception: # pragma: no cover - first run / missing branch
return BotState()

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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:

Suggested change
except Exception: # pragma: no cover - first run / missing branch
return BotState()
except GithubException as e:
if e.status != 404:
raise
return BotState()

Comment thread .github/workflows/package-ci.yml Outdated
jobs:
package-ci:
runs-on: ubuntu-latest
timeout-minutes: 30 # Default; subpackages override via [tool.mellea-contribs.ci]

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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.

Suggested change
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.

Comment thread .github/workflows/legacy-ci.yml Outdated

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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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.

Suggested change
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)

Comment thread .github/workflows/package-ci.yml Outdated
# 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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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.

Comment thread .github/scripts/auto_issue_bot.py Outdated
Comment on lines +449 to +455
except Exception: # pragma: no cover - first commit on the branch
repo.create_file(
path=STATE_FILE_PATH,
message=commit_message,
content=payload,
branch=branch,
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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:

Suggested change
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,
)

Comment thread .github/workflows/ci.yml Outdated
if [ -z "$BASE_SHA" ] || [ "$BASE_SHA" = "0000000000000000000000000000000000000000" ]; then
BASE_SHA="HEAD~1"
fi
CHANGED=$(git diff --name-only "$BASE_SHA"...HEAD || git ls-files)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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:

Suggested change
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>
@avinash2692 avinash2692 requested a review from planetf1 June 8, 2026 17:20
@avinash2692

Copy link
Copy Markdown
Member Author

Thanks @planetf1, pushed 5 commits.

The race was the interesting one. After tracing it, the legs were actually writing disjoint keys (consecutive_failures[package] only), so the conflict was entirely substrate-level: one JSON blob, one SHA, contention. Splitting storage to .github/bot-state/<package>.json matches the access pattern, so parallel legs never share a path.

Rest of the findings:

  • contents: write on smoke + archival, narrowed except Exception
  • if: failure() so install breaks against mellea@main actually report
  • legacy-ci.yml grep was matching ci.yml (the new file); also dropped
    || git ls-files in ci.yml so a bad base SHA fails loud
  • package-ci.yml: hoisted toml read into a read-config job so
    timeout-minutes actually consumes it; pinned Ollama to a release tarball

python_versions is parsed but unwired, left for a follow-up since matrix expansion is its own change. Bot tests still green (14 passed). Please take a look and let me know.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants