From 55a5bcc0746e1126eb588db5b1f23f199da64454 Mon Sep 17 00:00:00 2001 From: Mark Malstrom Date: Tue, 16 Jun 2026 19:45:39 -0500 Subject: [PATCH 1/3] scaffold: add a repo-global Renovate deps gate to the monorepo root MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the (never-merged) per-member Dependabot→Renovate approach with a single repo-global dependency-update gate wired into the mise-monorepo root, matching the architecture that landed after that PR was written (members carry no [tools]; the swift family contributes none at all). wireMonorepo now, alongside maintaining the root mise.toml: - injects the gate's [tools] (node, npm:renovate, jq) into the root's single [tools] table (deduped against family pins; never overwrites a user pin), - appends [tasks.deps] (advisory local Renovate dry-run — never opens PRs, always exits 0) and [tasks.check] (repo-wide aggregate), - drops renovate.json + scripts/deps-check.sh at the repo root (skip-existing). One gate covers every member — Renovate is ecosystem-agnostic — so there's no per-stack duplication and Swift stacks gain no node tooling of their own. New tomlKey() quotes mise tool-backend keys like `npm:renovate` (the colon makes the bare form invalid TOML) while keeping the idempotency comparison on the decoded bare key. Covered by scaffold unit tests + the monorepo e2e test (real embedded assets, member stays gate-free). --- cmd/specify/monorepo.go | 5 + cmd/specify/monorepo_e2e_test.go | 14 +++ .../templates/monorepo/deps-check.sh | 94 +++++++++++++++ .../templates/monorepo/renovate.json | 5 + internal/scaffold/monorepo.go | 109 +++++++++++++++++- internal/scaffold/monorepo_test.go | 104 +++++++++++++++++ 6 files changed, 329 insertions(+), 2 deletions(-) create mode 100755 internal/coreassets/templates/monorepo/deps-check.sh create mode 100644 internal/coreassets/templates/monorepo/renovate.json diff --git a/cmd/specify/monorepo.go b/cmd/specify/monorepo.go index 4fe0311bd6..9458d840d2 100644 --- a/cmd/specify/monorepo.go +++ b/cmd/specify/monorepo.go @@ -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] diff --git a/cmd/specify/monorepo_e2e_test.go b/cmd/specify/monorepo_e2e_test.go index e068f76c55..3641830dba 100644 --- a/cmd/specify/monorepo_e2e_test.go +++ b/cmd/specify/monorepo_e2e_test.go @@ -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") diff --git a/internal/coreassets/templates/monorepo/deps-check.sh b/internal/coreassets/templates/monorepo/deps-check.sh new file mode 100755 index 0000000000..230d7138f2 --- /dev/null +++ b/internal/coreassets/templates/monorepo/deps-check.sh @@ -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 diff --git a/internal/coreassets/templates/monorepo/renovate.json b/internal/coreassets/templates/monorepo/renovate.json new file mode 100644 index 0000000000..c3a9215224 --- /dev/null +++ b/internal/coreassets/templates/monorepo/renovate.json @@ -0,0 +1,5 @@ +{ + "$schema": "https://docs.renovatebot.com/renovate-schema.json", + "extends": ["config:recommended"], + "dependencyDashboard": false +} diff --git a/internal/scaffold/monorepo.go b/internal/scaffold/monorepo.go index 904f112398..e9d25f9168 100644 --- a/internal/scaffold/monorepo.go +++ b/internal/scaffold/monorepo.go @@ -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 == "" { @@ -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() @@ -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 = ":X"`. diff --git a/internal/scaffold/monorepo_test.go b/internal/scaffold/monorepo_test.go index c2732a74ac..8cf0474cf6 100644 --- a/internal/scaffold/monorepo_test.go +++ b/internal/scaffold/monorepo_test.go @@ -333,3 +333,107 @@ run = "swift test --package-path Core --x" t.Errorf("vars-parameterized canonical task not converted:\n%s", read(t, member)) } } + +func TestEnsureRootMiseInjectsDepsGate(t *testing.T) { + root := t.TempDir() + if _, err := EnsureRootMise(root, []Family{nodeFam(t, false)}, []string{"apps/web"}); err != nil { + t.Fatal(err) + } + got := read(t, filepath.Join(root, "mise.toml")) + for _, want := range []string{ + `"npm:renovate" = "latest"`, // colon key MUST be quoted or it's invalid TOML + `jq = "latest"`, + "[tasks.deps]", + `run = "bash scripts/deps-check.sh"`, + "[tasks.check]", + `depends = ["deps"]`, + } { + if !strings.Contains(got, want) { + t.Errorf("root mise.toml missing deps-gate piece %q:\n%s", want, got) + } + } + // The unquoted form would be invalid TOML — guard against a regression. + if strings.Contains(got, "npm:renovate = ") { + t.Errorf("npm:renovate key emitted unquoted (invalid TOML):\n%s", got) + } + if !valid(t, got) { + t.Errorf("root mise.toml with deps gate does not re-parse:\n%s", got) + } +} + +func TestEnsureRootMiseDepsGateIsIdempotent(t *testing.T) { + root := t.TempDir() + if _, err := EnsureRootMise(root, []Family{nodeFam(t, false)}, []string{"apps/web"}); err != nil { + t.Fatal(err) + } + first := read(t, filepath.Join(root, "mise.toml")) + changed, err := EnsureRootMise(root, []Family{nodeFam(t, false)}, []string{"apps/web"}) + if err != nil { + t.Fatal(err) + } + if changed { + t.Error("re-running with the deps gate already present must be a no-op") + } + if read(t, filepath.Join(root, "mise.toml")) != first { + t.Error("deps gate re-injected on the second run (not idempotent)") + } +} + +// A family that contributes no [tools] of its own (swift) still gets a valid +// [tools] table created by the deps gate. +func TestEnsureRootMiseDepsGateCreatesToolsWhenFamilyHasNone(t *testing.T) { + root := t.TempDir() + swift := Family{Name: "swift", Templates: map[string]Template{}} // no Tools + if _, err := EnsureRootMise(root, []Family{swift}, []string{"packages/Core"}); err != nil { + t.Fatal(err) + } + got := read(t, filepath.Join(root, "mise.toml")) + for _, want := range []string{"[tools]", `node = "24"`, `"npm:renovate" = "latest"`, "[tasks.deps]"} { + if !strings.Contains(got, want) { + t.Errorf("swift-only root missing %q:\n%s", want, got) + } + } + if !valid(t, got) { + t.Errorf("swift-only root with deps gate does not re-parse:\n%s", got) + } +} + +func TestEnsureDepsGateFiles(t *testing.T) { + assets := fstest.MapFS{ + "templates/monorepo/renovate.json": {Data: []byte("{\n \"extends\": [\"config:recommended\"]\n}\n")}, + "templates/monorepo/deps-check.sh": {Data: []byte("#!/usr/bin/env bash\necho hi\n")}, + } + root := t.TempDir() + created, err := EnsureDepsGateFiles(assets, root) + if err != nil { + t.Fatal(err) + } + if len(created) != 2 { + t.Errorf("first run should create 2 files, got %v", created) + } + if got := read(t, filepath.Join(root, "renovate.json")); !strings.Contains(got, "config:recommended") { + t.Errorf("renovate.json content wrong:\n%s", got) + } + script := filepath.Join(root, "scripts", "deps-check.sh") + info, err := os.Stat(script) + if err != nil { + t.Fatalf("deps-check.sh not written: %v", err) + } + if info.Mode().Perm()&0o100 == 0 { + t.Errorf("deps-check.sh not executable: mode %v", info.Mode()) + } + // Skip-existing: a second run touches nothing, even if the file was edited. + if err := os.WriteFile(filepath.Join(root, "renovate.json"), []byte("EDITED"), 0o644); err != nil { + t.Fatal(err) + } + created, err = EnsureDepsGateFiles(assets, root) + if err != nil { + t.Fatal(err) + } + if len(created) != 0 { + t.Errorf("second run should create nothing, got %v", created) + } + if read(t, filepath.Join(root, "renovate.json")) != "EDITED" { + t.Error("second run clobbered an existing renovate.json") + } +} From a1ceb62f68ee5d9658c4cdfd80c5d0d7bb86e8a0 Mon Sep 17 00:00:00 2001 From: Mark Malstrom Date: Tue, 16 Jun 2026 19:48:00 -0500 Subject: [PATCH 2/3] docs+scaffold: drop projected Dependabot for the root deps gate Remove the web scaffold's .github/dependabot.yml.tmpl (the only projected Dependabot config) now that dependency updates are handled by the repo-global Renovate deps gate at the monorepo root. web_test.go asserts no dependabot.yml is projected. Docs: usage/github.md, design/github-integration.md, design/mise-monorepo.md and BACKLOG.md (Pillar 2) now describe the root-level deps gate; the monorepo memory topic records the tomlKey/npm:renovate quoting gotcha. --- .claude/memory/mise-monorepo.md | 19 +++++++++++++++++++ BACKLOG.md | 7 +++++-- docs/design/github-integration.md | 8 +++++++- docs/design/mise-monorepo.md | 17 +++++++++++++++++ docs/usage/github.md | 5 ++++- .../web/github/.github/dependabot.yml.tmpl | 10 ---------- internal/scaffold/web_test.go | 14 +++++--------- 7 files changed, 57 insertions(+), 23 deletions(-) delete mode 100644 internal/coreassets/templates/scaffolds/web/github/.github/dependabot.yml.tmpl diff --git a/.claude/memory/mise-monorepo.md b/.claude/memory/mise-monorepo.md index 7fdb47676a..646f6b0ff6 100644 --- a/.claude/memory/mise-monorepo.md +++ b/.claude/memory/mise-monorepo.md @@ -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`. diff --git a/BACKLOG.md b/BACKLOG.md index e66df5f263..0ecba2bbdb 100644 --- a/BACKLOG.md +++ b/BACKLOG.md @@ -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 diff --git a/docs/design/github-integration.md b/docs/design/github-integration.md index 4280902a0b..9fe4347af3 100644 --- a/docs/design/github-integration.md +++ b/docs/design/github-integration.md @@ -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) diff --git a/docs/design/mise-monorepo.md b/docs/design/mise-monorepo.md index af91c049ca..cdb2d6e6dd 100644 --- a/docs/design/mise-monorepo.md +++ b/docs/design/mise-monorepo.md @@ -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 diff --git a/docs/usage/github.md b/docs/usage/github.md index d70cd17f63..0fb8e64ad4 100644 --- a/docs/usage/github.md +++ b/docs/usage/github.md @@ -92,7 +92,10 @@ specify issues close # 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**: diff --git a/internal/coreassets/templates/scaffolds/web/github/.github/dependabot.yml.tmpl b/internal/coreassets/templates/scaffolds/web/github/.github/dependabot.yml.tmpl deleted file mode 100644 index 75543cc0bd..0000000000 --- a/internal/coreassets/templates/scaffolds/web/github/.github/dependabot.yml.tmpl +++ /dev/null @@ -1,10 +0,0 @@ -version: 2 -updates: - - package-ecosystem: npm - directory: /{{.Dir}} - schedule: - interval: weekly - - package-ecosystem: github-actions - directory: / - schedule: - interval: weekly diff --git a/internal/scaffold/web_test.go b/internal/scaffold/web_test.go index d6052ddcee..a76649fc15 100644 --- a/internal/scaffold/web_test.go +++ b/internal/scaffold/web_test.go @@ -196,12 +196,16 @@ func TestWebScaffold(t *testing.T) { ".github/ISSUE_TEMPLATE/defect.yml", ".github/ISSUE_TEMPLATE/config.yml", ".github/CODEOWNERS", - ".github/dependabot.yml", } { if _, err := os.Stat(filepath.Join(proj, filepath.FromSlash(p))); err != nil { t.Errorf("RenderGitHub did not write %s: %v", p, err) } } + // Dependency updates are handled by the repo-global Renovate deps gate (wired + // into the monorepo root), not Dependabot — no dependabot.yml is projected. + if _, err := os.Stat(filepath.Join(proj, ".github/dependabot.yml")); !os.IsNotExist(err) { + t.Errorf("dependabot.yml must not be projected (replaced by the deps gate); stat err = %v", err) + } // the defect form stamps the Bug issue type; CODEOWNERS gates the spec library. defect, _ := os.ReadFile(filepath.Join(proj, ".github/ISSUE_TEMPLATE/defect.yml")) if !strings.Contains(string(defect), "type: Bug") { @@ -211,14 +215,6 @@ func TestWebScaffold(t *testing.T) { if !strings.Contains(string(owners), "/features/") || !strings.Contains(string(owners), "/specs/") { t.Errorf("CODEOWNERS must route /features and /specs:\n%s", owners) } - // dependabot.yml is templated: the npm ecosystem points at the app dir. - dep, _ := os.ReadFile(filepath.Join(proj, ".github/dependabot.yml")) - if !strings.Contains(string(dep), "directory: /apps/web") { - t.Errorf("dependabot.yml npm directory not substituted to the app dir:\n%s", dep) - } - if strings.Contains(string(dep), "{{") { - t.Errorf("dependabot.yml has unrendered template syntax:\n%s", dep) - } // the convex data variant overwrites the base router and adds the convex files if m.DataDefault != "convex" { t.Errorf("web dataDefault = %q, want convex", m.DataDefault) From 06af307b2f41f8d0684e54b9df3192de05f475ae Mon Sep 17 00:00:00 2001 From: Mark Malstrom Date: Tue, 16 Jun 2026 19:50:06 -0500 Subject: [PATCH 3/3] test: guard the deps-check.sh executable-bit assertion to non-Windows Windows file modes carry no Unix executable bit (mode -rw-rw-rw-), so the exec-bit check failed there. The 0o755 write is still correct on Unix, where the bash script actually runs. --- internal/scaffold/monorepo_test.go | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/internal/scaffold/monorepo_test.go b/internal/scaffold/monorepo_test.go index 8cf0474cf6..faa0b6a8bd 100644 --- a/internal/scaffold/monorepo_test.go +++ b/internal/scaffold/monorepo_test.go @@ -3,6 +3,7 @@ package scaffold import ( "os" "path/filepath" + "runtime" "strings" "testing" "testing/fstest" @@ -419,7 +420,9 @@ func TestEnsureDepsGateFiles(t *testing.T) { if err != nil { t.Fatalf("deps-check.sh not written: %v", err) } - if info.Mode().Perm()&0o100 == 0 { + // Windows file modes don't carry a Unix executable bit, so only assert it + // where it's meaningful (the script runs under bash on Unix-y systems). + if runtime.GOOS != "windows" && info.Mode().Perm()&0o100 == 0 { t.Errorf("deps-check.sh not executable: mode %v", info.Mode()) } // Skip-existing: a second run touches nothing, even if the file was edited.