Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions .claude/memory/mise-monorepo.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,5 +73,24 @@ apple's `test`/`fmt`/`lint` tasks works normally via `[vars] package_path = "Cor
the tuist-specific `build`/`generate`/`test:app`/`launch:macos` tasks have no
family template and stay inline permanently.

## Dependency-update gate (repo-global)

`EnsureRootMise` also injects a **repo-global Renovate deps gate** into the root
(every monorepo, from the first member): `ensureDepsGate` merges
`node`/`npm:renovate`/`jq` into the single root `[tools]` (deduped vs family pins,
never overwrites a user pin) and appends `[tasks.deps]` (advisory local dry-run —
never opens PRs, always exits 0) + `[tasks.check]`. `wireMonorepo` then drops
`renovate.json` + `scripts/deps-check.sh` at the repo root via
`EnsureDepsGateFiles` (skip-existing). **One ecosystem-agnostic gate covers all
members — it is NOT a family contribution**, so the swift-no-tools invariant holds
and Go/Swift stacks gain no node tooling of their own. There is **no per-member
deps task and no `dependabot.yml`** (the web scaffold's was removed). Source files
live at `internal/coreassets/templates/monorepo/{renovate.json,deps-check.sh}`.

**Gotcha:** the `npm:renovate` mise tool-backend key has a colon, so it must
serialize **quoted** (`"npm:renovate" = "latest"`) or the root mise.toml fails to
parse — `tomlKey` in `internal/scaffold/monorepo.go` handles this; the idempotency
compare still uses the decoded bare key.

See [[dev-workflow]] for the CI gate. Design: `docs/design/mise-monorepo.md`.
Implementation plan (historical): `docs/design/mise-monorepo-plan.md`.
7 changes: 5 additions & 2 deletions BACKLOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,8 +68,11 @@ so the offline guarantee holds structurally.**
- ✅ **Pillar 2 — Issues as ephemeral defect intake.** `specify issues list|create|close`
(confirmation-gated, `--json`); the `github/` scaffold subtree now drops the full
per-target `.github/` (`PULL_REQUEST_TEMPLATE.md`, `ISSUE_TEMPLATE/defect.yml` stamping
`type: Bug` + label fallback, `config.yml`, `CODEOWNERS` for `/features` `/specs`,
`dependabot.yml`). No durable issue↔scenario link (GitHub cross-refs). Close-on-green is
`type: Bug` + label fallback, `config.yml`, `CODEOWNERS` for `/features` `/specs`).
Dependency updates moved off Dependabot to a single repo-global **Renovate** gate
wired into the mise-monorepo root (`mise run deps`/`check`, `renovate.json` +
`scripts/deps-check.sh`; advisory dry-run, never opens PRs — one ecosystem-agnostic
gate for all members). No durable issue↔scenario link (GitHub cross-refs). Close-on-green is
the discipline.
- ✅ **Pillar 3 — Projects board (Beads-informed).** `specify work ready|claim|move|discover`
on the inlined Projects v2 GraphQL client (resolve project/fields/options, add item, set
Expand Down
5 changes: 5 additions & 0 deletions cmd/specify/monorepo.go
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,11 @@ func wireMonorepo(root string) error {
if _, err := scaffold.EnsureRootMise(root, families, dirs); err != nil {
return err
}
// Drop the repo-global dependency-update gate's static files at the root
// (skip-existing). The root mise.toml's deps/check tasks reference them.
if _, err := scaffold.EnsureDepsGateFiles(coreassets.FS, root); err != nil {
return err
}
// Promote members of any hoisted family (safe: only canonical tasks convert).
for _, m := range members {
fam := loaded[m.family]
Expand Down
14 changes: 14 additions & 0 deletions cmd/specify/monorepo_e2e_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,20 @@ func TestWireMonorepoInlineThenPromote(t *testing.T) {
if strings.Contains(read(t, filepath.Join(root, "apps/web/mise.toml")), "extends =") {
t.Error("single member must stay inline (no extends)")
}
// The repo-global dependency-update gate lands at the root (real embedded
// assets): the deps/check tasks in mise.toml plus renovate.json + the script.
if !strings.Contains(rootMise, "[tasks.deps]") || !strings.Contains(rootMise, `"npm:renovate" = "latest"`) {
t.Errorf("root mise.toml missing the deps gate:\n%s", rootMise)
}
for _, f := range []string{"renovate.json", "scripts/deps-check.sh"} {
if _, err := os.Stat(filepath.Join(root, f)); err != nil {
t.Errorf("deps gate file %s not written to root: %v", f, err)
}
}
// The member must NOT carry the gate — it's repo-global, not per-member.
if strings.Contains(read(t, filepath.Join(root, "apps/web/mise.toml")), "renovate") {
t.Error("web member must not carry renovate (gate is repo-global)")
}

// --- member 2: apps/web2 (promotion) ---
writeWebMember(t, root, "apps/web2")
Expand Down
8 changes: 7 additions & 1 deletion docs/design/github-integration.md
Original file line number Diff line number Diff line change
Expand Up @@ -179,7 +179,13 @@ the same hierarchy Beads gets from parent-child. *Caveat:* Issue Types are an
| `ISSUE_TEMPLATE/defect.yml` | a defect form — stamps `type: Bug`, a label, the project; prompts for repro + the target |
| `ISSUE_TEMPLATE/config.yml` | points docs at *this* repo |
| `CODEOWNERS` | maps `/features/**` and `/specs/**` to the spec owner, so **spec changes require human review** |
| `dependabot.yml` | for the stack's ecosystem (web → npm, go → gomod) |

_No `dependabot.yml` is projected. Dependency updates are surfaced by a single
repo-global **Renovate** gate wired into the mise-monorepo root — `mise run deps`
(an advisory local dry-run that never opens PRs) and the `check` aggregate, with
`renovate.json` + `scripts/deps-check.sh` at the repo root. One ecosystem-agnostic
gate covers every member, so no per-stack Dependabot config and no node tooling
is forced on the Go/Swift stacks._

## Pillar 3 — Projects as the work surface (Beads-informed, simplified)

Expand Down
17 changes: 17 additions & 0 deletions docs/design/mise-monorepo.md
Original file line number Diff line number Diff line change
Expand Up @@ -136,17 +136,34 @@ pnpm = "11"
gh = "2.94"
1password = "2"
go = "1.26"
"npm:renovate" = "latest" # deps gate (repo-global, every monorepo)
jq = "latest"

# ---- node family (written only once the node family has ≥2 members) ----
[task_templates."node:test"] …
# ---- go family ----
[task_templates."go:test"] …

# ---- dependency-update gate (repo-global, from the first member) ----
[tasks.deps] # advisory local Renovate dry-run; never opens PRs
run = "bash scripts/deps-check.sh"
[tasks.check] # repo-wide aggregate gate
depends = ["deps"]
```

`monorepo_root`, `[monorepo]`, and `[tools]` are present from the first member;
a family's `[task_templates]` block appears only when that family gains its
second member (see [promotion](#promotion-the-member-1-conversion)).

**Dependency-update gate.** From the first member, the root also gets a single
repo-global Renovate gate: the `node`/`npm:renovate`/`jq` tool pins, the
`[tasks.deps]` (advisory local dry-run — never opens PRs, always exits 0) and
`[tasks.check]` tasks, and `renovate.json` + `scripts/deps-check.sh` at the repo
root (skip-existing). Renovate is ecosystem-agnostic, so one gate covers every
member (npm, gomod, SwiftPM, Actions, Docker, mise tools) — there's no per-stack
Dependabot config and the Go/Swift stacks gain no node tooling of their own. The
colon in the `npm:renovate` mise tool-backend key forces TOML quoting (`tomlKey`).

It is **partly managed, partly the user's**: SpecKit only ever *adds* its
managed entries (idempotent add-if-absent); the user may add their own
`[tasks.*]`, `[env]`, extra tools, and comments anywhere, and they survive every
Expand Down
5 changes: 4 additions & 1 deletion docs/usage/github.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,10 @@ specify issues close <issue#> # close on green
| `ISSUE_TEMPLATE/config.yml` | disables blank issues, links out to the SpecKit docs |
| `PULL_REQUEST_TEMPLATE.md` | a spec-touch checklist (specs changed? scenarios bound? `verify` green? `drift` clean?) |
| `CODEOWNERS` | routes `/features/`, `/specs/`, and `/.speckit/` to the spec owner, so **spec changes require human review** (replace `@OWNER` with a user or team) |
| `dependabot.yml` | for the stack's ecosystem (web → npm) |

_Dependency updates aren't a GitHub surface: the mise-monorepo root ships a
repo-global **`deps`** task (a local Renovate dry-run that never opens PRs, wired
into `mise run check`) plus `renovate.json`, so no `dependabot.yml` is projected._

The lifecycle is **scenario-canonical**:

Expand Down
94 changes: 94 additions & 0 deletions internal/coreassets/templates/monorepo/deps-check.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
#!/usr/bin/env bash
# Local dependency-update gate.
#
# Runs Renovate against the working tree in local/dry-run mode — it NEVER opens
# PRs or writes anything; it just reports which dependencies (npm packages, Go
# modules, Swift packages, GitHub Actions, mise tools, …) have newer versions
# available. Wired into `mise run check` so updates surface as ongoing work
# instead of as autonomously-opened Dependabot PRs.
#
# One repo-global gate covers every member of the monorepo at once — Renovate is
# ecosystem-agnostic, so a single root-level scan finds npm, gomod, SwiftPM,
# GitHub-Actions and Docker updates across all targets.
#
# Advisory only: always exits 0. Available updates are a heads-up, not a failure,
# and a Renovate/network hiccup must not break the rest of `check`.
set -uo pipefail

if ! command -v renovate >/dev/null 2>&1; then
echo "deps: renovate not found — enable the gate with: mise use 'npm:renovate@latest' jq"
exit 0
fi

# Renovate needs a github.com token to look up Actions / Go-module / Swift-package
# release versions (npm needs none). Reuse the user's existing gh auth when
# present; anonymous still works for npm, just rate-limited on github.com.
github_token="$(gh auth token 2>/dev/null || true)"

log="$(mktemp -t renovate.XXXXXX)"
trap 'rm -f "$log"' EXIT

GITHUB_COM_TOKEN="$github_token" \
RENOVATE_ONBOARDING=false \
LOG_LEVEL=debug \
LOG_FORMAT=json \
renovate --platform=local --dry-run=full >"$log" 2>&1
status=$?

if [ "$status" -ne 0 ]; then
echo "deps: renovate exited $status (skipping the update check this run)"
grep -iE '"level":(50|60)|FATAL|ERROR' "$log" | head -5
exit 0
fi

# All available updates live in the single "packageFiles with updates" debug
# record: walk every dependency that carries a non-empty updates[] array and
# emit one row per update — updateType, ecosystem, name, current → target.
# Everything Renovate found is surfaced; scope what it *looks at* in renovate.json.
updates="$(jq -r '
select(.msg == "packageFiles with updates")
| [.. | objects | select(.depName? and (.updates? | type == "array") and ((.updates | length) > 0))]
| .[] as $dep
| $dep.updates[]
| "\(.updateType // "?")\t\($dep.datasource // "-")\t\($dep.depName)\t\($dep.currentValue // $dep.currentVersion // "-")\t\(.newVersion // .newValue)"
' "$log" | sort -u | sort -t"$(printf '\t')" -k2,2 -k1,1 -k3,3)"

if [ -z "$updates" ]; then
echo "deps: ✓ all dependencies up to date"
exit 0
fi

count="$(printf '%s\n' "$updates" | grep -c '')"
echo "deps: ⚠ $count update(s) available — local Renovate dry-run, no PRs opened"
echo
# Majors get their own flagged section (each is a breaking-change decision, not
# routine work); the in-range minor/patch updates follow, grouped by ecosystem.
printf '%s\n' "$updates" | awk -F'\t' '
{
type[NR] = $1; eco[NR] = $2; name[NR] = $3; cur[NR] = $4; tgt[NR] = $5
if ($1 == "major") majors++
else inrange[$2]++
}
END {
if (majors > 0) {
printf " major — review before upgrading (%d)\n", majors
for (i = 1; i <= NR; i++)
if (type[i] == "major")
printf " %-7s %-34s %s → %s\n", eco[i], name[i], cur[i], tgt[i]
print ""
}
prev = ""
for (i = 1; i <= NR; i++) {
if (type[i] == "major") continue
if (eco[i] != prev) {
if (prev != "") print ""
printf " %s (%d)\n", eco[i], inrange[eco[i]]
prev = eco[i]
}
printf " %-6s %-34s %s → %s\n", type[i], name[i], cur[i], tgt[i]
}
}'
echo
echo " In-range bumps: apply them with your stack's package manager, then re-run \`mise run deps\`."
echo " Majors are listed separately — review each one's changelog before upgrading."
exit 0
5 changes: 5 additions & 0 deletions internal/coreassets/templates/monorepo/renovate.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": ["config:recommended"],
"dependencyDashboard": false
}

This file was deleted.

109 changes: 107 additions & 2 deletions internal/scaffold/monorepo.go
Original file line number Diff line number Diff line change
Expand Up @@ -242,6 +242,10 @@ func EnsureRootMise(root string, families []Family, targetDirs []string) (bool,
}
}
}
// Inject the repo-global dependency-update gate (tools + deps/check tasks).
if data, err = ensureDepsGate(data); err != nil {
return false, err
}
// Append any hoisted family's templates that aren't present yet.
for _, fam := range families {
if !fam.Hoist || fam.Raw == "" {
Expand Down Expand Up @@ -293,7 +297,7 @@ func rootSkeleton(families []Family, targetDirs []string) string {
if len(pins) > 0 {
b.WriteString("\n[tools]\n")
for _, p := range pins {
b.WriteString(p.Key + " = \"" + p.Val + "\"\n")
b.WriteString(tomlKey(p.Key) + " = \"" + p.Val + "\"\n")
}
}
return b.String()
Expand Down Expand Up @@ -424,11 +428,112 @@ func ensureTools(data []byte, pins []ToolPin) ([]byte, error) {
continue
}
at := sectionEnd(ex, toolsIdx)
data = splice(data, at, "\n"+pin.Key+" = \""+pin.Val+"\"")
data = splice(data, at, "\n"+tomlKey(pin.Key)+" = \""+pin.Val+"\"")
}
return data, nil
}

// tomlKey renders k as a TOML bare key, quoting it only when it carries a
// character outside the bare-key set (A–Z a–z 0–9 _ -). The mise tool-backend
// syntax `npm:renovate` has a colon, so it must serialize as `"npm:renovate"`
// or the document fails to parse. The parsed/decoded key is always bare, so the
// idempotency comparison in ensureTools still works against the unquoted form.
func tomlKey(k string) string {
for _, r := range k {
if !(r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9' || r == '_' || r == '-') {
return `"` + k + `"`
}
}
return k
}

// depsGateTools are the toolchain pins the repo-global dependency-update gate
// needs: node (to run the Renovate npm CLI — pinned to the node family's default
// so a node repo dedupes to one entry), the renovate CLI itself, and jq (the
// report formatter). They merge into the root [tools] table for every monorepo,
// node-family or not; ensureTools never overwrites a user-pinned version.
func depsGateTools() []ToolPin {
return []ToolPin{
{"node", "24"},
{"npm:renovate", "latest"},
{"jq", "latest"},
}
}

// depsGateTasks is the [tasks.deps] + [tasks.check] block appended to the root
// mise.toml. `deps` runs the advisory Renovate dry-run (never opens PRs, always
// exits 0); `check` is the repo-wide aggregate gate, today just the deps check.
const depsGateTasks = `# Local dependency-update gate: ` + "`mise run deps`" + ` runs Renovate over the whole
# repo in local dry-run mode (never opens PRs, always exits 0) and reports the
# available updates grouped by ecosystem, majors flagged separately. One gate
# covers every member — Renovate is ecosystem-agnostic. See scripts/deps-check.sh.
[tasks.deps]
description = "report available dependency updates (local Renovate dry-run; no PRs)"
run = "bash scripts/deps-check.sh"

# Repo-wide aggregate gate. Per-member quality gates (fmt/lint/typecheck/test)
# live in each member's mise.toml; this is where repo-level checks aggregate.
[tasks.check]
description = "run the repo-wide gates (currently: the dependency-update check)"
depends = ["deps"]
run = ":"
`

// ensureDepsGate injects the repo-global dependency-update gate into the root
// mise.toml: the deps-gate [tools] pins (merged into the single [tools] table)
// and the [tasks.deps]/[tasks.check] block (appended once). Idempotent — keyed
// on the presence of [tasks.deps].
func ensureDepsGate(data []byte) ([]byte, error) {
var err error
if data, err = ensureTools(data, depsGateTools()); err != nil {
return nil, err
}
if bytes.Contains(data, []byte("[tasks.deps]")) {
return data, nil
}
if !bytes.HasSuffix(data, []byte("\n")) {
data = append(data, '\n')
}
data = append(data, '\n')
return append(data, depsGateTasks...), nil
}

// EnsureDepsGateFiles writes the dependency-update gate's static repo-root files
// — renovate.json and scripts/deps-check.sh — from the assets FS's
// templates/monorepo/. A file that already exists is left untouched (a repo's
// own renovate.json or an edited script survives re-runs); the script is written
// executable. Returns the project-relative paths it created.
func EnsureDepsGateFiles(assets fs.FS, projectRoot string) ([]string, error) {
specs := []struct {
src, dst string
mode os.FileMode
}{
{"templates/monorepo/renovate.json", "renovate.json", 0o644},
{"templates/monorepo/deps-check.sh", filepath.Join("scripts", "deps-check.sh"), 0o755},
}
var created []string
for _, s := range specs {
dst := filepath.Join(projectRoot, s.dst)
if _, err := os.Stat(dst); err == nil {
continue // skip-existing
} else if !os.IsNotExist(err) {
return created, err
}
body, err := fs.ReadFile(assets, s.src)
if err != nil {
return created, err
}
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
return created, err
}
if err := os.WriteFile(dst, body, s.mode); err != nil {
return created, err
}
created = append(created, s.dst)
}
return created, nil
}

// PromoteMember rewrites the member mise.toml at path in place, converting each
// inline [tasks.X] whose `run` still equals the family template X's canonical run
// (after the member's own [vars] substitution) into `extends = "<family>:X"`.
Expand Down
Loading