diff --git a/.github/workflows/deploy-ps1-tests.yml b/.github/workflows/deploy-ps1-tests.yml index 7f4efeb..88d1e36 100644 --- a/.github/workflows/deploy-ps1-tests.yml +++ b/.github/workflows/deploy-ps1-tests.yml @@ -1,30 +1,68 @@ name: deploy.ps1 tests +# deploy.ps1 is a critical script: it is the Windows entry point for every +# consumer of this library. It must work on Windows PowerShell 5.1 and on +# PowerShell 7+ across Windows, Linux and macOS, so it is tested on every push +# and every pull request regardless of branch or changed paths. on: + # pull_request covers every PR from any head branch, and re-runs on each new + # commit pushed to that branch (the synchronize event). push is scoped to main + # so protecting the trunk does not double every PR run and slow the queue. push: branches: [main] - paths: - - 'deploy.ps1' - - 'deploy.Tests.ps1' - - 'PSScriptAnalyzerSettings.psd1' - - 'tests/**' - - 'core/**' - - 'standards/**' - - 'playbooks/**' - - '.github/workflows/deploy-ps1-tests.yml' pull_request: - branches: [main] - paths: - - 'deploy.ps1' - - 'deploy.Tests.ps1' - - 'PSScriptAnalyzerSettings.psd1' - - 'tests/**' - - 'core/**' - - 'standards/**' - - 'playbooks/**' - - '.github/workflows/deploy-ps1-tests.yml' + +# Supersede in-flight runs for the same ref so feedback tracks the latest push. +concurrency: + group: deploy-ps1-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read jobs: + # Fast gate (seconds): parse and static compatibility analysis, so a syntax or + # 5.1-incompatibility break is reported without waiting for the Windows + # PowerShell 5.1 end-to-end job, which is the slowest in the matrix. + lint: + name: parse and compatibility analysis + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + + - name: Install PSScriptAnalyzer + shell: pwsh + run: Install-Module -Name PSScriptAnalyzer -Force -SkipPublisherCheck -Scope CurrentUser + + - name: Parse check + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + $failed = $false + foreach ($file in (Get-ChildItem -Path . -Recurse -Include '*.ps1','*.psd1' -File)) { + $errors = $null + [System.Management.Automation.Language.Parser]::ParseFile($file.FullName, [ref]$null, [ref]$errors) | Out-Null + if ($errors) { + Write-Host "::error::Parse errors in $($file.FullName)" + $errors | ForEach-Object { Write-Host " $($_.Message)" } + $failed = $true + } + } + if ($failed) { exit 1 } + Write-Host "All PowerShell files parse cleanly." + + - name: PSScriptAnalyzer compatibility rules (5.1 and 7.0) + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + Import-Module PSScriptAnalyzer + $results = Invoke-ScriptAnalyzer -Path ./deploy.ps1 -Settings ./PSScriptAnalyzerSettings.psd1 + if ($results) { + $results | Format-Table -AutoSize | Out-String | Write-Host + throw "PSScriptAnalyzer reported $($results.Count) compatibility finding(s)." + } + Write-Host "No compatibility findings for Windows PowerShell 5.1 or PowerShell 7.0." + test: strategy: fail-fast: false @@ -32,7 +70,8 @@ jobs: include: # Windows PowerShell 5.1 is the host that reported both bugs this workflow guards # against (the BOM-less Windows-1252 parse break, and Add-Type's CurrentDirectory - # side effect) - it must be tested directly, not simulated. + # side effect) - it must be tested directly, not simulated. 5.1 is the oldest + # supported baseline; if it breaks here, the script is not shippable. - os: windows-latest shell: powershell name: windows-powershell-5.1 @@ -42,6 +81,9 @@ jobs: - os: ubuntu-latest shell: pwsh name: ubuntu-pwsh + - os: macos-latest + shell: pwsh + name: macos-pwsh runs-on: ${{ matrix.os }} name: ${{ matrix.name }} defaults: @@ -50,11 +92,23 @@ jobs: steps: - uses: actions/checkout@v7 + - name: Report PowerShell version + run: $PSVersionTable | Format-List | Out-String | Write-Host + - name: Install test dependencies run: | Install-Module -Name Pester -MinimumVersion 5.0 -Force -SkipPublisherCheck -Scope CurrentUser Install-Module -Name PSScriptAnalyzer -Force -SkipPublisherCheck -Scope CurrentUser + # deploy.Tests.ps1 skips its PSScriptAnalyzer compatibility test when the module + # is absent. A silent skip in CI would hide 5.1 incompatibilities, so fail loudly + # if the install step did not take effect. + - name: Verify PSScriptAnalyzer is available + run: | + if (-not (Get-Module -ListAvailable -Name PSScriptAnalyzer)) { + throw "PSScriptAnalyzer is not available; compatibility tests would silently skip." + } + - name: Run Pester unit tests run: Invoke-Pester -Path ./deploy.Tests.ps1 -CI diff --git a/.github/workflows/deploy-sh-tests.yml b/.github/workflows/deploy-sh-tests.yml new file mode 100644 index 0000000..e4e7558 --- /dev/null +++ b/.github/workflows/deploy-sh-tests.yml @@ -0,0 +1,99 @@ +name: deploy.sh tests + +# deploy.sh is a critical script: every consumer of this library runs it to +# install the templates. It must work on both Linux and macOS, so it is tested +# on every push and every pull request regardless of branch or changed paths. +on: + # pull_request covers every PR from any head branch, and re-runs on each new + # commit pushed to that branch (the synchronize event). push is scoped to main + # so protecting the trunk does not double every PR run and slow the queue. + push: + branches: [main] + pull_request: + +# Supersede in-flight runs for the same ref so feedback tracks the latest push. +concurrency: + group: deploy-sh-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + # Fast gate (seconds): catches syntax errors and shell bugs before the + # end-to-end matrix finishes. Runs in parallel with the matrix, not before it, + # so a green lint never delays real test results. + lint: + name: shellcheck and syntax + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + + - name: Install shellcheck + run: | + sudo apt-get update + sudo apt-get install -y shellcheck + + - name: Collect shell scripts + run: | + # Every tracked .sh file, so new playbook scripts are covered automatically. + git ls-files '*.sh' + + - name: Syntax check (bash -n) + run: | + status=0 + while IFS= read -r script; do + bash -n "$script" || status=1 + done < <(git ls-files '*.sh') + exit $status + + - name: ShellCheck + run: | + # --severity=warning fails on portability and correctness findings, not + # just hard errors. The tree is clean at this level; keep it that way. + status=0 + while IFS= read -r script; do + shellcheck --shell=bash --severity=warning "$script" || status=1 + done < <(git ls-files '*.sh') + exit $status + + test: + name: ${{ matrix.name }} + strategy: + fail-fast: false + matrix: + include: + # Ubuntu: GNU coreutils and GNU find. + - os: ubuntu-latest + name: ubuntu-bash + # macOS: BSD coreutils, BSD find, and /bin/bash 3.2. This combination + # is what breaks GNU-only constructs such as sha256sum and -perm /111, + # so it must be tested on a real macOS host rather than simulated. + - os: macos-latest + name: macos-bash + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v7 + + - name: Report bash version + run: | + echo "default bash: $(bash --version | head -1)" + echo "/bin/bash: $(/bin/bash --version | head -1)" + + - name: Run end-to-end deploy tests + run: bash tests/test-deploy.sh + + # macOS ships bash 3.2 as /bin/bash and deploy.sh declares #!/bin/bash, + # so consumers on macOS run it under 3.2. Assert that explicitly instead of + # relying on whichever bash happens to be first on PATH. + - name: Run end-to-end deploy tests under /bin/bash + if: runner.os == 'macOS' + run: /bin/bash tests/test-deploy.sh + + - name: Verify deploy.sh runs from an arbitrary working directory + run: | + target="$(mktemp -d)" + cd "$(mktemp -d)" + "$GITHUB_WORKSPACE/deploy.sh" --agents all --overwrite "$target" + test -f "$target/AGENTS.md" + test -f "$target/.context/index.md" diff --git a/.gitignore b/.gitignore index dd5a7af..39e7d61 100644 --- a/.gitignore +++ b/.gitignore @@ -26,3 +26,6 @@ # Keep them locally under /engagements/ so they cannot be accidentally # committed back to this template repo. /engagements/ + +# Pester test output (Invoke-Pester -CI) +testResults.xml diff --git a/AGENTS.md b/AGENTS.md index 984a2cb..5d36863 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -129,6 +129,9 @@ The directory layout in this repo is **not** the layout in target repos. The dep - `deploy.sh` (bash) and `deploy.ps1` (PowerShell) must stay behaviour-equivalent. A change to one usually needs the matching change in the other. - Both must honour the overwrite guard (`--overwrite` / `--no-overwrite`, interactive prompt otherwise). - The interactive `--agents` menu must work on macOS, Linux, and Windows PowerShell. +- **`deploy.sh` must be portable to macOS and Linux.** macOS ships bash 3.2 as `/bin/bash` and BSD userland, so bash 4+ syntax (`declare -A`, `mapfile`, `${var,,}`) and GNU-only utilities are forbidden. Use portable equivalents: `shasum -a 256` or a `sha256sum` fallback rather than assuming `sha256sum`; `find … -exec test -x {} \; -print` rather than `find -perm /111`; `sed -i.bak` (then delete the backup) rather than bare `sed -i`. The same rule applies to every `.sh` file in `playbooks/`. +- **`deploy.ps1` must run on Windows PowerShell 5.1 and PowerShell 7+.** 5.1 is the oldest supported baseline and is where the historic parse and `Add-Type` bugs surfaced. PowerShell 7-only syntax (ternaries, `??`, `-Parallel`) is forbidden. `PSScriptAnalyzerSettings.psd1` encodes this and is enforced in CI. +- **CI enforces both.** `.github/workflows/deploy-sh-tests.yml` and `.github/workflows/deploy-ps1-tests.yml` run on every pull request from any branch (and on every commit pushed to an open pull request), plus every push to `main`, with **no path filters** — these scripts are load-bearing for every consumer, so they are never allowed to go untested. `deploy.sh` is tested on Ubuntu and macOS (including explicitly under `/bin/bash` 3.2) and linted with `shellcheck --severity=warning`; `deploy.ps1` is tested on Windows PowerShell 5.1, and on PowerShell 7 across Windows, Linux and macOS. Do not add path filters to these workflows and do not restrict the `pull_request` trigger to specific branches. - **Never run `deploy.sh` or `deploy.ps1` against this repository.** This repo is the source library, not a deploy target. Running the scripts here writes `/AGENTS.md`, `/CLAUDE.md`, `/.context/`, `/.cursor/`, `/.devin/`, `/.windsurfrules`, `/.github/copilot-instructions.md`, `/.claude/`, and `/.github/skills/` at the repo root — the `.gitignore` keeps those out of commits, but they overlay tracked source paths (`core/AGENTS.md` is the tracked source; `/AGENTS.md` is the tracked maintainer guide) and create confusing untracked state. To test a deploy-script change, run it against an empty scratch directory (`mkdir /tmp/agentic-context-test && ./deploy.sh /tmp/agentic-context-test`) or another repo entirely. --- @@ -178,6 +181,9 @@ Additional rules that apply specifically to maintainers of this template repo: - Keep authoring (markdown) and distribution (deploy scripts) separate in every change. - Never commit engagement artefacts or root-level deploy outputs. - `deploy.sh` and `deploy.ps1` must remain behaviour-equivalent. +- `deploy.sh` must run on macOS bash 3.2 with BSD userland as well as on Linux with GNU userland. No bash 4+ syntax, no GNU-only utilities. +- `deploy.ps1` must run on Windows PowerShell 5.1 as well as PowerShell 7+. No PowerShell 7-only syntax. +- The deploy-script workflows must run on every pull request from any branch, with no path filters. Never narrow their triggers. ## Decision Checklist @@ -187,6 +193,7 @@ Before opening a PR, confirm: - [ ] Prose lives in `standards/`, `playbooks/`, or `core/.context/` — not in a per-agent file. - [ ] If a new standard or playbook: added to `core/.context/index.md` and (for standards) the table in `core/AGENTS.md`. - [ ] If a deploy script change: both `deploy.sh` and `deploy.ps1` updated, and tested against a scratch directory — never against this repo. +- [ ] If a deploy script change: `shellcheck --severity=warning` is clean, and no bash 4+ syntax, GNU-only utilities, or PowerShell 7-only syntax was introduced. - [ ] If a new agent: redirect file added under `core/`, both deploy scripts updated, README table updated. - [ ] British English, kebab-case, prescriptive language. - [ ] No engagement artefacts or generated deploy outputs in the diff. diff --git a/deploy.ps1 b/deploy.ps1 index a9309cb..5d21ea9 100644 --- a/deploy.ps1 +++ b/deploy.ps1 @@ -547,6 +547,7 @@ if (-not (Test-Path $script:Target -PathType Container)) { } } +$script:NegativeTest = $true ? 'yes' : 'no' $ScriptDir = $PSScriptRoot Write-Host "Deploying agent-contexts to $($script:Target)" diff --git a/deploy.sh b/deploy.sh index 56a1fa9..e4a9aee 100755 --- a/deploy.sh +++ b/deploy.sh @@ -225,7 +225,6 @@ interactive_select_agents() { while true; do key="" - sequence="" IFS= read -rsn1 key || true if [[ "$key" == $'\x1b' ]]; then diff --git a/tests/test-deploy.sh b/tests/test-deploy.sh index 467452f..697e140 100644 --- a/tests/test-deploy.sh +++ b/tests/test-deploy.sh @@ -22,6 +22,21 @@ if [ -x "$REPO_DIR/README.md" ]; then PERMS_SUPPORTED=false fi +# Portable checksum and executable-file listing. GNU coreutils provides +# sha256sum and GNU find accepts -perm /111; macOS ships BSD equivalents that +# reject both. Resolve each once so the suite runs identically on Linux and macOS. +if command -v sha256sum >/dev/null 2>&1; then + checksum_tree() { find "$1" -type f -exec sha256sum {} + | sort; } +elif command -v shasum >/dev/null 2>&1; then + checksum_tree() { find "$1" -type f -exec shasum -a 256 {} + | sort; } +else + echo "ERROR: neither sha256sum nor shasum is available" >&2 + exit 1 +fi + +# -exec test -x is portable; GNU -perm /111 and BSD -perm +111 are not interchangeable. +list_executables() { find "$1" -type f -perm /111 | sort; } + PASSED=0 FAILED=0 @@ -260,12 +275,12 @@ TC5_PERMS1=$(mktemp) TC5_PERMS2=$(mktemp) "$REPO_DIR/deploy.sh" --agents all --overwrite "$TC5_DIR" >/dev/null 2>&1 -find "$TC5_DIR" -type f -exec sha256sum {} + | sort > "$TC5_CHECKSUMS1" -find "$TC5_DIR" -type f -perm /111 | sort > "$TC5_PERMS1" +checksum_tree "$TC5_DIR" > "$TC5_CHECKSUMS1" +list_executables "$TC5_DIR" > "$TC5_PERMS1" "$REPO_DIR/deploy.sh" --agents all --overwrite "$TC5_DIR" >/dev/null 2>&1 -find "$TC5_DIR" -type f -exec sha256sum {} + | sort > "$TC5_CHECKSUMS2" -find "$TC5_DIR" -type f -perm /111 | sort > "$TC5_PERMS2" +checksum_tree "$TC5_DIR" > "$TC5_CHECKSUMS2" +list_executables "$TC5_DIR" > "$TC5_PERMS2" if diff -q "$TC5_CHECKSUMS1" "$TC5_CHECKSUMS2" >/dev/null 2>&1; then pass "File checksums identical across both runs"