Skip to content
Open
10 changes: 6 additions & 4 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -152,21 +152,23 @@ jobs:
run: ./linux/install.sh --doctor

linux-upgrade-path:
name: linux upgrade path (latest tag -> main)
name: linux upgrade path (latest published release -> main)
runs-on: ubuntu-latest
timeout-minutes: 40
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0

- name: install from the newest release tag
- name: install from the latest published release
env:
HERMES: "0"
GITHUB_TOKEN: ${{ github.token }}
GH_TOKEN: ${{ github.token }}
run: |
tag=$(git tag --sort=-v:refname | head -1)
echo "installing from $tag"
tag="$(gh release view --repo "$GITHUB_REPOSITORY" --json tagName --jq '.tagName')"
[[ -n "$tag" ]] || { echo "could not resolve latest published release" >&2; exit 1; }
echo "installing from published release $tag"
git worktree add /tmp/kit-old "$tag"
/tmp/kit-old/linux/install.sh --yes --skip docker

Expand Down
271 changes: 123 additions & 148 deletions .github/workflows/release.yml

Large diffs are not rendered by default.

3 changes: 2 additions & 1 deletion README.en.md
Original file line number Diff line number Diff line change
Expand Up @@ -171,7 +171,8 @@ record and enforce ownership of everything it creates.
- **Config backup**: a `.bak` backup is created before the first managed edit of a file.
- **Recursive-delete boundaries**: internal cleanup rejects HOME, filesystem root, paths outside the allowed boundary, and symlink traversal.
- **AI shell guard**: an additional defense layer blocks recursive `rm` calls from Codex and Claude Code hooks.
- **Release-based install**: after bootstrap, installation code resolves against the newest release tag by default.
- **Published-release install**: default installs and updates from detached checkouts resolve the newest **published GitHub Release**, not simply the newest `v*` tag. Tags that are still building or whose release failed are not selected by default.
- **Release gate**: the release stays a draft until `ci.yml` has succeeded for the exact tagged commit and macOS/Windows packaging, signing, and attestations all complete. It is published only after every release job succeeds.
- **CI**: install and health verification run on macOS, Windows, Ubuntu, Fedora, Arch, and openSUSE.

This project still relies on external supply chains including Homebrew,
Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -168,7 +168,8 @@ oh-my-zsh 등을 사용하고 있었다면 이름이나 경로만 기준으로
- **설정 백업**: 관리 파일을 처음 변경할 때 `.bak` 백업을 만듭니다.
- **재귀 삭제 경계 검사**: 내부 정리가 필요한 경우 HOME/루트/경계 밖/심볼릭 링크를 거부합니다.
- **AI shell guard**: Codex/Claude Code의 재귀 `rm` 호출을 차단하는 추가 방어층을 제공합니다.
- **릴리스 기준 설치**: 부트스트랩 이후 설치 코드는 최신 릴리스 태그를 기준으로 실행됩니다.
- **공개 릴리스 기준 설치**: 기본 설치와 detached checkout의 업데이트는 단순히 가장 최신 `v*` 태그를 고르지 않고, GitHub가 실제 공개한 **최신 published Release**를 기준으로 실행합니다. 아직 빌드 중이거나 실패한 태그는 기본 설치 대상으로 선택되지 않습니다.
- **릴리스 게이트**: 태그 커밋의 `ci.yml`이 성공하고 macOS/Windows 패키징·서명·attestation이 모두 끝날 때까지 Release는 draft 상태로 유지되며, 모든 단계가 성공한 뒤에만 공개됩니다.
- **CI**: macOS, Windows, Ubuntu, Fedora, Arch, openSUSE에서 설치와 상태 검증을 자동 실행합니다.

이 키트는 Homebrew, npm/bun 패키지, 각 프로젝트의 공식 설치 프로그램 등
Expand Down
29 changes: 25 additions & 4 deletions VERSIONING.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,18 +10,27 @@ Breaking any of these requires a **major** version bump:

