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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .github/workflows/validate.yml
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,10 @@ jobs:
set "E=%ERRORLEVEL%"
popd
exit /b %E%
- name: Assert broken managed-link cleanup
shell: pwsh
run: ./tests/windows/assert-migration.ps1

- shell: pwsh
run: ./tests/windows/assert-state.ps1 -RepoRoot $env:GITHUB_WORKSPACE -ExpectedCommit $env:GITHUB_SHA
- shell: cmd
Expand Down Expand Up @@ -147,6 +151,10 @@ jobs:
set "E=%ERRORLEVEL%"
popd
exit /b %E%
- name: Assert broken managed-link cleanup under AllSigned
shell: cmd
run: call "%GITHUB_WORKSPACE%\scripts\windows\invoke-ps-script.cmd" "%GITHUB_WORKSPACE%\tests\windows\assert-migration.ps1"

- shell: cmd
run: call "%GITHUB_WORKSPACE%\scripts\windows\invoke-ps-script.cmd" "%GITHUB_WORKSPACE%\tests\windows\assert-allsigned.ps1" -RepoRoot "%GITHUB_WORKSPACE%" -ThumbprintFile "%RUNNER_TEMP%\dotfiles-cert-thumbprint.txt"
- shell: cmd
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ Run this from a normal `cmd.exe` prompt:
curl.exe -fsSLo "%TEMP%\dotfiles-bootstrap.cmd" https://raw.githubusercontent.com/jsilverdev/dotfiles/main/bootstrap.cmd && call "%TEMP%\dotfiles-bootstrap.cmd"
```

The Windows bootstrap requires WinGet. If App Installer exists but WinGet is not registered for the current user, it attempts current-user App Installer registration. If corporate policy disables WinGet, it stops with an error. Bootstrap installs only Git, PowerShell 7, and chezmoi when they are missing; the platform installer owns the remaining application catalog.
The Windows bootstrap requires WinGet. Before applying chezmoi, it removes only broken links or reparse points that block a currently managed destination path. Parent components under the user profile are checked as well, but valid links are never traversed and unrelated broken links are left untouched. If App Installer exists but WinGet is not registered for the current user, it attempts current-user App Installer registration. If corporate policy disables WinGet, it stops with an error. Bootstrap installs only Git, PowerShell 7, and chezmoi when they are missing; the platform installer owns the remaining application catalog.

Baseline CLI packages are declared in `scripts/windows/managed-apps.csv` and are installed with WinGet in user scope. An interactive run also installs workstation applications, fonts, Windows Terminal customization, and offers optional applications. `DOTFILES_NONINTERACTIVE=1` installs the complete baseline but skips those interactive/workstation customizations.

Expand All @@ -26,7 +26,7 @@ On Debian or Arch Linux, run:
bash <(curl -fsSL https://raw.githubusercontent.com/jsilverdev/dotfiles/main/bootstrap.sh)
```

The bootstrap installs only the prerequisites required to obtain/apply the repository, installs chezmoi in `~/.local/bin` when needed, and then runs the package installer. Linux package catalogs are declared under `scripts/linux/`. Non-interactive mode still installs and validates the complete baseline; it only skips shell changes, WSL system configuration, and optional-package prompts. Arch continues to install and manage `yay`.
The bootstrap installs only the prerequisites required to obtain/apply the repository, installs chezmoi in `~/.local/bin` when needed, removes only broken symlinks that block currently managed destination paths, and then applies the source state before running the package installer. Parent components under `$HOME` are checked without traversing valid symlinks, and unrelated broken links are left untouched. Linux package catalogs are declared under `scripts/linux/`. Non-interactive mode still installs and validates the complete baseline; it only skips shell changes, WSL system configuration, and optional-package prompts. Arch continues to install and manage `yay`.

## Updates

