Skip to content

Commit 56ddda4

Browse files
committed
pipeline discipline: main is promote-only, sync owns pin realignment
Development happens on the dev pipeline (dev branch or feature branches); the stable branch (main) only moves via 'lpb-devstack release promote'. - pre-commit guard (validate check 0): committing on main fails the hook unless the promote exemption is set. 'release promote' sets LPB_ALLOW_MAIN_COMMIT=1 around its devstack VERSION-strip commit (the one legitimate git commit on main — its merges don't trigger hooks). Outside a hook, validate on main stays green and only warns. - workspace sync re-aligns settings.json pins to the synced pipeline: one command (sync --tag dev / --tag main) leaves repos, symlinks AND pins consistent; realign_pins() shared with release promote (pins follow the stable version right after promote — no leftover -lpb-dev). - pin rewrites dedupe: a managed repo always ends up with exactly ONE entry (pi maps every git:<owner>/<repo> entry to one storage dir; duplicates raced during 'pi update --extensions'). - render --force guard: a stale pin of a repo the template owns is not a user addition — dropped/re-pinned, never appended as an 'extra' (this is what originally produced the duplicate 0.0.83-lpb-dev pins). Tests: 6 new commit-guard tests + render/sync/dedupe cases; 16/16 localpibox suites and test_lpb (93) green.
1 parent faf880a commit 56ddda4

10 files changed

Lines changed: 488 additions & 27 deletions

File tree

.githooks/pre-commit

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,11 @@
1212
# Escape hatches:
1313
# SKIP_TESTS=1 git commit ... — skip unit tests (~4s)
1414
# git commit --no-verify ... — skip everything
15+
# LPB_ALLOW_MAIN_COMMIT=1 git commit ... — commit on the stable branch
16+
# (main) without release promote. The validate commit guard (section 0)
17+
# blocks development commits to main; use this only for deliberate
18+
# stable-branch work. `release promote` sets it automatically for its
19+
# devstack VERSION-strip commit.
1520

1621
# In a linked worktree GIT_DIR is an ABSOLUTE path into the main repo's
1722
# .git/worktrees/<name> — it would leak into every `git -C` subprocess the

.pi/skills/localpibox-repo-workflow/SKILL.md

Lines changed: 42 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -111,7 +111,31 @@ Extension clones (pi loads these per settings.json pins):
111111
clean merges from `lpb-dev` via the release procedure. Divergence from
112112
`lpb-dev` is normal during active development.
113113

114-
**Rule:** always work on the default branch (`dev` or `lpb-dev`).
114+
**Rule:** always work on the dev pipeline (`dev` / `lpb-dev`, or a feature
115+
branch off dev). **`main` is promoted, not developed on** — the devstack
116+
pre-commit hook enforces this (the *commit guard*, validate section 0):
117+
committing on `main` fails validation unless `release promote` sets the
118+
exemption (`LPB_ALLOW_MAIN_COMMIT=1`, automatic for its devstack
119+
VERSION-strip commit) or the commit is deliberately made with
120+
`--no-verify` / `LPB_ALLOW_MAIN_COMMIT=1 git commit ...`. Outside a hook,
121+
`lpb-devstack validate` on main only warns — validating the stable tree
122+
stays possible.
123+
124+
**Pipeline moves are one command each:**
125+
- Start development: `lpb-devstack workspace sync --tag dev` → devstack,
126+
config + both extension repos onto their dev branches **and**
127+
`settings.json` pins re-aligned to the dev version (leftover stable
128+
pins dropped).
129+
- Return to stable: `lpb-devstack workspace sync --tag main` → same, to
130+
stable branches + stable pins.
131+
- After `release promote`: pins are re-aligned to the stable version
132+
automatically (no leftover `-lpb-dev` pins), then `pi update --extensions`.
133+
134+
`render --force` also refuses to reintroduce stale managed pins: an existing
135+
`git:github.com/lpb-stack/<repo>@<old>` whose repo the template already owns
136+
is a stale pin, not a user addition — it is dropped (or re-pinned), never
137+
appended alongside the template's entry (duplicate entries map to one pi
138+
checkout dir and race during `pi update --extensions`).
115139

116140
## Commit Author Convention
117141

@@ -170,9 +194,9 @@ on `main` is the trigger:
170194
1. Builds `:{v}-cli/web`, `:main-cli/web`, `:latest-cli/web`, `:{sha}-cli/web`
171195
2. Tags the 4 repos on their stable branches (`lpb`/`main`)
172196

