chore(pkg,ci): security/packaging tail (#129)#132
Conversation
Deferred security/packaging items from the 2026-07-17 triage. Verified with Invoke-Pester (590 passing). auto-tag — pin the reusable dotfiles-core auto-tag-call workflow to a full commit SHA (e9cf907, = v3) instead of the mutable @V3 tag, matching ci.yml's SHA-pinned Actions discipline; fix the stale "@v2" comment. install .bak accumulation — add Test-CopyCurrent and skip the back-up+recopy in COPY mode (no symlink capability) when the destination already matches the target's content. Files compare by SHA-256, directories by their full {relative path -> SHA-256} set. Previously every unelevated re-install dropped a fresh timestamped .bak and recopied identical bytes, piling up backups. scoop-installer hash — apply the same opt-in DOTFILES_SCOOP_SHA256 gate the installer uses to the CI freshness path (Check-PackageFreshness.ps1), which had none. The SHA is computed over the UTF-8 string bytes exactly as install.ps1's Get-DotStringSha256, so one expected-hash value covers both. No hardcoded pin by design — get.scoop.sh is a moving target. winget-import drift gate — add a Pester test asserting the committed packages/winget-import.json is byte-identical to a fresh Export-WingetImport regeneration (it went stale silently before). Adds an -OutPath param to the generator so the test regenerates to a temp path without touching the repo copy. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KSeBsL1YesMCpEqxHXSKkv
There was a problem hiding this comment.
Pull request overview
This PR picks up the remaining “security / packaging tail” items by tightening CI supply-chain checks, adding drift gates for generated artifacts, and making copy-mode installs idempotent to prevent .bak accumulation on hosts without symlink capability.
Changes:
- Pin the
auto-tagreusable workflow call to an immutable commit SHA. - Add copy-mode “already current” detection (file + directory tree hashing) and tests to avoid repeated backup/recopy churn.
- Add CI integrity gating for the Scoop installer and a drift test ensuring
packages/winget-import.jsonmatches a fresh regeneration (with a new-OutPathoption).
Reviewed changes
Copilot reviewed 6 out of 6 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| tests/Packages.Tests.ps1 | Adds a Pester drift gate to ensure packages/winget-import.json matches a freshly generated output byte-for-byte. |
| tests/Install.Tests.ps1 | Adds unit tests for the new copy-mode idempotency helper (Test-CopyCurrent). |
| packages/Export-WingetImport.ps1 | Adds -OutPath to support deterministic regen to a temp path for drift testing. |
| packages/Check-PackageFreshness.ps1 | Adds an opt-in Scoop installer SHA-256 verification path in CI when Scoop isn’t present. |
| install.ps1 | Adds Test-CopyCurrent and uses it to skip redundant copy-mode work to prevent .bak accumulation. |
| .github/workflows/auto-tag.yml | Pins the reusable workflow reference to a full commit SHA (supply-chain hardening). |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| # Directory: hash every file, keyed by its path relative to the tree root. | ||
| $hashTree = { | ||
| param([string]$Root) | ||
| $root = (Resolve-Path -LiteralPath $Root).Path | ||
| $map = @{} # PowerShell hashtables are case-insensitive, matching NTFS | ||
| Get-ChildItem -LiteralPath $root -Recurse -File -Force -ErrorAction SilentlyContinue | ForEach-Object { | ||
| $rel = $_.FullName.Substring($root.Length).TrimStart('\', '/') | ||
| $map[$rel] = (Get-FileHash -LiteralPath $_.FullName).Hash | ||
| } | ||
| $map | ||
| } | ||
| $a = & $hashTree $Link | ||
| $b = & $hashTree $Target | ||
| if ($a.Count -ne $b.Count) { return $false } | ||
| foreach ($k in $a.Keys) { | ||
| if (-not $b.ContainsKey($k) -or $b[$k] -ne $a[$k]) { return $false } | ||
| } | ||
| return $true | ||
| } |
There was a problem hiding this comment.
Fixed. Test-CopyCurrent now (1) wraps the directory walk in try/catch with -ErrorAction Stop, so any traversal/hash error returns $false (not-current -> safe recopy) instead of silently dropping files, and (2) maps directory entries too (files -> SHA-256, subdirs -> a <dir> sentinel) so an added/removed empty directory is detected. Added a covering test.
| if ($actual -ne $env:DOTFILES_SCOOP_SHA256.ToLowerInvariant()) { | ||
| throw "scoop installer hash mismatch — expected $($env:DOTFILES_SCOOP_SHA256), got $actual" | ||
| } | ||
| } | ||
| # Must run as a FILE (scoop's installer takes -RunAsAdmin, which iex can't |
There was a problem hiding this comment.
Fixed both points. The hash mismatch now re-throws from the outer catch (transient setup errors still warn+continue), so a configured mismatch fails the run. And the installer is written with [IO.File]::WriteAllBytes using the exact UTF-8 byte array that was hashed (was Set-Content, which re-encodes/appends a newline) — verified the on-disk file hashes to the gated value.
Test-CopyCurrent (install.ps1): - Treat any traversal/hash error as "not current" (return $false) so an incomplete comparison recopies rather than falsely skipping — swapped the swallowed -ErrorAction SilentlyContinue for -ErrorAction Stop inside a try/catch guard. - Include directory entries in the tree map (files -> SHA-256, subdirs -> '<dir>' sentinel) so an added/removed EMPTY directory is detected too. Added a covering test. Check-PackageFreshness.ps1: - The DOTFILES_SCOOP_SHA256 gate was ineffective: the mismatch throw was swallowed by the surrounding tolerant catch. Re-throw a hash mismatch so the run fails; transient setup errors still warn and continue. - Write the EXACT hashed bytes with [IO.File]::WriteAllBytes (was Set-Content, which re-encodes / appends a newline) so the installer that runs is byte-for-byte what was verified. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KSeBsL1YesMCpEqxHXSKkv
| # Must run as a FILE (scoop's installer takes -RunAsAdmin, which iex can't | ||
| # pass). WriteAllBytes emits exactly the verified bytes — no re-encoding. | ||
| $installer = Join-Path ([System.IO.Path]::GetTempPath()) 'install-scoop.ps1' | ||
| Invoke-RestMethod -Uri 'https://get.scoop.sh' -OutFile $installer | ||
| [System.IO.File]::WriteAllBytes($installer, $scoopBytes) | ||
| & $installer -RunAsAdmin 2>&1 | Out-Null # CI runs elevated; -RunAsAdmin lets scoop install anyway | ||
| $env:PATH = "$HOME\scoop\shims;$env:PATH" # make `scoop` resolvable in this session |
Picks up the security / packaging tail from #129. Verified on the Windows host with
Invoke-Pester(590 passing).Changes
auto-tag — SHA-pin the reusable workflow.
auto-tag.ymldelegated todotfiles-core/.github/workflows/auto-tag-call.yml@v3(a mutable tag); pinned it to the full commit SHAe9cf907(= v3), matchingci.yml's SHA-pinned Actions discipline so a moved tag can't silently change what runs. Also fixed the stale@v2comment.install
.bakaccumulation. AddedTest-CopyCurrentand a copy-mode idempotency skip inLink-Item: on a host with no symlink capability, if the destination already matches the target's content, skip instead of backing up + recopying. Files compare by SHA-256; directories (nvim\,psmux\scripts) by their full{relative path → SHA-256}set. Previously every unelevated re-install dropped a fresh timestamped.bakand recopied identical bytes — backups piled up unbounded. Covered by 7 newTest-CopyCurrenttests.scoop-installer hash — close the CI gap.
install.ps1verifiesget.scoop.shwhenDOTFILES_SCOOP_SHA256is set, but the CI path (Check-PackageFreshness.ps1, runs elevated) had no gate. Applied the same opt-in gate there. The SHA is computed over the UTF-8 string bytes exactly asinstall.ps1'sGet-DotStringSha256(verified they produce identical digests), so one expected-hash value covers both paths. No hardcoded pin by design —get.scoop.shis a moving target, so a baked-in SHA would break CI on every upstream installer edit (decision confirmed with the maintainer).winget-import drift gate. Added a Pester test asserting the committed
packages/winget-import.jsonis byte-identical to a freshExport-WingetImportregeneration (it went stale silently before). Added an-OutPathparam to the generator so the test regenerates to a temp path without touching the repo copy. The generator is deterministic (noCreationDate/WinGetVersionstamp), so a clean tree round-trips exactly.Verification
Test-CopyCurrent, 1 winget drift).Get-DotStringSha256for the same input, and the committedwinget-import.jsonmatches a fresh regen byte-for-byte.Completes the deferred #129 items (alongside #131 for the dedup/dead-code hygiene). The only remaining checkbox is the psmux CRIT-1 runtime-verify, which needs an interactive psmux session and can't be automated in CI.
🤖 Generated with Claude Code