Expand Down
31 changes: 23 additions & 8 deletions bootstrap.cmd
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ if defined DOTFILES_REPO (
) else (
set "REPO_URL=https://github.com/jsilverdev/dotfiles.git"
)
set "DOTFILES_EXISTING_SOURCE=0"
where winget.exe >nul 2>&1
if errorlevel 1 (
echo WinGet is not registered for this user. Attempting App Installer registration...
Expand Down Expand Up @@ -56,6 +57,23 @@ if not exist "%REPO_ROOT%\scripts\windows\invoke-ps-script.cmd" (
echo Resolved chezmoi working tree does not contain the dotfiles scripts: "%REPO_ROOT%" 1>&2
goto bootstrap_failed
)
if "%DOTFILES_EXISTING_SOURCE%"=="1" (
git.exe -C "%REPO_ROOT%" pull --autostash --rebase
if errorlevel 1 (
echo Unable to update the existing chezmoi source tree. 1>&2
goto bootstrap_failed
)
)
call "%REPO_ROOT%\scripts\windows\invoke-ps-script.cmd" "%REPO_ROOT%\scripts\windows\cleanup-broken-managed-links.ps1" -RepoRoot "%REPO_ROOT%"
if errorlevel 1 (
echo Broken managed-link cleanup failed. 1>&2
goto bootstrap_failed
)
chezmoi.exe --source "%REPO_ROOT%" apply
if errorlevel 1 (
echo chezmoi apply failed. 1>&2
goto bootstrap_failed
)
if /I "%DOTFILES_NONINTERACTIVE%"=="1" (
call "%REPO_ROOT%\scripts\windows\invoke-ps-script.cmd" "%REPO_ROOT%\install.ps1" -NonInteractive -RepoRoot "%REPO_ROOT%"
) else (
Expand Down Expand Up @@ -96,19 +114,16 @@ if defined SOURCE_ROOT for %%P in ("%SOURCE_ROOT%\..") do if exist "%%~fP\instal
exit /b 1

:initialize_chezmoi
if exist "%CD%\.chezmoiroot" (
chezmoi.exe --source "%CD%" apply
exit /b %ERRORLEVEL%
)
if exist "%CD%\.chezmoiroot" exit /b 0

set "SOURCE_ROOT="
for /f "delims=" %%R in ('chezmoi.exe source-path 2^>nul') do set "SOURCE_ROOT=%%R"
if defined SOURCE_ROOT if exist "%SOURCE_ROOT%\.chezmoiroot" goto existing_chezmoi
if defined SOURCE_ROOT for %%P in ("%SOURCE_ROOT%\..") do if exist "%%~fP\.chezmoiroot" goto existing_chezmoi

chezmoi.exe init "%REPO_URL%"
if errorlevel 1 exit /b %ERRORLEVEL%
chezmoi.exe apply
exit /b %ERRORLEVEL%

:existing_chezmoi
chezmoi.exe update
exit /b %ERRORLEVEL%
set "DOTFILES_EXISTING_SOURCE=1"
exit /b 0
58 changes: 34 additions & 24 deletions bootstrap.sh
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,29 @@ configure_local_chezmoi_source() {
fi
}

resolve_repo_root() {
local candidate parent
if [[ -f "$PWD/.chezmoiroot" && -f "$PWD/install.sh" ]]; then
printf '%s\n' "$PWD"
return 0
fi

for candidate in "$(chezmoi execute-template '{{ .chezmoi.workingTree }}' 2>/dev/null || true)" "$(chezmoi source-path 2>/dev/null || true)"; do
[[ -n "$candidate" ]] || continue
if [[ -f "$candidate/install.sh" ]]; then
printf '%s\n' "$candidate"
return 0
fi
parent="$(dirname "$candidate")"
if [[ -f "$parent/install.sh" ]]; then
printf '%s\n' "$parent"
return 0
fi
done

return 1
}

if [[ -f /etc/debian_version ]] && command -v apt-get >/dev/null 2>&1; then
DISTRO="debian"
missing=()
Expand Down Expand Up @@ -51,38 +74,25 @@ fi
export PATH="$HOME/.local/bin:$PATH"
command -v chezmoi >/dev/null 2>&1 || { printf 'chezmoi installation failed.\n' >&2; exit 1; }

existing_source=false
if [[ -f "$PWD/.chezmoiroot" ]]; then
configure_local_chezmoi_source
chezmoi --source "$PWD" apply
else
SOURCE_ROOT="$(chezmoi source-path 2>/dev/null || true)"
if [[ -f "$SOURCE_ROOT/.chezmoiroot" || -f "$(dirname "$SOURCE_ROOT")/.chezmoiroot" ]]; then
chezmoi update
existing_source=true
else
chezmoi init --apply "$REPO_URL"
chezmoi init "$REPO_URL"
fi
fi

resolve_repo_root() {
local candidate parent
if [[ -f "$PWD/.chezmoiroot" && -f "$PWD/install.sh" ]]; then
printf '%s\n' "$PWD"
return 0
fi
for candidate in "$(chezmoi execute-template '{{ .chezmoi.workingTree }}' 2>/dev/null || true)" "$(chezmoi source-path)"; do
[[ -n "$candidate" ]] || continue
if [[ -f "$candidate/install.sh" ]]; then
printf '%s\n' "$candidate"
return 0
fi
parent="$(dirname "$candidate")"
if [[ -f "$parent/install.sh" ]]; then
printf '%s\n' "$parent"
return 0
fi
done
return 1
}

REPO_ROOT="$(resolve_repo_root)" || { printf 'Unable to resolve the chezmoi working tree.\n' >&2; exit 1; }

if [[ "$existing_source" == true ]]; then
git -C "$REPO_ROOT" pull --autostash --rebase
fi

"$REPO_ROOT/scripts/linux/cleanup-broken-managed-links.sh" "$REPO_ROOT"
chezmoi --source "$REPO_ROOT" apply

exec "$REPO_ROOT/install.sh"
42 changes: 42 additions & 0 deletions scripts/linux/cleanup-broken-managed-links.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
#!/usr/bin/env bash
set -euo pipefail

repo_root="${1:?repository root is required}"
home_root="${HOME%/}"

cleanup_managed_target() {
local managed_path=$1 relative current component

case "$managed_path" in
"$home_root") return 0 ;;
"$home_root"/*) relative=${managed_path#"$home_root"/} ;;
*) return 0 ;;
esac

current="$home_root"
IFS='/' read -r -a components <<< "$relative"
for component in "${components[@]}"; do
[[ -n "$component" && "$component" != "." ]] || continue
[[ "$component" != ".." ]] || return 0

current="$current/$component"

if [[ -L "$current" ]]; then
if [[ ! -e "$current" ]]; then
printf 'Removing broken managed symlink: %s\n' "$current"
rm -- "$current"
fi

# Never traverse through a symlink, whether valid or broken.
return 0
fi

[[ -e "$current" ]] || return 0
done
}

while IFS= read -r -d '' managed_path; do
cleanup_managed_target "$managed_path"
done < <(
chezmoi --source "$repo_root" managed --path-style absolute --nul-path-separator
)
88 changes: 88 additions & 0 deletions scripts/windows/cleanup-broken-managed-links.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
[CmdletBinding()]
param(
[Parameter(Mandatory)][string]$RepoRoot
)

$ErrorActionPreference = "Stop"

function Get-DirectoryEntry {
param([Parameter(Mandatory)][string]$Path)

$parent = [IO.Path]::GetDirectoryName($Path)
$leaf = [IO.Path]::GetFileName($Path)

if ([string]::IsNullOrWhiteSpace($parent) -or -not (Test-Path -LiteralPath $parent -PathType Container)) {
return $null
}

Get-ChildItem -LiteralPath $parent -Force -ErrorAction Stop |
Where-Object Name -eq $leaf |
Select-Object -First 1
}

function Test-BrokenLink {
param([Parameter(Mandatory)]$Item)

if (($Item.Attributes -band [IO.FileAttributes]::ReparsePoint) -eq 0) {
return $false
}

try {
$target = $Item.ResolveLinkTarget($true)
return $null -eq $target -or -not $target.Exists
}
catch {
return $true
}
}

$managedPaths = @(& chezmoi.exe --source $RepoRoot managed --path-style absolute)
if ($LASTEXITCODE -ne 0) {
throw "chezmoi managed failed with exit code $LASTEXITCODE."
}

$homeRoot = [IO.Path]::GetFullPath($HOME).TrimEnd([IO.Path]::DirectorySeparatorChar)

foreach ($managedPath in $managedPaths) {
if ([string]::IsNullOrWhiteSpace($managedPath)) {
continue
}

$fullPath = [IO.Path]::GetFullPath([string]$managedPath)
$relativePath = [IO.Path]::GetRelativePath($homeRoot, $fullPath)

if ([IO.Path]::IsPathRooted($relativePath) -or $relativePath -eq ".." -or $relativePath.StartsWith("..$([IO.Path]::DirectorySeparatorChar)")) {
continue
}

$current = $homeRoot
foreach ($component in $relativePath -split '[\\/]') {
if ([string]::IsNullOrWhiteSpace($component) -or $component -eq ".") {
continue
}
if ($component -eq "..") {
break
}

$current = Join-Path $current $component
$item = Get-DirectoryEntry -Path $current
if ($null -eq $item) {
break
}

if (($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) {
if (Test-BrokenLink -Item $item) {
Write-Host "Removing broken managed reparse point: $current" -ForegroundColor Yellow
if ($item.PSIsContainer) {
[IO.Directory]::Delete($current)
}
else {
[IO.File]::Delete($current)
}
}

# Never traverse through a reparse point, whether valid or broken.
break
}
}
}
4 changes: 2 additions & 2 deletions tests/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
GitHub Actions intentionally contains orchestration only. Reusable fixture and assertion logic lives under this directory.

- `static/` validates source structure, manifests, shell code, PowerShell syntax, chezmoi templates, the encoded AllSigned bridge, and the workflow with actionlint.
- `linux/` runs the real non-interactive Debian and Arch baseline, including package installation, `yay` on Arch, update behavior, and post-bootstrap assertions.
- `windows/` owns WinGet fixture setup, normal-policy assertions, AllSigned assertions, and update fixtures.
- `linux/` runs the real non-interactive Debian and Arch baseline, including package installation, `yay` on Arch, broken-managed-link recovery, unrelated-link preservation, update behavior, and post-bootstrap assertions.
- `windows/` owns WinGet fixture setup, broken-link migration fixtures, normal-policy assertions, AllSigned assertions, and update fixtures.

`DOTFILES_NONINTERACTIVE=1` is the only CI execution mode. It is also a supported real-world mode: baseline dependencies are installed normally while prompts, GUI/workstation customization, shell changes, and WSL setup are skipped.
16 changes: 16 additions & 0 deletions tests/linux/run-ci.sh
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,24 @@ as_ci() {
sudo -u dotfilesci -H env HOME=/home/dotfilesci USER=dotfilesci "$@"
}

as_ci bash -lc '
mkdir -p "$HOME/.config"

ln -s /nonexistent/legacy-fdignore "$HOME/.fdignore"
ln -s /nonexistent/legacy-starship "$HOME/.config/starship"
ln -s /nonexistent/unrelated-link "$HOME/.dotfiles-ci-unrelated-broken-link"
'

as_ci GITHUB_WORKSPACE="$repo" DOTFILES_REPO="file://$remote" DOTFILES_NONINTERACTIVE=1 bash -lc 'cd /tmp; bash "$GITHUB_WORKSPACE/bootstrap.sh"'

as_ci bash -lc '
[[ -f "$HOME/.fdignore" && ! -L "$HOME/.fdignore" ]]
[[ -d "$HOME/.config/starship" && ! -L "$HOME/.config/starship" ]]
[[ -f "$HOME/.config/starship/config.toml" ]]
[[ -L "$HOME/.dotfiles-ci-unrelated-broken-link" ]]
[[ ! -e "$HOME/.dotfiles-ci-unrelated-broken-link" ]]
'

as_ci PATH="/home/dotfilesci/.local/bin:$PATH" GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=safe.directory GIT_CONFIG_VALUE_0="$repo" GITHUB_WORKSPACE="$repo" D="$distro" C="$commit" bash -lc 'bash "$GITHUB_WORKSPACE/tests/linux/assert-state.sh" "$D" "$C"'

marker="ci-update-marker-${GITHUB_RUN_ID:-local}"
Expand Down
3 changes: 3 additions & 0 deletions tests/static/validate-architecture.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,15 +35,18 @@ def read_text(path: str) -> str:
required_paths = (
"home",
"scripts/linux/packages-debian.txt",
"scripts/linux/cleanup-broken-managed-links.sh",
"scripts/linux/packages-arch.txt",
"scripts/linux/required-commands.txt",
"scripts/windows/managed-apps.csv",
"scripts/windows/cleanup-broken-managed-links.ps1",
"scripts/windows/managed-modules.txt",
"scripts/windows/invoke-ps-script.cmd",
"scripts/windows/invoke-ps-script-bridge.ps1",
"scripts/windows/signing.ps1",
"scripts/windows/deploy-pwsh.ps1",
"home/.chezmoiscripts/run_after_90-deploy-pwsh.cmd.tmpl",
"tests/windows/assert-migration.ps1",
)
for relative in required_paths:
if not (ROOT / relative).exists():
Expand Down
1 change: 1 addition & 0 deletions tests/static/validate-source.sh
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ validate_shell() {
update.sh
tests/linux/assert-state.sh
tests/linux/run-ci.sh
scripts/linux/cleanup-broken-managed-links.sh
tests/static/validate-source.sh
)
bash -n "${shell_files[@]}"
Expand Down
Loading