| Surface | Examples |
|---|---|
| **CLI flags** | `--only`, `--skip`, `--dry-run`, `--yes`, `--profile`, `--doctor`, `--update`, `--list`, `--version`, `--with-gajae` (Windows: the `-PascalCase` equivalents) |
| **Step / group ids** | install steps (`prereqs`, `brew`/`packages`, `runtimes`, `shell`, `docker`, `git`, `agents`, `wsl`) and uninstall groups — the values accepted by `--only`/`--skip` |
| **CLI flags** | `--only`, `--skip`, `--dry-run`, `--yes`, `--profile`, `--doctor`, `--update`, `--list`, `--version` (Windows: the `-PascalCase` equivalents) |
| **Step ids** | install steps (`prereqs`, `brew`/`packages`, `runtimes`, `shell`, `docker`, `git`, `agents`, `wsl`) — the values accepted by `--only`/`--skip` |
| **Profile names** | `full`, `minimal`, `work` |
| **Managed-block markers** | `# >>> lazy-starter-kit:<tag> >>>` … `# <<< lazy-starter-kit:<tag> <<<` in `${ZDOTDIR-$HOME}/.zshrc`, `${ZDOTDIR-$HOME}/.zprofile`, PowerShell profiles — tools and users may key on these |
| **Environment variables** | `STARTER_KIT_BRANCH` (pin an explicit ref; unset installs the newest release tag), `STARTER_KIT_COMMIT` (require that ref to resolve to one full 40-character commit SHA), `HERMES=1` (opt in to the Hermes agent, macOS/Linux), `ZDOTDIR` (non-empty absolute Zsh config directory), `ASSUME_YES`/CI non-interactive behavior |
| **Environment variables** | `STARTER_KIT_BRANCH` (pin an explicit ref; unset installs the newest **published GitHub Release**), `STARTER_KIT_COMMIT` (require that ref to resolve to one full 40-character commit SHA), `HERMES=1` (opt in to the Hermes agent, macOS/Linux), `ZDOTDIR` (non-empty absolute Zsh config directory), `ASSUME_YES`/CI non-interactive behavior |
| **Release selection** | With `STARTER_KIT_BRANCH` unset, the official repository must not silently fall back from an unresolved published release to `main`. Tags that have not become a published Release are not the default install/update target. |
| **Exit codes** | `0` success / `1` failure; `--doctor` exits `0` when nothing is missing (PATH-only warnings don't fail) and `1` when something is — CI enforces this contract |
| **Backup behavior** | the one-time `.bak` backup before the first managed edit of a config file |

**Minor** versions may: add tools to the default set, add steps/flags/profiles,
change log wording, change *which versions* of tools get installed.
**Patch** versions fix bugs without interface changes.

## Automatic uninstall is intentionally not a public interface

Automatic uninstall was retired before `v1.0.0` because the installer does not
have a reliable ownership ledger for pre-existing tools and user state. The
legacy `uninstall.sh`, `linux/uninstall.sh`, and `windows/uninstall.ps1`
entrypoints are non-destructive compatibility stubs and are not semver-covered
removal APIs.

## Not covered (may change in any release)

- The exact set and versions of installed tools (upstreams move; that's the point).
Expand All @@ -35,11 +44,23 @@ Until `v1.0.0`, minor versions (`0.x` → `0.y`) may include breaking changes;
we keep them rare and always list them in the [CHANGELOG](./CHANGELOG.md).
From `v1.0.0` on, the table above is a hard promise.

## Release safety contract

For official tagged releases:

1. the tag's `VERSION` must match the tag name,
2. `ci.yml` must have completed successfully for the exact tagged commit,
3. the GitHub Release remains a draft while macOS/Windows release artifacts are built, signed/notarized where applicable, and attested,
4. only after every release artifact job succeeds is the Release published.

Default bootstrap/update selection uses the newest **published GitHub Release**,
not simply the lexicographically or semantically newest `v*` tag.

## Support tiers

| Tier | Platforms | Promise |
|---|---|---|
| **Tier 1** | macOS 14+ (Apple Silicon) · Windows Server 2025 (≈ Windows 11) · Ubuntu 24.04 · Fedora (latest) · Arch (latest) · openSUSE Tumbleweed | Full install → verify → uninstall runs in CI **on every commit**, plus idempotency (second install) and upgrade-path (previous tag → main) tests |
| **Tier 1** | macOS 14+ (Apple Silicon) · Windows Server 2025 (≈ Windows 11) · Ubuntu 24.04 · Fedora (latest) · Arch (latest) · openSUSE Tumbleweed | Full install → verify runs in CI **on every commit**, plus idempotency (second install) and upgrade-path (previous published release → main) tests |
| **Tier 2** | Windows 10 1809+ / 11 desktop · Debian 12+ · RHEL 9 / Rocky / Alma · openSUSE Leap · WSL2 (Ubuntu) · Intel Macs | Expected to work (same code paths), not automatically tested; regressions fixed with priority when reported |
| **Unsupported** | Alpine / musl distros · 32-bit systems | Upstream tools (node, ast-grep, bun) don't ship builds |

Expand Down
54 changes: 46 additions & 8 deletions install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,9 @@ set -euo pipefail
REPO_URL="${STARTER_KIT_REPO:-https://github.com/Heoooooon/lazy-starter-kit.git}"
CLONE_DIR="${STARTER_KIT_DIR:-$HOME/.lazy-starter-kit}"
# STARTER_KIT_BRANCH pins an explicit ref (a tag like v0.9.0, or "main" to ride
# the development branch). Left unset, the bootstrap resolves the newest release
# tag instead of main — a fresh machine should get a ref CI actually verified
# end-to-end, not whatever landed on main minutes ago.
# the development branch). Left unset, the bootstrap resolves the newest
# published GitHub Release instead of merely taking the newest v* tag. A tag
# that is still building or whose release failed must never become the default.
REPO_BRANCH="${STARTER_KIT_BRANCH:-}"
REPO_COMMIT="${STARTER_KIT_COMMIT:-}"
EPHEMERAL_ROOT="${STARTER_KIT_EPHEMERAL_ROOT:-}"
Expand All @@ -39,10 +39,42 @@ if [[ -n "$REPO_COMMIT" && ! "$REPO_COMMIT" =~ ^[0-9a-f]{40}$ ]]; then
exit 1
fi

# kit_latest_ref — newest vX.Y.Z tag on the remote; "main" when a repo has no
# release tags yet (forks, first-ever run before v0.1.0).
# kit_latest_ref — newest published GitHub Release tag. For the official repo,
# failure to resolve a published release is fatal: silently falling back to main
# would defeat the release/CI trust boundary. Non-GitHub custom repos retain the
# legacy tag/main fallback for local forks and tests.
kit_latest_ref() {
local tag
local repo_web="" final="" tag=""
case "$REPO_URL" in
https://github.com/*)
repo_web="${REPO_URL%.git}" ;;
git@github.com:*)
repo_web="https://github.com/${REPO_URL#git@github.com:}"
repo_web="${repo_web%.git}" ;;
ssh://git@github.com/*)
repo_web="https://github.com/${REPO_URL#ssh://git@github.com/}"
repo_web="${repo_web%.git}" ;;
esac
repo_web="${repo_web%/}"

if [[ -n "$repo_web" ]] && command -v curl >/dev/null 2>&1; then
final="$(curl -fsSL -o /dev/null -w '%{url_effective}' "$repo_web/releases/latest" 2>/dev/null || true)"
case "$final" in
"$repo_web"/releases/tag/*)
tag="${final#"$repo_web"/releases/tag/}"
if [[ -n "$tag" && "$tag" != */* ]]; then
printf '%s\n' "$tag"
return 0
fi
;;
esac
fi

