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
19 changes: 19 additions & 0 deletions .editorconfig
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# Editor settings matching .gitattributes and the CI checks (ASCII .ps1 files without BOM, CRLF for PowerShell, LF elsewhere).
root = true

[*]
charset = utf-8
insert_final_newline = true
trim_trailing_whitespace = true
indent_style = space
indent_size = 4

[*.{ps1,psm1,psd1}]
end_of_line = crlf

[*.{json,md,yml,yaml}]
end_of_line = lf
indent_size = 2

[*.md]
trim_trailing_whitespace = false
6 changes: 3 additions & 3 deletions .github/ISSUE_TEMPLATE/config.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,6 @@ contact_links:
- name: Security Vulnerability
url: https://github.com/26zl/PowerShellPerfect/security/advisories/new
about: Report security issues privately via GitHub Security Advisories
- name: General Questions
url: https://github.com/26zl/PowerShellPerfect/discussions
about: Ask general usage questions in GitHub Discussions
- name: Usage questions and troubleshooting
url: https://github.com/26zl/PowerShellPerfect#troubleshooting
about: Check the troubleshooting table and run psp-doctor; if that does not help, open a bug report
8 changes: 6 additions & 2 deletions .github/PULL_REQUEST_TEMPLATE.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,14 @@

## Checklist