173-
Then align the runtime to the stable pipeline:
197+
Then align the runtime to the stable pipeline (promote already re-aligned
198+
the `settings.json` pins to the stable version — verify, don't redo):
174199
```bash
175-
lpb-config --tag main sync-pins # pins → stable version
176200
pi update --extensions
177201
lpb-devstack --tag main validate
178202
```
@@ -247,6 +271,11 @@ lpb-devstack validate-hooks # full pre-commit checks (tests included)
247271
lpb-devstack --tag main validate
248272
```
249273

274+
`workspace sync` is the single write path for a pipeline move: clones /
275+
symlinks / branches **and** re-aligns `settings.json` extension pins to
276+
that pipeline's stack version (stale cross-pipeline pins are re-pinned and
277+
extra duplicates dropped — one entry per managed repo).
278+
250279
Both tools are thin CLIs over the shared `scripts/localpibox/stack/` library
251280
(`gitutil` / `repos` / `version` / `workspace` / `validate` / `release`).
252281

@@ -288,16 +317,18 @@ Same pattern — template in config repo, user config on host volume:
288317
1. devstack's tracked changes are fully staged (unstaged edits would
289318
silently miss the commit)
290319
2. `python3 scripts/lpb-devstack validate` — the full stack alignment
291-
check (VERSION, `LPB_PI_VERSION`, extension pins, branch alignment,
292-
pipeline consistency, config worktree clean + extension-repo WIP
293-
report). Runs the WORKSPACE copy on purpose: the PATH `lpb-devstack`
294-
is the image-baked one and stays stale until the next rebuild, so the
295-
hook always validates with the current logic.
320+
check. **Check 0 (commit guard)**: committing on `main` fails unless
321+
the promote exemption is set (see Branch Strategy) — development
322+
belongs on `dev` or feature branches. Runs the WORKSPACE copy on
323+
purpose: the PATH `lpb-devstack` is the image-baked one and stays
324+
stale until the next rebuild, so the hook always validates with the
325+
current logic. Remaining checks: VERSION, `LPB_PI_VERSION`,
326+
extension pins, branch alignment, pipeline consistency, config
327+
worktree clean + extension-repo WIP report.
296328
3. `scripts/test_lpb.py` passes (skip with `SKIP_TESTS=1`)
297329

298-
The full test suite in pre-commit is intentional — it guards against
299-
low-quality changes reaching the repo. `lpb-devstack validate-hooks` runs
300-
the same checks on demand.
330+
Escape hatches: `SKIP_TESTS=1` (tests only), `LPB_ALLOW_MAIN_COMMIT=1`
331+
(commit on main without promote), `--no-verify` (everything).
301332

302333
**commit-msg****no-op.** Version bumping is manual (`lpb-devstack bump`).
303334
Git hooks never write VERSION or cross-repo state.

scripts/localpibox/stack/release.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,9 @@
1818
from ..run import run_cmd
1919
from .gitutil import git, git_auth
2020
from .repos import TAG_REPOS, repo_path, stack_repos
21+
from .repos import DEFAULT_AGENT_DIR
2122
from .version import get_version
23+
from .workspace import realign_pins
2224

2325

2426
# ─── Dev release gate ────────────────────────────────────────────────────────
@@ -554,6 +556,10 @@ def cmd_release_promote(*, assume_yes: bool, dry_run: bool, rebase: bool,
554556
cons.info(f" {label}: merged origin/{dev_b}{main_b} ({action})")
555557

556558
# ── devstack VERSION: strip -dev on main ──
559+
# devstack commits on main under the hook's promote exemption: the
560+
# commit guard (validate section 0) refuses commits on the stable
561+
# branch — this strip commit is THE legitimate one.
562+
os.environ["LPB_ALLOW_MAIN_COMMIT"] = "1"
557563
for label, path, dev_b, main_b, gh, st in entries:
558564
if label != "devstack" or label in failures or label in skipped:
559565
continue
@@ -584,6 +590,7 @@ def cmd_release_promote(*, assume_yes: bool, dry_run: bool, rebase: bool,
584590
failures.append("devstack")
585591
else:
586592
cons.info(f" devstack: VERSION {current}{stable} (on {main_b})")
593+
del os.environ["LPB_ALLOW_MAIN_COMMIT"]
587594

588595
# ── Push ──
589596
cons.info("")
@@ -645,6 +652,11 @@ def cmd_release_promote(*, assume_yes: bool, dry_run: bool, rebase: bool,
645652
return 1
646653
stable_version = (version[:-len("-dev")] if version.endswith("-dev") else version)
647654
cons.done(f"Promoted all 6 repos. main's VERSION is now {stable_version}.")
655+
# Realign the runtime pins to the stable version: without this, the
656+
# dev pins outlive the release in settings.json (and the next
657+
# render --force would re-append them as 'extras').
658+
if realign_pins(DEFAULT_AGENT_DIR, "main", cons):
659+
cons.info(" Run 'pi update --extensions' to apply the stable pins.")
648660
cons.info(f"CI (main pipeline) now builds :{stable_version}-* / :main-* / :latest-*")
649661
cons.info("and tags the 5 repos at the stable branches.")
650662
cons.info("After CI passes:")

scripts/localpibox/stack/validate.py

Lines changed: 57 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88

99
from __future__ import annotations
1010

11+
import os
1112
import re
1213
from pathlib import Path
1314

@@ -35,6 +36,36 @@
3536
)
3637

3738

39+
def _branch_is_main() -> str:
40+
"""Current branch name of the devstack checkout being validated ('' if none)."""
41+
out, _, code = git(".", "branch", "--show-current")
42+
return out.strip() if code == 0 else ""
43+
44+
45+
def _allow_main_commit() -> bool:
46+
"""True when the caller opted into committing on the stable branch.
47+
48+
`release promote` sets LPB_ALLOW_MAIN_COMMIT=1 for its devstack
49+
VERSION-strip commit (the only legitimate `git commit` on main —
50+
promote's merges don't trigger hooks, the strip commit does).
51+
"""
52+
return bool(os.environ.get("LPB_ALLOW_MAIN_COMMIT"))
53+
54+
55+
def _commit_guard_failed() -> bool:
56+
"""True when committing on the stable branch without the promote exemption.
57+
58+
Development belongs on the dev pipeline (dev branch or feature
59+
branches); `main` only moves via `lpb-devstack release promote`.
60+
"""
61+
return _branch_is_main() == "main" and not _allow_main_commit()
62+
63+
64+
def _inside_git_hook() -> bool:
65+
"""True when running under a git hook (git sets GIT_INDEX_FILE)."""
66+
return bool(os.environ.get("GIT_INDEX_FILE"))
67+
68+
3869
def cmd_validate(pipeline: str, cons: Console) -> int:
3970
"""Validate the entire stack alignment to the current pipeline."""
4071
version = get_version()
@@ -68,7 +99,32 @@ def check(label: str, condition: bool, detail: str = "", fix: str = "") -> None:
6899
if fix:
69100
cons.info(f" Fix: {fix}")
70101

71-
# ── 1. VERSION file ────────────────────────────────────────────────
102+
# ── 0. Commit guard — main is promote-only ────────────────────────
103+
# Commit-time check: under a git hook (GIT_INDEX_FILE set) a commit on
104+
# the stable branch is a hard failure unless promote exempted it.
105+
# Outside hooks (plain `lpb-devstack validate`) it's informational
106+
# only — validating the stable environment on main must stay green.
107+
# The guard is branch-based, NOT pipeline-based: the pipeline is
108+
# detected from the VERSION file, which on main carries the stable
109+
# suffix even right after a promote.
110+
if _commit_guard_failed():
111+
detail = ("on stable branch without the promote exemption "
112+
"(LPB_ALLOW_MAIN_COMMIT, set by 'release promote')")
113+
if _inside_git_hook():
114+
check(
115+
"Commit target is not the stable branch (main is promote-only)",
116+
False,
117+
detail,
118+
"Switch to the dev pipeline first (lpb-devstack workspace sync "
119+
"--tag dev), or commit a feature branch off dev. Escape "
120+
"hatches (deliberate stable commits only): "
121+
"LPB_ALLOW_MAIN_COMMIT=1 git commit ... or --no-verify",
122+
)
123+
return 1
124+
cons.warn(" (on the stable branch: commits to main need the promote "
125+
"exemption — LPB_ALLOW_MAIN_COMMIT=1 or --no-verify)")
126+
127+
# ── 1. VERSION file ─────────────────────────────────────────────────
72128
vf = _find_version_file()
73129
if vf is not None:
74130
version_on_disk = vf.read_text().strip()

scripts/localpibox/stack/workspace.py

Lines changed: 83 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -421,6 +421,15 @@ def cmd_workspace_sync(pipeline: str, cons: Console) -> int:
421421
if not _sync_config(pipeline, cons):
422422
prepared = False
423423

424+
# Realign settings.json pins to the pipeline we just synced to: the
425+
# single write path leaves repos, symlinks, AND pins consistent
426+
# (fixes stale cross-pipeline pins that otherwise survive until the
427+
# next manual sync-pins / render --force).
428+
changed = realign_pins(DEFAULT_AGENT_DIR, pipeline, cons)
429+
if changed:
430+
cons.info(f" {changed} extension pin(s) realigned to the "
431+
f"{pipeline} pipeline")
432+
424433
cons.info("")
425434
if prepared:
426435
cons.done("Workspace prepared.")
@@ -466,23 +475,52 @@ def _get_pinned_versions(settings: dict) -> dict[str, str]:
466475
return pins
467476

468477

478+
def _dedupe_managed_pins(packages: list) -> list[str]:
479+
"""Collapse duplicate managed-pin entries: one entry per LPB extension repo.
480+
481+
A polluted settings.json can carry several `git:.../<name>@<old>` lines
482+
per repo (e.g. a stale `-lpb-dev` pin surviving a pipeline move next to
483+
the fresh one). pi maps every `git:<owner>/<repo>` entry to ONE storage
484+
dir, so duplicates make `pi update --extensions` race on the same
485+
checkout — deduping is mandatory, not cosmetic. Non-managed packages
486+
(npm/bare) pass through untouched, in order.
487+
"""
488+
seen: set[str] = set()
489+
out: list[str] = []
490+
for pkg in packages:
491+
if not isinstance(pkg, str):
492+
out.append(pkg)
493+
continue
494+
name = git_extension_pin(pkg)
495+
if name is None:
496+
out.append(pkg)
497+
elif name not in seen:
498+
seen.add(name)
499+
out.append(pkg)
500+
return out
501+
502+
469503
def _update_pinned_versions(settings: dict, target_version: str) -> list[tuple[str, str, str]]:
470504
"""Update git-managed LPB extension pins to target_version. Returns changes.
471505
506+
Also DEDUPES: if a repo appears multiple times (stale pins from an
507+
earlier pipeline move), only the FIRST entry is re-pinned — the rest
508+
are dropped — so the result has exactly one entry per managed repo.
472509
npm/bare packages are left untouched — not managed by the stack VERSION.
473510
"""
474511
packages = settings.get("packages", [])
475512
changes: list[tuple[str, str, str]] = []
513+
deduped = _dedupe_managed_pins(packages)
476514
for name in LPB_EXTENSION_REPOS:
477515
marker = f"git:github.com/lpb-stack/{name}@"
478-
for i, pkg in enumerate(packages):
516+
for i, pkg in enumerate(deduped):
479517
if isinstance(pkg, str) and pkg.startswith(marker):
480518
old_tag = pkg.split("@")[-1]
481519
if old_tag != target_version:
482-
new_pkg = f"{marker}{target_version}"
483-
packages[i] = new_pkg
520+
deduped[i] = f"{marker}{target_version}"
484521
changes.append((name, old_tag, target_version))
485-
settings["packages"] = packages
522+
break
523+
settings["packages"] = deduped
486524
return changes
487525

488526

@@ -491,22 +529,55 @@ def _update_pinned_versions(settings: dict, target_version: str) -> list[tuple[s
491529
def sync_pins_quiet(agent_dir: str | Path, target_version: str,
492530
cons: Console) -> int:
493531
"""Non-interactive pin update: write pins != *target_version* in
494-
settings.json. Returns the number of changed extension(s); 0 if nothing
495-
to do or no settings.json (a missing file is not an error here —
496-
callers treat it as 'nothing to sync'). Used by `lpb-devstack bump`
497-
so a VERSION commit never lands with stale pins (the pre-commit
498-
validate would fail, and a post-hook sync would target the OLD
499-
version)."""
532+
settings.json (also dedupes duplicate managed-pin entries). Returns the
533+
number of changed extension(s); 0 if nothing to do or no settings.json
534+
(a missing file is not an error here — callers treat it as 'nothing to
535+
sync'). Used by `lpb-devstack bump` so a VERSION commit never lands
536+
with stale pins (the pre-commit validate would fail, and a post-hook
537+
sync would target the OLD version)."""
500538
settings = _read_settings(agent_dir)
501539
if settings is None:
502540
return 0
541+
before = settings.get("packages", [])
503542
changed = _update_pinned_versions(settings, target_version)
504-
if not changed:
543+
if settings.get("packages") == before:
505544
return 0
506545
_write_settings(agent_dir, settings)
507546
for name, old, new in changed:
508547
cons.info(f" pin {name}: {old}{new}")
509-
return len(changed)
548+
return max(len(changed), 1)
549+
550+
551+
def realign_pins(agent_dir: str | Path, pipeline: str, cons: Console) -> int:
552+
"""Realign settings.json extension pins to *pipeline*'s stack version.
553+
554+
Shared write path for the two moments when the pipeline moves out from
555+
under the runtime config:
556+
- `workspace sync` — every sync now leaves pins matching the branch
557+
it just aligned the repos to (start dev / return to stable);
558+
- `release promote` — after dev is merged to the stable branches, the
559+
runtime must track the stable version, not leftover dev pins.
560+
561+
Re-pins stale pins AND drops duplicate managed-pin entries (a polluted
562+
file can survive re-pinning with the first entry already at target).
563+
Returns the number of pin operations performed (0 = already aligned or
564+
no settings.json — a missing file is not an error here, mirroring
565+
sync_pins_quiet).
566+
"""
567+
target_version = expected_pin_version(pipeline)
568+
settings = _read_settings(agent_dir)
569+
if settings is None:
570+
return 0
571+
before = settings.get("packages", [])
572+
changed = _update_pinned_versions(settings, target_version)
573+
if settings.get("packages") == before:
574+
return 0
575+
_write_settings(agent_dir, settings)
576+
for name, old, new in changed:
577+
cons.info(f" pin {name}: {old}{new}")
578+
if not changed:
579+
cons.info(" dropped duplicate managed-pin entrie(s)")
580+
return max(len(changed), 1)
510581

511582

512583
def cmd_workspace_sync_pins(pipeline: str, cons: Console) -> int:

scripts/lpb-config

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -97,11 +97,13 @@ from localpibox.stack.repos import ( # noqa: E402
9797
DEFAULT_AGENT_DIR,
9898
DEFAULT_REMOTE,
9999
DEFAULT_REF,
100+
GIT_EXTENSION_PREFIX,
100101
LPB_EXTENSION_REPOS,
101102
migrate_legacy_layout,
102103
)
103104
from localpibox.stack.version import detect_pipeline, get_version # noqa: E402
104105
from localpibox.stack.workspace import ( # noqa: E402
106+
_dedupe_managed_pins,
105107
_write_settings,
106108
cmd_workspace_sync_pins,
107109
)
@@ -244,8 +246,24 @@ def _render_file(
244246
and isinstance(existing.get("packages"), list)
245247
):
246248
tpl_pkgs = new["packages"]
247-
extras = [p for p in existing["packages"] if p not in tpl_pkgs]
248-
new = {**new, "packages": tpl_pkgs + extras}
249+
# A stale pin of a repo the TEMPLATE already owns (e.g. a leftover
250+
# `-lpb-dev` pin after a promote to `-lpb`) is not a user addition:
251+
# the template re-pins that repo. Keeping it would leave two
252+
# entries for the same repo, which pi maps to one checkout dir
253+
# (duplicate updates race there). Drop stale template-owned pins;
254+
# keep genuine extras (npm/bare/other repos) — deduped for safety.
255+
tpl_owned = {
256+
p[len(GIT_EXTENSION_PREFIX):].split("@", 1)[0]
257+
for p in tpl_pkgs
258+
if isinstance(p, str) and p.startswith(GIT_EXTENSION_PREFIX)
259+
}
260+
extras = [
261+
p for p in existing["packages"]
262+
if p not in tpl_pkgs
263+
and (not isinstance(p, str) or not p.startswith(GIT_EXTENSION_PREFIX)
264+
or p[len(GIT_EXTENSION_PREFIX):].split("@", 1)[0] not in tpl_owned)
265+
]
266+
new = {**new, "packages": _dedupe_managed_pins(tpl_pkgs + extras)}
249267
merged = {**new, **existing} if local_wins else {**existing, **new}
250268
out.write_text(json.dumps(merged, indent=2) + "\n")
251269
cons.info(f"Re-rendered {out.name} from {template_name} (merged, user keys preserved)")

0 commit comments

Comments
 (0)