diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cec13f1..f20f905 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -152,7 +152,7 @@ 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: @@ -160,13 +160,15 @@ jobs: 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 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 654928c..c4ff33a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -9,16 +9,81 @@ permissions: contents: read jobs: + verify-ci: + name: verify tagged commit passed CI + runs-on: ubuntu-latest + permissions: + actions: read + contents: read + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: verify version matches tag + run: | + set -euo pipefail + version="$(tr -d '[:space:]' < VERSION)" + test "v$version" = "$GITHUB_REF_NAME" || { + echo "::error::VERSION $version does not match release tag $GITHUB_REF_NAME" + exit 1 + } + - name: require successful CI for this exact commit + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + echo "Waiting for ci.yml on $GITHUB_SHA" + for attempt in $(seq 1 60); do + row="$( + gh run list \ + --repo "$GITHUB_REPOSITORY" \ + --workflow ci.yml \ + --commit "$GITHUB_SHA" \ + --event push \ + --limit 20 \ + --json databaseId,status,conclusion,createdAt \ + --jq 'sort_by(.createdAt) | reverse | .[0] | if . == null then "" else [.databaseId, .status, (.conclusion // "")] | map(tostring) | join("|") end' + )" + if [[ -z "$row" ]]; then + echo "CI run not visible yet (attempt $attempt/60)" + sleep 10 + continue + fi + IFS='|' read -r run_id status conclusion <<< "$row" + echo "CI run $run_id: status=$status conclusion=${conclusion:-pending}" + if [[ "$status" == "completed" ]]; then + [[ "$conclusion" == "success" ]] || { + echo "::error::Refusing release: CI run $run_id concluded '$conclusion'" + exit 1 + } + echo "CI gate passed: https://github.com/$GITHUB_REPOSITORY/actions/runs/$run_id" + exit 0 + fi + sleep 10 + done + echo "::error::Timed out waiting for a successful ci.yml run for $GITHUB_SHA" + exit 1 + create-release: - name: create GitHub release + name: create draft GitHub release + needs: verify-ci runs-on: ubuntu-latest permissions: contents: write steps: - - name: create release with auto-generated notes + - name: create or reuse draft release env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: gh release create "$GITHUB_REF_NAME" --repo "$GITHUB_REPOSITORY" --generate-notes --verify-tag + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + if gh release view "$GITHUB_REF_NAME" --repo "$GITHUB_REPOSITORY" --json isDraft >/dev/null 2>&1; then + is_draft="$(gh release view "$GITHUB_REF_NAME" --repo "$GITHUB_REPOSITORY" --json isDraft --jq '.isDraft')" + [[ "$is_draft" == "true" ]] || { + echo "::error::Release $GITHUB_REF_NAME is already public; refusing to rebuild it" + exit 1 + } + echo "Reusing existing draft release $GITHUB_REF_NAME" + else + gh release create "$GITHUB_REF_NAME" --repo "$GITHUB_REPOSITORY" --generate-notes --verify-tag --draft + fi macos-installers: name: package macOS beginner installers @@ -39,15 +104,9 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: import Developer ID certificate run: | - for name in \ - MACOS_CERTIFICATE_P12_BASE64 \ - MACOS_CERTIFICATE_PASSWORD \ - ASC_API_KEY_P8_BASE64 \ - ASC_API_KEY_ID \ - ASC_API_ISSUER_ID; do + for name in MACOS_CERTIFICATE_P12_BASE64 MACOS_CERTIFICATE_PASSWORD ASC_API_KEY_P8_BASE64 ASC_API_KEY_ID ASC_API_ISSUER_ID; do test -n "${!name:-}" || { echo "::error::$name is not configured"; exit 1; } done - CERTIFICATE_PATH="$RUNNER_TEMP/developer-id.p12" API_KEY_PATH="$RUNNER_TEMP/AuthKey_${ASC_API_KEY_ID}.p8" KEYCHAIN_PATH="$RUNNER_TEMP/lazy-starter-kit-signing.keychain-db" @@ -55,29 +114,14 @@ jobs: printf '%s' "$MACOS_CERTIFICATE_P12_BASE64" | base64 -D > "$CERTIFICATE_PATH" printf '%s' "$ASC_API_KEY_P8_BASE64" | base64 -D > "$API_KEY_PATH" chmod 600 "$CERTIFICATE_PATH" "$API_KEY_PATH" - security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" security set-keychain-settings -lut 21600 "$KEYCHAIN_PATH" security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" - security import "$CERTIFICATE_PATH" \ - -k "$KEYCHAIN_PATH" \ - -P "$MACOS_CERTIFICATE_PASSWORD" \ - -T /usr/bin/codesign \ - -T /usr/bin/security - security set-key-partition-list \ - -S apple-tool:,apple: \ - -s \ - -k "$KEYCHAIN_PASSWORD" \ - "$KEYCHAIN_PATH" - security list-keychains -d user -s \ - "$KEYCHAIN_PATH" \ - "$HOME/Library/Keychains/login.keychain-db" + security import "$CERTIFICATE_PATH" -k "$KEYCHAIN_PATH" -P "$MACOS_CERTIFICATE_PASSWORD" -T /usr/bin/codesign -T /usr/bin/security + security set-key-partition-list -S apple-tool:,apple: -s -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" + security list-keychains -d user -s "$KEYCHAIN_PATH" "$HOME/Library/Keychains/login.keychain-db" identities="$(security find-identity -v -p codesigning "$KEYCHAIN_PATH")" - case "$identities" in - *"$SIGNING_IDENTITY"*) ;; - *) echo "::error::Developer ID identity was not imported"; exit 1 ;; - esac - + case "$identities" in *"$SIGNING_IDENTITY"*) ;; *) echo "::error::Developer ID identity was not imported"; exit 1 ;; esac echo "CERTIFICATE_PATH=$CERTIFICATE_PATH" >> "$GITHUB_ENV" echo "API_KEY_PATH=$API_KEY_PATH" >> "$GITHUB_ENV" echo "KEYCHAIN_PATH=$KEYCHAIN_PATH" >> "$GITHUB_ENV" @@ -87,33 +131,14 @@ jobs: STARTER_KIT_RELEASE_REF: ${{ github.ref_name }} run: | bash gui/macos/build-app.sh dist - codesign \ - --force \ - --deep \ - --options runtime \ - --timestamp \ - --sign "$SIGNING_IDENTITY" \ - "dist/Lazy Starter Kit Installer.app" - codesign --verify --deep --strict --verbose=2 \ - "dist/Lazy Starter Kit Installer.app" - - ditto -c -k --keepParent \ - "dist/Lazy Starter Kit Installer.app" \ - "$RUNNER_TEMP/lazy-starter-kit-notary-upload.zip" - xcrun notarytool submit \ - "$RUNNER_TEMP/lazy-starter-kit-notary-upload.zip" \ - --key "$API_KEY_PATH" \ - --key-id "$ASC_API_KEY_ID" \ - --issuer "$ASC_API_ISSUER_ID" \ - --wait + codesign --force --deep --options runtime --timestamp --sign "$SIGNING_IDENTITY" "dist/Lazy Starter Kit Installer.app" + codesign --verify --deep --strict --verbose=2 "dist/Lazy Starter Kit Installer.app" + ditto -c -k --keepParent "dist/Lazy Starter Kit Installer.app" "$RUNNER_TEMP/lazy-starter-kit-notary-upload.zip" + xcrun notarytool submit "$RUNNER_TEMP/lazy-starter-kit-notary-upload.zip" --key "$API_KEY_PATH" --key-id "$ASC_API_KEY_ID" --issuer "$ASC_API_ISSUER_ID" --wait xcrun stapler staple "dist/Lazy Starter Kit Installer.app" xcrun stapler validate "dist/Lazy Starter Kit Installer.app" - spctl --assess --type execute --verbose=4 \ - "dist/Lazy Starter Kit Installer.app" - - ditto -c -k --keepParent \ - "dist/Lazy Starter Kit Installer.app" \ - "lazy-starter-kit-macos-gui.zip" + spctl --assess --type execute --verbose=4 "dist/Lazy Starter Kit Installer.app" + ditto -c -k --keepParent "dist/Lazy Starter Kit Installer.app" "lazy-starter-kit-macos-gui.zip" - name: package double-click launcher run: | mkdir macos-double-click @@ -121,43 +146,25 @@ jobs: cp install.sh VERSION macos-double-click/ git rev-parse HEAD > macos-double-click/RELEASE_COMMIT bootstrap_hash="$(shasum -a 256 install.sh | awk '{print $1}')" - perl -0pi -e "s/__BOOTSTRAP_SHA256__/$bootstrap_hash/g" \ - macos-double-click/Install-lazy-starter-kit.command - if grep -q '__BOOTSTRAP_SHA256__' \ - macos-double-click/Install-lazy-starter-kit.command; then - echo "macOS launcher digest placeholder was not replaced" >&2 - exit 1 - fi - ditto -c -k --keepParent \ - macos-double-click \ - "lazy-starter-kit-macos-double-click.zip" - shasum -a 256 lazy-starter-kit-macos-double-click.zip \ - > lazy-starter-kit-macos-double-click.zip.sha256 + perl -0pi -e "s/__BOOTSTRAP_SHA256__/$bootstrap_hash/g" macos-double-click/Install-lazy-starter-kit.command + grep -q '__BOOTSTRAP_SHA256__' macos-double-click/Install-lazy-starter-kit.command && { echo "macOS launcher digest placeholder was not replaced" >&2; exit 1; } || true + ditto -c -k --keepParent macos-double-click "lazy-starter-kit-macos-double-click.zip" + shasum -a 256 lazy-starter-kit-macos-double-click.zip > lazy-starter-kit-macos-double-click.zip.sha256 - name: attest macOS release archives uses: actions/attest-build-provenance@977bb373ede98d70efdf65b84cb5f73e068dcc2a # v3 with: subject-path: | lazy-starter-kit-macos-gui.zip lazy-starter-kit-macos-double-click.zip - - name: upload macOS assets + - name: upload macOS assets to draft env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - gh release upload "$GITHUB_REF_NAME" \ - lazy-starter-kit-macos-gui.zip \ - lazy-starter-kit-macos-double-click.zip \ - lazy-starter-kit-macos-double-click.zip.sha256 \ - --repo "$GITHUB_REPOSITORY" + GH_TOKEN: ${{ github.token }} + run: gh release upload "$GITHUB_REF_NAME" lazy-starter-kit-macos-gui.zip lazy-starter-kit-macos-double-click.zip lazy-starter-kit-macos-double-click.zip.sha256 --clobber --repo "$GITHUB_REPOSITORY" - name: remove temporary signing credentials if: always() run: | - if [[ -n "${KEYCHAIN_PATH:-}" && -f "$KEYCHAIN_PATH" ]]; then - security delete-keychain "$KEYCHAIN_PATH" - fi - rm -f \ - "${CERTIFICATE_PATH:-}" \ - "${API_KEY_PATH:-}" \ - "$RUNNER_TEMP/lazy-starter-kit-notary-upload.zip" + if [[ -n "${KEYCHAIN_PATH:-}" && -f "$KEYCHAIN_PATH" ]]; then security delete-keychain "$KEYCHAIN_PATH"; fi + rm -f "${CERTIFICATE_PATH:-}" "${API_KEY_PATH:-}" "$RUNNER_TEMP/lazy-starter-kit-notary-upload.zip" windows-installers: name: package Windows beginner installers @@ -173,89 +180,57 @@ jobs: shell: powershell run: | $version = (Get-Content VERSION -Raw).Trim() - if ("v$version" -ne $env:GITHUB_REF_NAME) { - throw "VERSION $version does not match release tag $env:GITHUB_REF_NAME" - } + if ("v$version" -ne $env:GITHUB_REF_NAME) { throw "VERSION $version does not match release tag $env:GITHUB_REF_NAME" } New-Item -ItemType Directory -Path windows-gui | Out-Null $commit = (git rev-parse HEAD).Trim() - if ($commit -notmatch '^[0-9a-f]{40}$') { - throw "Could not resolve release commit" - } - $bootstrapHash = - (Get-FileHash windows\install.ps1 -Algorithm SHA256).Hash.ToLowerInvariant() - $guiText = [System.IO.File]::ReadAllText( - (Join-Path $PWD 'gui\windows\installer.ps1'), - [System.Text.Encoding]::UTF8 - ).Replace( - '__BOOTSTRAP_SHA256__', - $bootstrapHash - ) - if ($guiText -match '__BOOTSTRAP_SHA256__') { - throw 'Windows GUI digest placeholder was not replaced' - } - [System.IO.File]::WriteAllText( - (Join-Path $PWD 'windows-gui\installer.ps1'), - $guiText, - (New-Object System.Text.UTF8Encoding($true)) - ) + if ($commit -notmatch '^[0-9a-f]{40}$') { throw "Could not resolve release commit" } + $bootstrapHash = (Get-FileHash windows\install.ps1 -Algorithm SHA256).Hash.ToLowerInvariant() + $guiText = [System.IO.File]::ReadAllText((Join-Path $PWD 'gui\windows\installer.ps1'), [System.Text.Encoding]::UTF8).Replace('__BOOTSTRAP_SHA256__', $bootstrapHash) + if ($guiText -match '__BOOTSTRAP_SHA256__') { throw 'Windows GUI digest placeholder was not replaced' } + [System.IO.File]::WriteAllText((Join-Path $PWD 'windows-gui\installer.ps1'), $guiText, (New-Object System.Text.UTF8Encoding($true))) Copy-Item gui\windows\Lazy-Starter-Kit-Installer.cmd windows-gui\ - Copy-Item gui\windows\cleanup-installer-clone.ps1 ` - windows-gui\cleanup-installer-clone.ps1 + Copy-Item gui\windows\cleanup-installer-clone.ps1 windows-gui\cleanup-installer-clone.ps1 Copy-Item windows\install.ps1 windows-gui\bootstrap-install.ps1 Copy-Item windows\scripts\lib.ps1 windows-gui\cleanup-lib.ps1 Copy-Item VERSION windows-gui\ $commit | Set-Content windows-gui\RELEASE_COMMIT -Encoding ASCII - Compress-Archive -Path windows-gui\* ` - -DestinationPath lazy-starter-kit-windows-gui.zip - $zipHash = - (Get-FileHash lazy-starter-kit-windows-gui.zip -Algorithm SHA256).Hash.ToLowerInvariant() - [System.IO.File]::WriteAllText( - (Join-Path $PWD 'lazy-starter-kit-windows-gui.zip.sha256'), - "$zipHash lazy-starter-kit-windows-gui.zip`n", - [System.Text.Encoding]::ASCII - ) + Compress-Archive -Path windows-gui\* -DestinationPath lazy-starter-kit-windows-gui.zip + $zipHash = (Get-FileHash lazy-starter-kit-windows-gui.zip -Algorithm SHA256).Hash.ToLowerInvariant() + [System.IO.File]::WriteAllText((Join-Path $PWD 'lazy-starter-kit-windows-gui.zip.sha256'), "$zipHash lazy-starter-kit-windows-gui.zip`n", [System.Text.Encoding]::ASCII) New-Item -ItemType Directory -Path windows-double-click | Out-Null - $launcher = [System.IO.File]::ReadAllText( - (Join-Path $PWD 'windows\Install-lazy-starter-kit.cmd'), - [System.Text.Encoding]::UTF8 - ).Replace( - '__BOOTSTRAP_SHA256__', - $bootstrapHash - ) - if ($launcher -match '__BOOTSTRAP_SHA256__') { - throw 'Windows launcher digest placeholder was not replaced' - } - [System.IO.File]::WriteAllText( - (Join-Path $PWD 'windows-double-click\Install-lazy-starter-kit.cmd'), - $launcher, - (New-Object System.Text.UTF8Encoding($false)) - ) + $launcher = [System.IO.File]::ReadAllText((Join-Path $PWD 'windows\Install-lazy-starter-kit.cmd'), [System.Text.Encoding]::UTF8).Replace('__BOOTSTRAP_SHA256__', $bootstrapHash) + if ($launcher -match '__BOOTSTRAP_SHA256__') { throw 'Windows launcher digest placeholder was not replaced' } + [System.IO.File]::WriteAllText((Join-Path $PWD 'windows-double-click\Install-lazy-starter-kit.cmd'), $launcher, (New-Object System.Text.UTF8Encoding($false))) Copy-Item windows\install.ps1 windows-double-click\bootstrap-install.ps1 Copy-Item VERSION windows-double-click\ $commit | Set-Content windows-double-click\RELEASE_COMMIT -Encoding ASCII - Compress-Archive -Path windows-double-click\* ` - -DestinationPath lazy-starter-kit-windows-double-click.zip - $launcherZipHash = - (Get-FileHash lazy-starter-kit-windows-double-click.zip -Algorithm SHA256).Hash.ToLowerInvariant() - [System.IO.File]::WriteAllText( - (Join-Path $PWD 'lazy-starter-kit-windows-double-click.zip.sha256'), - "$launcherZipHash lazy-starter-kit-windows-double-click.zip`n", - [System.Text.Encoding]::ASCII - ) + Compress-Archive -Path windows-double-click\* -DestinationPath lazy-starter-kit-windows-double-click.zip + $launcherZipHash = (Get-FileHash lazy-starter-kit-windows-double-click.zip -Algorithm SHA256).Hash.ToLowerInvariant() + [System.IO.File]::WriteAllText((Join-Path $PWD 'lazy-starter-kit-windows-double-click.zip.sha256'), "$launcherZipHash lazy-starter-kit-windows-double-click.zip`n", [System.Text.Encoding]::ASCII) - name: attest Windows release archives uses: actions/attest-build-provenance@977bb373ede98d70efdf65b84cb5f73e068dcc2a # v3 with: subject-path: | lazy-starter-kit-windows-gui.zip lazy-starter-kit-windows-double-click.zip - - name: upload Windows assets + - name: upload Windows assets to draft shell: powershell env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_TOKEN: ${{ github.token }} + run: gh release upload $env:GITHUB_REF_NAME lazy-starter-kit-windows-gui.zip lazy-starter-kit-windows-gui.zip.sha256 lazy-starter-kit-windows-double-click.zip lazy-starter-kit-windows-double-click.zip.sha256 --clobber --repo $env:GITHUB_REPOSITORY + + publish-release: + name: publish verified release + needs: [macos-installers, windows-installers] + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - name: publish only after every release artifact succeeded + env: + GH_TOKEN: ${{ github.token }} run: | - gh release upload $env:GITHUB_REF_NAME ` - lazy-starter-kit-windows-gui.zip ` - lazy-starter-kit-windows-gui.zip.sha256 ` - lazy-starter-kit-windows-double-click.zip ` - lazy-starter-kit-windows-double-click.zip.sha256 ` - --repo $env:GITHUB_REPOSITORY + set -euo pipefail + is_draft="$(gh release view "$GITHUB_REF_NAME" --repo "$GITHUB_REPOSITORY" --json isDraft --jq '.isDraft')" + [[ "$is_draft" == "true" ]] || { echo "::error::Expected $GITHUB_REF_NAME to still be a draft before final publish"; exit 1; } + gh release edit "$GITHUB_REF_NAME" --repo "$GITHUB_REPOSITORY" --draft=false diff --git a/README.en.md b/README.en.md index 1f99a78..9f05c2e 100644 --- a/README.en.md +++ b/README.en.md @@ -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, diff --git a/README.md b/README.md index 6ce6773..4204133 100644 --- a/README.md +++ b/README.md @@ -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 패키지, 각 프로젝트의 공식 설치 프로그램 등 diff --git a/VERSIONING.md b/VERSIONING.md index 70bdad0..5d14640 100644 --- a/VERSIONING.md +++ b/VERSIONING.md @@ -10,11 +10,12 @@ 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: >>>` … `# <<< lazy-starter-kit: <<<` 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 | @@ -22,6 +23,14 @@ Breaking any of these requires a **major** version bump: 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). @@ -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 | diff --git a/install.sh b/install.sh index 283424f..edebf7e 100755 --- a/install.sh +++ b/install.sh @@ -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:-}" @@ -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 @@ -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 @@ -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 \ No newline at end of file diff --git a/linux/install.sh b/linux/install.sh index efafba4..82b6cec 100755 --- a/linux/install.sh +++ b/linux/install.sh @@ -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 @@ -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. @@ -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 @@ -309,4 +346,4 @@ else fi fi fi -exit 0 +exit 0 \ No newline at end of file diff --git a/tests/release-signing.sh b/tests/release-signing.sh index 9f20cf8..4a7124b 100755 --- a/tests/release-signing.sh +++ b/tests/release-signing.sh @@ -3,24 +3,78 @@ set -euo pipefail ROOT="$(cd "$(dirname "$0")/.." && pwd)" WORKFLOW="$(<"$ROOT/.github/workflows/release.yml")" +MAC_INSTALLER="$(<"$ROOT/install.sh")" +LINUX_INSTALLER="$(<"$ROOT/linux/install.sh")" +WINDOWS_INSTALLER="$(<"$ROOT/windows/install.ps1")" fail() { printf 'FAIL %s\n' "$*" >&2 exit 1 } -# Release and CI entrypoints are invoked directly, so their executable mode is -# part of the shipped contract rather than a local convenience. for executable in \ Install-lazy-starter-kit.command \ install.sh \ gui/macos/build-app.sh \ tests/install-entrypoints.sh \ tests/release-signing.sh; do - [[ -x "$ROOT/$executable" ]] \ - || fail "$executable is not executable" + [[ -x "$ROOT/$executable" ]] || fail "$executable is not executable" done +# A tag must never become a public release merely because it exists. The +# workflow first waits for ci.yml to succeed on the exact tagged SHA, keeps the +# release as a draft while platform artifacts are built, and only publishes +# after both packaging jobs succeed. +for token in \ + 'verify tagged commit passed CI' \ + 'actions: read' \ + '--workflow ci.yml' \ + '--commit "$GITHUB_SHA"' \ + '--event push' \ + 'Refusing release: CI run' \ + 'create draft GitHub release' \ + '--draft' \ + 'needs: verify-ci' \ + 'upload macOS assets to draft' \ + 'upload Windows assets to draft' \ + '--clobber' \ + 'publish verified release' \ + 'needs: [macos-installers, windows-installers]' \ + '--draft=false'; do + [[ "$WORKFLOW" == *"$token"* ]] || fail "release gate is missing: $token" +done + +# Default bootstrap/update selection must use the latest PUBLISHED GitHub +# Release. A pushed v* tag that has not completed the release workflow yet must +# not be selected. The official repo fails closed if /releases/latest cannot be +# resolved instead of silently executing main. +for spec in "macOS:$MAC_INSTALLER" "Linux:$LINUX_INSTALLER"; do + platform="${spec%%:*}" + source_text="${spec#*:}" + for token in \ + '/releases/latest' \ + '/releases/tag/' \ + 'published GitHub Release' \ + 'refusing to fall back to main'; do + [[ "$source_text" == *"$token"* ]] || fail "$platform installer is missing published-release guard: $token" + done +done + +for token in \ + 'Get-KitLatestRef' \ + '/releases/latest' \ + '/releases/tag/' \ + 'HttpWebRequest' \ + 'published GitHub Release' \ + 'refusing to fall back to main'; do + [[ "$WINDOWS_INSTALLER" == *"$token"* ]] || fail "Windows installer is missing published-release guard: $token" +done + +[[ "$LINUX_INSTALLER" != *'installing from the existing checkout'* ]] \ + || fail 'Linux bootstrap can still continue from a stale checkout after fetch failure' +[[ "$LINUX_INSTALLER" == *'refusing to use a stale checkout'* ]] \ + || fail 'Linux bootstrap does not fail closed on stale checkout drift' + # Given a tagged release, when the macOS installer job runs, then it must use # the real Developer ID + Apple notarization secrets rather than ad-hoc signing. for token in \ @@ -36,8 +90,7 @@ for token in \ 'spctl --assess'; do [[ "$WORKFLOW" == *"$token"* ]] || fail "release workflow is missing: $token" done -[[ "$WORKFLOW" != *'codesign --force --deep --sign -'* ]] \ - || fail "release workflow still uses ad-hoc signing" +[[ "$WORKFLOW" != *'codesign --force --deep --sign -'* ]] || fail "release workflow still uses ad-hoc signing" # Windows PowerShell 5.1 decodes BOM-less UTF-8 as the active ANSI code page. # The release job must therefore read the Korean launchers explicitly as UTF-8, @@ -56,9 +109,7 @@ for token in \ 'lazy-starter-kit-windows-double-click.zip`n'; do [[ "$WORKFLOW" == *"$token"* ]] || fail "release packaging is missing: $token" done -[[ "$WORKFLOW" != *'Get-Content gui\windows\installer.ps1 -Raw'* ]] \ - || fail 'release workflow decodes the Windows GUI with the default code page' -[[ "$WORKFLOW" != *'Get-Content windows\Install-lazy-starter-kit.cmd -Raw'* ]] \ - || fail 'release workflow decodes the Windows launcher with the default code page' +[[ "$WORKFLOW" != *'Get-Content gui\windows\installer.ps1 -Raw'* ]] || fail 'release workflow decodes the Windows GUI with the default code page' +[[ "$WORKFLOW" != *'Get-Content windows\Install-lazy-starter-kit.cmd -Raw'* ]] || fail 'release workflow decodes the Windows launcher with the default code page' -printf 'PASS release signing and packaging contract\n' +printf 'PASS release signing, gating, bootstrap, and packaging contract\n' diff --git a/windows/install.ps1 b/windows/install.ps1 index 89fff71..3649e96 100644 --- a/windows/install.ps1 +++ b/windows/install.ps1 @@ -36,7 +36,7 @@ .PARAMETER Update Update the kit checkout, then continue the install with the remaining switches (e.g. -Update -Only agents). Branch checkouts fast-forward; detached - release checkouts move to the newest release tag. Requires a git checkout. + release checkouts move to the newest published release. Requires a git checkout. .EXAMPLE irm https://raw.githubusercontent.com/Heoooooon/lazy-starter-kit/main/windows/install.ps1 | iex @@ -81,9 +81,9 @@ $script:RunFromFile = $HomeDir = if ($env:USERPROFILE) { $env:USERPROFILE } else { $HOME } $RepoUrl = if ($env:STARTER_KIT_REPO) { $env:STARTER_KIT_REPO } else { 'https://github.com/Heoooooon/lazy-starter-kit.git' } # 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. $RepoBranch = if ($env:STARTER_KIT_BRANCH) { $env:STARTER_KIT_BRANCH } else { $null } $RepoCommit = if ($env:STARTER_KIT_COMMIT) { $env:STARTER_KIT_COMMIT } else { $null } $CloneDir = if ($env:STARTER_KIT_DIR) { $env:STARTER_KIT_DIR } else { Join-Path $HomeDir '.lazy-starter-kit' } @@ -177,9 +177,39 @@ function Resolve-Root { return (Join-Path $CloneDir 'windows') } -# Get-KitLatestRef -- 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). +# Get-KitLatestRef -- newest published GitHub Release tag. For the official repo, +# failure to resolve a published release is fatal rather than falling back to +# main. Non-GitHub custom repos retain the legacy tag/main fallback. function Get-KitLatestRef { + $repoWeb = $null + if ($RepoUrl.StartsWith('https://github.com/')) { + $repoWeb = $RepoUrl + } elseif ($RepoUrl.StartsWith('git@github.com:')) { + $repoWeb = 'https://github.com/' + $RepoUrl.Substring('git@github.com:'.Length) + } elseif ($RepoUrl.StartsWith('ssh://git@github.com/')) { + $repoWeb = 'https://github.com/' + $RepoUrl.Substring('ssh://git@github.com/'.Length) + } + if ($repoWeb) { + $repoWeb = $repoWeb.TrimEnd('/') + if ($repoWeb.EndsWith('.git')) { $repoWeb = $repoWeb.Substring(0, $repoWeb.Length - 4) } + try { + $request = [System.Net.HttpWebRequest]::Create("$repoWeb/releases/latest") + $request.Method = 'HEAD' + $request.AllowAutoRedirect = $true + $response = $request.GetResponse() + try { $finalUri = $response.ResponseUri.AbsoluteUri } finally { $response.Close() } + $prefix = "$repoWeb/releases/tag/" + if ($finalUri.StartsWith($prefix)) { + $tag = $finalUri.Substring($prefix.Length).TrimEnd('/') + if ($tag -and $tag -notmatch '/') { return $tag } + } + } catch {} + } + + if ($repoWeb -eq 'https://github.com/Heoooooon/lazy-starter-kit') { + throw 'Could not resolve the latest published lazy-starter-kit release; refusing to fall back to main.' + } + try { $line = (git ls-remote --tags --refs --sort=-v:refname $RepoUrl 'v*' 2>$null | Select-Object -First 1) if ($line -and $line -match 'refs/tags/(.+)$') { return $Matches[1] } @@ -258,7 +288,8 @@ if ($Update) { $oldVersion = $KitVersion # On a branch (manual clone / dev checkout) fast-forward it and leave the user # where they put themselves. Detached means the bootstrap pinned a release tag, - # so "update" means move to the newest release (or to STARTER_KIT_BRANCH). + # so "update" means move to the newest published release (or to + # STARTER_KIT_BRANCH when explicitly pinned). # Invoke-NativeSilently: git writes progress to stderr, which under EAP=Stop on # WinPS 5.1 would abort the run; we only probe $LASTEXITCODE here. Invoke-NativeSilently 'git' @('-C', $checkoutRoot, 'symbolic-ref', '-q', 'HEAD') | Out-Null @@ -269,11 +300,7 @@ if ($Update) { Stop-Kit "git pull --ff-only failed (exit $LASTEXITCODE). Resolve local changes or divergence, then re-run." } } else { - $ref = if ($env:STARTER_KIT_BRANCH) { $env:STARTER_KIT_BRANCH } else { $null } - if (-not $ref) { - $line = (Invoke-NativeSilently 'git' @('-C', $checkoutRoot, 'ls-remote', '--tags', '--refs', '--sort=-v:refname', 'origin', 'v*') | Select-Object -First 1) - if ($line -and $line -match 'refs/tags/(.+)$') { $ref = $Matches[1] } else { $ref = 'main' } - } + $ref = if ($env:STARTER_KIT_BRANCH) { $env:STARTER_KIT_BRANCH } else { Get-KitLatestRef } Write-Step "Update: checking out $ref" Invoke-NativeSilently 'git' @('-C', $checkoutRoot, 'fetch', '--depth', '1', 'origin', $ref) | Out-Null if ($LASTEXITCODE -ne 0) { @@ -526,4 +553,4 @@ if ($script:DryRun) { } } } -if ($script:RunFromFile) { exit 0 } else { return } +if ($script:RunFromFile) { exit 0 } else { return } \ No newline at end of file