- [ ] PSScriptAnalyzer passes with no warnings or errors
- [ ] `tests/lint.ps1` passes with no warnings or errors
- [ ] `tests/unit.ps1` passes on PowerShell 5.1 and 7+
- [ ] Bug fix: a regression case in `tests/unit.ps1` fails without the fix
- [ ] Tested on PowerShell 5.1 (if script behavior changed)
- [ ] Tested on PowerShell 7+ (if script behavior changed)
- [ ] User-visible change listed under **Unreleased** in `CHANGELOG.md`
- [ ] New downloads are pinned (version and SHA-256 where available); new third-party calls are mocked in CI and listed in `SECURITY.md`
- [ ] No hardcoded user paths (`C:\Users\`, `/home/`)
- [ ] No embedded secrets or tokens
- [ ] No em dashes or non-ASCII characters in source files
- [ ] UTF-8 without BOM for all file writes (`[System.IO.File]::WriteAllText()`)
- [ ] UTF-8 without BOM for all file writes (`Write-Utf8FileAtomic` or `[System.IO.File]::WriteAllText()`)
309 changes: 77 additions & 232 deletions .github/workflows/ci.yml

Large diffs are not rendered by default.

82 changes: 82 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
name: Release

# Push a tag vX.Y.Z (matching Version in the profile) to publish a release with SHA256SUMS.txt and a build provenance
# attestation. setup.ps1 -Ref vX.Y.Z and Update-Profile -Ref vX.Y.Z verify their downloads against that SHA256SUMS.txt.
on:
push:
tags: ['v*.*.*']

permissions:
contents: read

jobs:
release:
runs-on: ubuntu-24.04
timeout-minutes: 15
permissions:
contents: write # create the release and upload its files
id-token: write # sign the build provenance attestation
attestations: write # store the attestation
steps:
- name: Harden Runner
uses: step-security/harden-runner@ccd8616d44fd3846e67624a50d5aad6d37bf2d25 # v2.22.1
with:
# Audit first; switch to block with the endpoints from the first run's insights.
egress-policy: audit

- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false

- name: Build release files from the tagged commit
shell: bash
env:
TAG: ${{ github.ref_name }}
run: |
set -euo pipefail
# Release files are the committed blobs (LF line endings), byte-identical to what raw.githubusercontent.com serves
# for this tag; a checkout would convert .ps1 files to CRLF and change their hashes.
mkdir -p dist
files="setup.ps1 setprofile.ps1 Microsoft.PowerShell_profile.ps1 theme.json terminal-config.json"
for f in $files; do git cat-file blob "${GITHUB_SHA}:${f}" > "dist/${f}"; done
version=$(grep -oP "^\s*Version\s*=\s*'\K[0-9]+\.[0-9]+\.[0-9]+" dist/Microsoft.PowerShell_profile.ps1 | head -n 1)
if [ "v${version}" != "${TAG}" ]; then
echo "::error::Tag ${TAG} does not match Version '${version}' in Microsoft.PowerShell_profile.ps1"
exit 1
fi
(cd dist && sha256sum $files > SHA256SUMS.txt)
hash() { sha256sum "dist/$1" | cut -d' ' -f1 | tr 'a-f' 'A-F'; }
bundle=$(printf 'profile:%s:theme:%s:terminal:%s' "$(hash Microsoft.PowerShell_profile.ps1)" "$(hash theme.json)" "$(hash terminal-config.json)" | sha256sum | cut -d' ' -f1 | tr 'a-f' 'A-F')
# Release notes: the CHANGELOG section for this version plus how to verify and install.
awk -v v="${version}" '$0 ~ "^## \\[" v "\\]" {found=1; next} found && /^## \[/ {exit} found {print}' CHANGELOG.md > notes.md
if [ ! -s notes.md ]; then
echo "::error::CHANGELOG.md has no section for ${version}"
exit 1
fi
{
echo
echo "## Verify and install"
echo
echo "Each file's SHA-256 is in \`SHA256SUMS.txt\`; \`gh attestation verify <file> --repo ${GITHUB_REPOSITORY}\` checks that it was built here from this tag."
echo "\`setup.ps1 -Ref ${TAG}\` and \`Update-Profile -Ref ${TAG}\` check their downloads against \`SHA256SUMS.txt\` automatically."
echo "Install bundle hash (\`-ExpectedSha256\`): \`${bundle}\`"
echo
echo "See the README for the full install command."
} >> notes.md
cat dist/SHA256SUMS.txt

- name: Attest build provenance
uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2
with:
subject-path: 'dist/*'

- name: Publish the release
shell: bash
env:
GH_TOKEN: ${{ github.token }}
TAG: ${{ github.ref_name }}
run: |
set -euo pipefail
# Upload the files to a draft first, so an immutable release is published complete.
gh release create "${TAG}" dist/* --repo "${GITHUB_REPOSITORY}" --draft --verify-tag --title "PowerShellPerfect ${TAG}" --notes-file notes.md
gh release edit "${TAG}" --repo "${GITHUB_REPOSITORY}" --draft=false
98 changes: 98 additions & 0 deletions .github/workflows/scheduled.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
name: Scheduled checks

# Pull-request CI mocks third-party web services; this workflow covers what that leaves out.
on:
schedule:
- cron: '17 4 * * 1' # Mondays 04:17 UTC
workflow_dispatch:

permissions:
contents: read

jobs:
live-network:
runs-on: windows-2025
timeout-minutes: 30
steps:
- name: Harden Runner
uses: step-security/harden-runner@ccd8616d44fd3846e67624a50d5aad6d37bf2d25 # v2.22.1
with:
# Windows runners support audit mode only (block mode needs a Linux runner), so egress is logged, not blocked.
egress-policy: audit

- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false

- name: Functional tests against the real endpoints (no public paste upload)
shell: pwsh
run: pwsh -NoProfile -File tests/ci-functional.ps1 -AllowElevated -LiveNetwork

pins:
runs-on: ubuntu-24.04
timeout-minutes: 10
steps:
- name: Harden Runner
uses: step-security/harden-runner@ccd8616d44fd3846e67624a50d5aad6d37bf2d25 # v2.22.1
with:
egress-policy: audit

- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false

- name: Pinned downloads still match their hashes
shell: pwsh
run: |
$ErrorActionPreference = 'Stop'
. ./tests/lib/Sandbox.ps1
foreach ($v in '$script:NerdFontVersion', '$script:CuratedFonts', '$script:OmpThemesRef', '$script:PinnedOmpThemes') {
. ([scriptblock]::Create((Get-PspAssignmentSource -Path 'setup.ps1' -Variable $v)))
}
. ([scriptblock]::Create((Get-PspFunctionSource -Path 'Microsoft.PowerShell_profile.ps1' -Name 'Get-PspComponentPins')))
$problems = @()
$work = Join-Path ([System.IO.Path]::GetTempPath()) 'psp-pins'
New-Item -ItemType Directory -Path $work -Force | Out-Null

# Nerd Fonts: the curated fonts and terminal-config.json against the release's own SHA-256.txt.
$sumsFile = Join-Path $work 'nf-sums.txt'
Invoke-WebRequest "https://github.com/ryanoasis/nerd-fonts/releases/download/v$($script:NerdFontVersion)/SHA-256.txt" -OutFile $sumsFile
$nf = @{}
foreach ($line in Get-Content $sumsFile) { if ($line -match '^([0-9a-f]{64})\s+(\S+)$') { $nf[$Matches[2]] = $Matches[1].ToUpperInvariant() } }
foreach ($font in $script:CuratedFonts) {
if ($nf["$($font.Asset).zip"] -ne $font.Sha256) { $problems += "setup.ps1 hash for $($font.Asset).zip does not match Nerd Fonts v$($script:NerdFontVersion)" }
}
$fontInstall = (Get-Content 'terminal-config.json' -Raw | ConvertFrom-Json).fontInstall
if ($fontInstall.version -ne $script:NerdFontVersion) { $problems += "terminal-config.json font version $($fontInstall.version) differs from setup.ps1 $($script:NerdFontVersion)" }
elseif ($nf["$($fontInstall.name).zip"] -ne $fontInstall.sha256.ToUpperInvariant()) { $problems += "terminal-config.json hash for $($fontInstall.name).zip does not match" }

# Oh My Posh themes pinned in setup.ps1 and theme.json.
foreach ($name in $script:PinnedOmpThemes.Keys) {
$file = Join-Path $work "$name.omp.json"
Invoke-WebRequest "https://raw.githubusercontent.com/JanDeDobbeleer/oh-my-posh/$($script:OmpThemesRef)/themes/$name.omp.json" -OutFile $file
if ((Get-FileHash $file -Algorithm SHA256).Hash -ne $script:PinnedOmpThemes[$name]) { $problems += "Oh My Posh theme $name changed at $($script:OmpThemesRef)" }
}
$theme = (Get-Content 'theme.json' -Raw | ConvertFrom-Json).theme
$file = Join-Path $work 'theme-json.omp.json'
Invoke-WebRequest $theme.url -OutFile $file
if ((Get-FileHash $file -Algorithm SHA256).Hash -ne $theme.sha256.ToUpperInvariant()) { $problems += "theme.json hash for $($theme.url) does not match" }

# Pinned packages still exist.
$pins = Get-PspComponentPins
# The Packages(Id,Version) entry answers 404 for a version that does not exist (a filtered search would return an empty feed).
try {
$entry = Invoke-RestMethod "https://www.powershellgallery.com/api/v2/Packages(Id='PSFzf',Version='$($pins.PSFzf)')"
if (-not $entry) { throw 'empty response' }
} catch { $problems += "PSFzf $($pins.PSFzf) not found on the PowerShell Gallery" }
try { $null = Invoke-RestMethod "https://registry.npmjs.org/http-server/$($pins.HttpServer)" } catch { $problems += "http-server $($pins.HttpServer) not found on npm" }

# Newer upstream releases are reported, not failed.
foreach ($repo in 'ryanoasis/nerd-fonts', 'JanDeDobbeleer/oh-my-posh') {
try {
$latest = (Invoke-RestMethod "https://api.github.com/repos/$repo/releases/latest" -Headers @{ 'User-Agent' = 'PowerShellPerfect-pins' }).tag_name
Write-Host "::notice::Latest $repo release: $latest"
} catch { Write-Host "::warning::Could not read the latest $repo release: $($_.Exception.Message)" }
}

if ($problems) { $problems | ForEach-Object { Write-Host "::error::$_" }; exit 1 }
Write-Host 'All pinned downloads match.'
1 change: 1 addition & 0 deletions .mailmap
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
26zl <143036376+26zl@users.noreply.github.com>
36 changes: 36 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
# Instructions for AI coding agents

PowerShellPerfect is a Windows PowerShell profile (`Microsoft.PowerShell_profile.ps1`), its installer (`setup.ps1`), a profile-only copier (`setprofile.ps1`), shipped config (`theme.json`, `terminal-config.json`) and test harnesses in `tests/`. Read [CONTRIBUTING.md](CONTRIBUTING.md) for the full conventions; the rules below are the ones that are easy to get wrong.

## Safety: this code changes real machines

- Never run `setup.ps1` (without `-WhatIf`), `Update-Profile`, `Uninstall-Profile`, `Clear-Cache` or `Clear-ProfileCache` against the real environment. Run them only inside a sandbox from `tests/lib/Sandbox.ps1` (`New-PspSandbox` + `Enter-PspSandbox`, then dot-source `$PspPostLoadStubs` after loading the profile).
- `Uninstall-Profile -All`/`-RemoveFonts` touches the machine font store unless `$script:PSP.TestFontScopes` points into the sandbox; `$PspPostLoadStubs` sets it.
- Do not create `telemetry.owned` in a sandbox before calling `Uninstall-Profile`: uninstall would then remove the real `POWERSHELL_TELEMETRY_OPTOUT` variable.
- Run tests from a non-elevated shell. Pass `-AllowElevated` only on CI runners or disposable VMs.
- `tests/rawhunt.ps1` is not sandboxed (public paste upload, clipboard, winget); do not run it.

## Checks to run

```powershell
pwsh -NoProfile -File tests/unit.ps1 # and: powershell -NoProfile -File tests/unit.ps1
pwsh -NoProfile -File tests/lint.ps1 # PSScriptAnalyzer 1.25.0 + PSScriptAnalyzerSettings.psd1
pwsh -NoProfile -File tests/smoke.ps1
pwsh -NoProfile -File tests/test.ps1 # full local gate
```

A bug fix needs a regression case in `tests/unit.ps1` that fails without the fix.

## Code rules

- Must run on Windows PowerShell 5.1 and PowerShell 7. `.ps1` files are ASCII only, no BOM, CRLF (`.gitattributes`).
- Read JSON with `Read-JsonFile`/`ConvertFrom-JsonC`, text with `Get-Utf8FileText`, write with `Write-Utf8FileAtomic`; use `-LiteralPath`.
- Helpers such as `Get-PspProp`, `Get-PspArray` and `Read-JsonFile` return lists with `return , $list`. Wrap such calls in parentheses before piping or counting (`(Get-PspArray $o 'k').Count`); `@(Get-PspArray ...)` nests the array.
- Windows Terminal `settings.json` is changed only through `Update-WindowsTerminalSettingsFile` / `Undo-WindowsTerminalSettingsFile`, which record each change so uninstall can revert it.
- `setup.ps1` loads the helpers named in `$script:SharedFunctionNames` from the verified profile; every profile helper that a shared function calls must be in that list (`tests/unit.ps1` checks it). The five pre-bundle helpers (`Write-PspHost`, `Invoke-DownloadWithRetry`, `Get-CombinedSha256`, `Resolve-PspCommitSha`, `Get-PspReleaseChecksums`) exist in both files and must stay identical; CI compares them.
- Destructive commands use `SupportsShouldProcess`; pin and hash anything downloaded at runtime; new third-party calls need a mock in `tests/ci-functional.ps1` and a row in `SECURITY.md`.
- Use `Write-PspHost` (honors `NO_COLOR`), keep state in `$script:PSP`, name load-time temporaries `$_psp*` (removed after load), and keep comments to one sentence about current behavior.

## Releases

Bump `Version` in `$script:PSP`, add a `CHANGELOG.md` section, then tag `vX.Y.Z`; `release.yml` publishes the files with `SHA256SUMS.txt` and an attestation. Do not create tags or releases unless asked.
Loading
Loading