if [[ "$repo_web" == "https://github.com/Heoooooon/lazy-starter-kit" ]]; then
echo "Could not resolve the latest published lazy-starter-kit release; refusing to fall back to main." >&2
return 1
fi

tag="$(git ls-remote --tags --refs --sort=-v:refname "$REPO_URL" 'v*' 2>/dev/null \
| head -1 | sed 's#.*refs/tags/##')"
if [[ -n "$tag" ]]; then echo "$tag"; else echo main; fi
Expand Down Expand Up @@ -207,13 +239,19 @@ doctor() {
# Update (--update): pull the latest kit, then re-exec the freshly-pulled
# installer with the remaining args. Handled BEFORE normal parsing so it
# composes with any other flag (order-independent) and the run always uses the
# updated step files rather than the stale ones already on disk.
# updated step files rather than the stale ones already on disk. Detached
# release checkouts resolve the newest published release before fetching.
# ---------------------------------------------------------------------------
DO_UPDATE=0; PASS_ARGS=()
for arg in "$@"; do
if [[ "$arg" == "--update" ]]; then DO_UPDATE=1; else PASS_ARGS+=("$arg"); fi
done
if [[ "$DO_UPDATE" == "1" ]]; then
if ! git -C "$ROOT" symbolic-ref -q HEAD >/dev/null 2>&1 \
&& [[ -z "${STARTER_KIT_BRANCH:-}" ]]; then
latest_ref="$(kit_latest_ref)" || die "could not resolve latest published release"
export STARTER_KIT_BRANCH="$latest_ref"
fi
update_kit "$ROOT"
exec bash "$ROOT/install.sh" ${PASS_ARGS[@]+"${PASS_ARGS[@]}"}
fi
Expand Down Expand Up @@ -346,4 +384,4 @@ if [[ "$KIT_INSTALL_FAILED" == "1" ]]; then
warn "setup finished with package errors — re-run ./install.sh --only brew, then use ./install.sh --doctor for remaining issues"
exit 1
fi
exit 0
exit 0
67 changes: 52 additions & 15 deletions linux/install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -31,15 +31,47 @@ set -euo pipefail
REPO_URL="${STARTER_KIT_REPO:-https://github.com/Heoooooon/lazy-starter-kit.git}"
CLONE_DIR="${STARTER_KIT_DIR:-$HOME/.lazy-starter-kit}"
# STARTER_KIT_BRANCH pins an explicit ref (a tag like v0.9.0, or "main" to ride
# the development branch). Left unset, the bootstrap resolves the newest release
# tag instead of main — a fresh machine should get a ref CI actually verified
# end-to-end, not whatever landed on main minutes ago.
# the development branch). Left unset, the bootstrap resolves the newest
# published GitHub Release instead of merely taking the newest v* tag. A tag
# that is still building or whose release failed must never become the default.
REPO_BRANCH="${STARTER_KIT_BRANCH:-}"

# kit_latest_ref — newest vX.Y.Z tag on the remote; "main" when a repo has no
# release tags yet (forks, first-ever run before v0.1.0).
# kit_latest_ref — newest published GitHub Release tag. For the official repo,
# failure to resolve a published release is fatal: silently falling back to main
# would defeat the release/CI trust boundary. Non-GitHub custom repos retain the
# legacy tag/main fallback for local forks and tests.
kit_latest_ref() {
local tag
local repo_web="" final="" tag=""
case "$REPO_URL" in
https://github.com/*)
repo_web="${REPO_URL%.git}" ;;
git@github.com:*)
repo_web="https://github.com/${REPO_URL#git@github.com:}"
repo_web="${repo_web%.git}" ;;
ssh://git@github.com/*)
repo_web="https://github.com/${REPO_URL#ssh://git@github.com/}"
repo_web="${repo_web%.git}" ;;
esac
repo_web="${repo_web%/}"

if [[ -n "$repo_web" ]] && command -v curl >/dev/null 2>&1; then
final="$(curl -fsSL -o /dev/null -w '%{url_effective}' "$repo_web/releases/latest" 2>/dev/null || true)"
case "$final" in
"$repo_web"/releases/tag/*)
tag="${final#"$repo_web"/releases/tag/}"
if [[ -n "$tag" && "$tag" != */* ]]; then
printf '%s\n' "$tag"
return 0
fi
;;
esac
fi

if [[ "$repo_web" == "https://github.com/Heoooooon/lazy-starter-kit" ]]; then
echo "Could not resolve the latest published lazy-starter-kit release; refusing to fall back to main." >&2
return 1
fi

tag="$(git ls-remote --tags --refs --sort=-v:refname "$REPO_URL" 'v*' 2>/dev/null \
| head -1 | sed 's#.*refs/tags/##')"
if [[ -n "$tag" ]]; then echo "$tag"; else echo main; fi
Expand All @@ -66,13 +98,12 @@ resolve_root() {
echo "==> Using ${REPO_BRANCH}" >&2
if [[ -d "$CLONE_DIR/.git" ]]; then
# Fetch the exact ref, then detach onto it — works for both tags and
# branches, unlike `pull --ff-only`. A failure here is reported instead of
# silently installing from a stale checkout.
if ! git -C "$CLONE_DIR" fetch --depth 1 origin "$REPO_BRANCH" >&2; then
echo "==> WARNING: could not fetch $REPO_BRANCH — installing from the existing checkout in $CLONE_DIR" >&2
elif ! git -C "$CLONE_DIR" checkout --quiet --detach FETCH_HEAD; then
echo "==> WARNING: could not check out $REPO_BRANCH (local changes?) — installing from the existing checkout in $CLONE_DIR" >&2
fi
# branches, unlike `pull --ff-only`. A failure must stop instead of silently
# running whatever stale checkout happened to be there.
git -C "$CLONE_DIR" fetch --force --depth 1 origin "$REPO_BRANCH" >&2 \
|| { echo "Could not fetch $REPO_BRANCH; refusing to use a stale checkout." >&2; exit 1; }
git -C "$CLONE_DIR" checkout --quiet --detach FETCH_HEAD \
|| { echo "Could not check out $REPO_BRANCH; refusing to use a stale checkout." >&2; exit 1; }
else
# -c advice.detachedHead=false: tag checkouts are detached by design;
# the 15-line git lecture only alarms first-time users.
Expand Down Expand Up @@ -175,13 +206,19 @@ doctor() {
# installer with the remaining args. Handled BEFORE normal parsing so it
# composes with any other flag (order-independent) and the run always uses the
# updated step files rather than the stale ones already on disk. $ROOT is the
# linux/ dir here, so the git checkout is its parent.
# linux/ dir here, so the git checkout is its parent. Detached release checkouts
# resolve the newest published release before fetching.
# ---------------------------------------------------------------------------
DO_UPDATE=0; PASS_ARGS=()
for arg in "$@"; do
if [[ "$arg" == "--update" ]]; then DO_UPDATE=1; else PASS_ARGS+=("$arg"); fi
done
if [[ "$DO_UPDATE" == "1" ]]; then
if ! git -C "$ROOT/.." symbolic-ref -q HEAD >/dev/null 2>&1 \
&& [[ -z "${STARTER_KIT_BRANCH:-}" ]]; then
latest_ref="$(kit_latest_ref)" || die "could not resolve latest published release"
export STARTER_KIT_BRANCH="$latest_ref"
fi
update_kit "$ROOT/.."
exec bash "$ROOT/install.sh" ${PASS_ARGS[@]+"${PASS_ARGS[@]}"}
fi
Expand Down Expand Up @@ -309,4 +346,4 @@ else
fi
fi
fi
exit 0
exit 0
Loading
Loading