From a1f1f81ea1ee9e8bfb842b2eb26fbdfb4c7d655f Mon Sep 17 00:00:00 2001 From: ldastey-dev Date: Sun, 6 Sep 2026 14:04:21 +0100 Subject: [PATCH 01/20] refactor(scripts): move deploy scripts and tests into scripts/ Relocates deploy.sh, deploy.ps1, deploy.Tests.ps1 and tests/ into a scripts/ directory to keep the repository root clean, and adds VERSION as the canonical version marker. Content paths are resolved via a new SOURCE_ROOT/SourceRoot pointing at the repository root, since the scripts now live one level down. Test harnesses gain a separate SCRIPTS_DIR/ScriptsDir so REPO_DIR keeps referring to the repository root for content lookups. BREAKING CHANGE: deploy.sh and deploy.ps1 must now be invoked as ./scripts/deploy.sh and ./scripts/deploy.ps1. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/deploy-ps1-tests.yml | 6 +-- .github/workflows/deploy-sh-tests.yml | 6 +-- AGENTS.md | 38 ++++++++--------- README.md | 31 ++++++++++---- VERSION | 1 + deploy.Tests.ps1 => scripts/deploy.Tests.ps1 | 4 +- deploy.ps1 => scripts/deploy.ps1 | 25 +++++------ deploy.sh => scripts/deploy.sh | 42 ++++++++++--------- .../fixtures/assess-observability-skill.md | 0 {tests => scripts/tests}/test-deploy.ps1 | 13 +++--- {tests => scripts/tests}/test-deploy.sh | 17 ++++---- 11 files changed, 101 insertions(+), 82 deletions(-) create mode 100644 VERSION rename deploy.Tests.ps1 => scripts/deploy.Tests.ps1 (98%) rename deploy.ps1 => scripts/deploy.ps1 (93%) rename deploy.sh => scripts/deploy.sh (91%) rename {tests => scripts/tests}/fixtures/assess-observability-skill.md (100%) rename {tests => scripts/tests}/test-deploy.ps1 (96%) rename {tests => scripts/tests}/test-deploy.sh (96%) diff --git a/.github/workflows/deploy-ps1-tests.yml b/.github/workflows/deploy-ps1-tests.yml index 88d1e36..af4d573 100644 --- a/.github/workflows/deploy-ps1-tests.yml +++ b/.github/workflows/deploy-ps1-tests.yml @@ -56,7 +56,7 @@ jobs: run: | $ErrorActionPreference = 'Stop' Import-Module PSScriptAnalyzer - $results = Invoke-ScriptAnalyzer -Path ./deploy.ps1 -Settings ./PSScriptAnalyzerSettings.psd1 + $results = Invoke-ScriptAnalyzer -Path ./scripts/deploy.ps1 -Settings ./PSScriptAnalyzerSettings.psd1 if ($results) { $results | Format-Table -AutoSize | Out-String | Write-Host throw "PSScriptAnalyzer reported $($results.Count) compatibility finding(s)." @@ -110,7 +110,7 @@ jobs: } - name: Run Pester unit tests - run: Invoke-Pester -Path ./deploy.Tests.ps1 -CI + run: Invoke-Pester -Path ./scripts/deploy.Tests.ps1 -CI - name: Run end-to-end deploy tests - run: pwsh ./tests/test-deploy.ps1 + run: pwsh ./scripts/tests/test-deploy.ps1 diff --git a/.github/workflows/deploy-sh-tests.yml b/.github/workflows/deploy-sh-tests.yml index e4e7558..da2eb8c 100644 --- a/.github/workflows/deploy-sh-tests.yml +++ b/.github/workflows/deploy-sh-tests.yml @@ -81,19 +81,19 @@ jobs: echo "/bin/bash: $(/bin/bash --version | head -1)" - name: Run end-to-end deploy tests - run: bash tests/test-deploy.sh + run: bash scripts/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 + run: /bin/bash scripts/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" + "$GITHUB_WORKSPACE/scripts/deploy.sh" --agents all --overwrite "$target" test -f "$target/AGENTS.md" test -f "$target/.context/index.md" diff --git a/AGENTS.md b/AGENTS.md index 5d36863..0ed9ef6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,6 +1,6 @@ # AGENTS.md -Maintainer-facing guide for working in **this** repository — the source of standards, playbooks, and per-agent configuration templates that `deploy.sh` and `deploy.ps1` distribute into target repositories. +Maintainer-facing guide for working in **this** repository — the source of standards, playbooks, and per-agent configuration templates that `scripts/deploy.sh` and `scripts/deploy.ps1` distribute into target repositories. This file is for contributors editing the templates here. It is **not** the `AGENTS.md` that gets shipped to consumers — that template lives at `core/AGENTS.md` and is copied into each target repo by the deploy scripts (the consumer fills in the `[CONFIGURE]` sections; the scripts do not template anything themselves). @@ -13,9 +13,9 @@ A single-source-of-truth library of: - **Standards** (`standards/`) — per-concern prescriptive rules (security, testing, performance, .NET, React, etc.). - **Playbooks** (`playbooks/`) — step-by-step procedures for assessments, reviews, planning, and refactoring. - **Core configuration** (`core/`) — the lean, always-in-context files (`AGENTS.md`, `CLAUDE.md`, `.context/index.md`, conventions, per-agent redirects) that every target repo receives. -- **Deploy scripts** (`deploy.sh`, `deploy.ps1`) — the writers that assemble the above into a target repository's layout for the selected agents. +- **Deploy scripts** (`scripts/deploy.sh`, `scripts/deploy.ps1`) — the writers that assemble the above into a target repository's layout for the selected agents. -Consumers of this library run `deploy.sh` (or `deploy.ps1`) against their own repository. They never edit content here. +Consumers of this library run `scripts/deploy.sh` (or `scripts/deploy.ps1`) against their own repository. They never edit content here. --- @@ -38,7 +38,7 @@ If you need to reference the same rule from two playbooks, link to the standard | Concern | Where | What lives here | | --- | --- | --- | | **Authoring** | `standards/`, `playbooks/`, `core/` | The prose. Edit these. | -| **Distribution** | `deploy.sh`, `deploy.ps1` | The writers. They translate this repo's structure into the target's layout, selecting per-agent files based on `--agents`. | +| **Distribution** | `scripts/deploy.sh`, `scripts/deploy.ps1` | The writers. They translate this repo's structure into the target's layout, selecting per-agent files based on `--agents`. | Authoring changes touch markdown only. Distribution changes touch the scripts only. Do not mix. @@ -47,7 +47,7 @@ Authoring changes touch markdown only. Distribution changes touch the scripts on The point of supporting multiple agents (Claude Code, Cursor, Windsurf, Devin, Copilot) is **not** to write multiple copies of our standards. It is to write a small adapter file that each agent reads and then sends the agent to `AGENTS.md` + `.context/index.md`. - `core/.cursor/rules/standards.mdc`, `core/.windsurfrules`, `core/.devin/devin.json`, `core/.github/copilot-instructions.md`, `core/CLAUDE.md` — all are redirects. None contains a standard. -- For Claude Code and Copilot, `deploy.sh` additionally generates skill wrappers from playbook frontmatter — wrappers, not copies. +- For Claude Code and Copilot, `scripts/deploy.sh` additionally generates skill wrappers from playbook frontmatter — wrappers, not copies. If you find yourself writing prose in a per-agent file, stop and put it in `core/AGENTS.md` or a standard instead. @@ -107,13 +107,13 @@ The directory layout in this repo is **not** the layout in target repos. The dep 3. Use semantic, non-numbered headings (`## Role`, `## Phase 1: Discovery`, ...). 4. Add a keyword route in `core/.context/index.md`. -5. Do not generate skill wrappers by hand. `deploy.sh` does that from the frontmatter. +5. Do not generate skill wrappers by hand. `scripts/deploy.sh` does that from the frontmatter. ### Adding support for a new agent 1. Add the agent's redirect file under `core/` (e.g. `core/.newagent/config.yaml`). 2. The redirect must point the agent to `AGENTS.md` and `.context/index.md` — never duplicate standards. -3. Extend `deploy.sh` and `deploy.ps1`: agent flag parsing, interactive menu entry, copy step. +3. Extend `scripts/deploy.sh` and `scripts/deploy.ps1`: agent flag parsing, interactive menu entry, copy step. 4. Update the README's "Supported Agents" table. ### Editing standards or playbooks @@ -126,13 +126,13 @@ The directory layout in this repo is **not** the layout in target repos. The dep ### Editing deploy scripts -- `deploy.sh` (bash) and `deploy.ps1` (PowerShell) must stay behaviour-equivalent. A change to one usually needs the matching change in the other. +- `scripts/deploy.sh` (bash) and `scripts/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. +- **`scripts/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/`. +- **`scripts/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. `scripts/deploy.sh` is tested on Ubuntu and macOS (including explicitly under `/bin/bash` 3.2) and linted with `shellcheck --severity=warning`; `scripts/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 `scripts/deploy.sh` or `scripts/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 && ./scripts/deploy.sh /tmp/agentic-context-test`) or another repo entirely. --- @@ -146,7 +146,7 @@ The directory layout in this repo is **not** the layout in target repos. The dep | The lean per-project config every target repo gets | `core/AGENTS.md` | | A pointer for a specific agent to find AGENTS.md | `core/` | | A keyword route to discover a standard or playbook | `core/.context/index.md` | -| Anything about how files are written to target repos | `deploy.sh` and `deploy.ps1` | +| Anything about how files are written to target repos | `scripts/deploy.sh` and `scripts/deploy.ps1` | If a change does not fit any row above, it probably does not belong in this repo. @@ -155,7 +155,7 @@ If a change does not fit any row above, it probably does not belong in this repo ## What Does Not Belong Here - **Engagement artefacts.** Assessment reports, pen-test write-ups, and review outputs produced *by* using these playbooks against a target codebase. Keep them locally under `/engagements/` (git-ignored), alongside any `reviews/` directories (also ignored at any depth). -- **Deploy outputs.** Anything written to the repo root by `deploy.sh` (e.g. a generated `/AGENTS.md` template copy, `/.context/`, `/.cursor/`, etc.) when this repo is used as its own deploy target for local testing. The `.gitignore` anchors these with leading slashes so they cannot be accidentally committed; `core/` is the tracked source and is never affected. +- **Deploy outputs.** Anything written to the repo root by `scripts/deploy.sh` (e.g. a generated `/AGENTS.md` template copy, `/.context/`, `/.cursor/`, etc.) when this repo is used as its own deploy target for local testing. The `.gitignore` anchors these with leading slashes so they cannot be accidentally committed; `core/` is the tracked source and is never affected. - **Per-agent duplicates of standards.** See Core Design Principle 3. - **Local editor or agent settings** beyond what every contributor needs. `/.claude/`, `/.cursor/`, `/.devin/` at the repo root, and `.vscode/` at any depth, are ignored. @@ -174,15 +174,15 @@ Additional rules that apply specifically to maintainers of this template repo: ## Non-Negotiables -- **Never run `deploy.sh` or `deploy.ps1` against this repository.** Test deploy-script changes in a scratch directory or against another repo. See [Editing deploy scripts](#editing-deploy-scripts) for the reasoning. +- **Never run `scripts/deploy.sh` or `scripts/deploy.ps1` against this repository.** Test deploy-script changes in a scratch directory or against another repo. See [Editing deploy scripts](#editing-deploy-scripts) for the reasoning. - Never duplicate a standard across files. One canonical home per rule. - Never write a standard inside a per-agent redirect file. - Never paste playbook text into a skill wrapper — regenerate from frontmatter. - 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. +- `scripts/deploy.sh` and `scripts/deploy.ps1` must remain behaviour-equivalent. +- `scripts/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. +- `scripts/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 @@ -192,7 +192,7 @@ Before opening a PR, confirm: - [ ] No existing file already covers this content (would be duplication). - [ ] 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: both `scripts/deploy.sh` and `scripts/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. diff --git a/README.md b/README.md index ee2b0fa..955d48a 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ The `.context/index.md` file is a keyword-to-file routing table. Every agent — This is the cross-agent mechanism: any LLM-based agent can read a markdown table and match keywords. No proprietary skill system required. -For **Claude Code** and **GitHub Copilot** specifically, `deploy.sh` generates thin skill wrappers (`.claude/skills/` and `.github/skills/`) for the selected agents. The playbook is the single source of truth; the skill wrapper is a disposable adapter. +For **Claude Code** and **GitHub Copilot** specifically, `scripts/deploy.sh` generates thin skill wrappers (`.claude/skills/` and `.github/skills/`) for the selected agents. The playbook is the single source of truth; the skill wrapper is a disposable adapter. ### Example @@ -46,23 +46,23 @@ User says: "refactor the authentication module" ## Quick Start ```bash -./deploy.sh --agents all /path/to/target-repo +./scripts/deploy.sh --agents all /path/to/target-repo ``` Examples: ```bash # Claude only -./deploy.sh --agents claude /path/to/target-repo +./scripts/deploy.sh --agents claude /path/to/target-repo # Multiple agents -./deploy.sh --agents claude copilot cursor /path/to/target-repo +./scripts/deploy.sh --agents claude copilot cursor /path/to/target-repo # Deploy to current directory -./deploy.sh --agents all +./scripts/deploy.sh --agents all # Omit --agents to use the interactive arrow-key selector -./deploy.sh /path/to/target-repo +./scripts/deploy.sh /path/to/target-repo ``` Then fill in all `[CONFIGURE]` sections in `AGENTS.md` and any selected agent-specific files (for example `CLAUDE.md` when Claude is selected). @@ -84,7 +84,7 @@ core/ Tier 1 — always in context (→ target .devin/devin.json Devin config + index pointer .github/copilot-instructions.md Copilot redirect → AGENTS.md .claude/settings.json Claude Code permissions + hooks template - (skill wrappers are generated by deploy.sh + (skill wrappers are generated by scripts/deploy.sh for selected Claude/Copilot agents) standards/ Tier 3 — reference (→ target .context/standards/) @@ -139,6 +139,19 @@ playbooks/ Tier 2 — on demand (→ target .contex discover-local-otel-stack.md use-local-otel-stack.md instrument-dotnet-otel.md + +scripts/ Distribution tooling (not deployed to targets) + deploy.sh / deploy.ps1 First-time install into a target repository + update.sh / update.ps1 Staleness check and in-place update + migrate.sh / migrate.ps1 One-off upgrade for pre-2.0 deployments + lib/ Shared helpers (SemVer, manifest, hashing) + baselines/.sha256 Per-release hashes of deployable files + deploy.Tests.ps1 Pester unit tests + tests/ End-to-end test suites (bash and PowerShell) + +VERSION Canonical SemVer — written by CI, never by hand +CHANGELOG.md Generated from commit history on release +MIGRATIONS.md What consumers must do on each MAJOR bump ``` > Scripts in `playbooks/setup/create-local-otel-stack/` are deployed to @@ -163,7 +176,7 @@ keywords: [assess security, security audit, threat model] ... ``` -The `keywords` field feeds the context index. The `description` field is used by `deploy.sh` to generate Claude Code and GitHub Copilot skill wrappers. The content is plain markdown that any agent can read and follow. +The `keywords` field feeds the context index. The `description` field is used by `scripts/deploy.sh` to generate Claude Code and GitHub Copilot skill wrappers. The content is plain markdown that any agent can read and follow. ## Conventions @@ -186,7 +199,7 @@ Standards are maintained in **one place only**: | Per-concern detail | `standards/{concern}.md` | | On-demand context routing | `core/.context/index.md` | | Playbook procedures | `playbooks/{category}/{concern}.md` | -| Skill wrappers (Claude + Copilot) | Generated by `deploy.sh` — do not edit directly | +| Skill wrappers (Claude + Copilot) | Generated by `scripts/deploy.sh` — do not edit directly | ## Editing Guidelines diff --git a/VERSION b/VERSION new file mode 100644 index 0000000..3eefcb9 --- /dev/null +++ b/VERSION @@ -0,0 +1 @@ +1.0.0 diff --git a/deploy.Tests.ps1 b/scripts/deploy.Tests.ps1 similarity index 98% rename from deploy.Tests.ps1 rename to scripts/deploy.Tests.ps1 index 3a968f6..65d3d90 100644 --- a/deploy.Tests.ps1 +++ b/scripts/deploy.Tests.ps1 @@ -32,10 +32,10 @@ Describe 'deploy.ps1 (PowerShell version/platform compatibility)' { # isn't installed locally; run `Install-Module PSScriptAnalyzer -Scope CurrentUser` to enable # it. This is a static check - it does not catch semantic/runtime issues like Add-Type # resetting [Environment]::CurrentDirectory, which is why the compatibility test above and - # TC5 in tests/test-deploy.ps1 exist separately. + # TC5 in scripts/tests/test-deploy.ps1 exist separately. It 'has no PSScriptAnalyzer compatibility findings for Windows PowerShell 5.1 / PowerShell 7.0' -Skip:(-not $script:PSScriptAnalyzerAvailable) { Import-Module PSScriptAnalyzer - $results = Invoke-ScriptAnalyzer -Path (Join-Path $PSScriptRoot 'deploy.ps1') -Settings (Join-Path $PSScriptRoot 'PSScriptAnalyzerSettings.psd1') + $results = Invoke-ScriptAnalyzer -Path (Join-Path $PSScriptRoot 'deploy.ps1') -Settings (Join-Path (Split-Path -Parent $PSScriptRoot) 'PSScriptAnalyzerSettings.psd1') $results | Should -BeNullOrEmpty } } diff --git a/deploy.ps1 b/scripts/deploy.ps1 similarity index 93% rename from deploy.ps1 rename to scripts/deploy.ps1 index a9309cb..396f1c8 100644 --- a/deploy.ps1 +++ b/scripts/deploy.ps1 @@ -547,46 +547,47 @@ if (-not (Test-Path $script:Target -PathType Container)) { } } -$ScriptDir = $PSScriptRoot +# Content lives one level up: this script sits in scripts/, sources are at the repo root. +$SourceRoot = Split-Path -Parent $PSScriptRoot Write-Host "Deploying agent-contexts to $($script:Target)" Write-Host " Selected agents: $($script:EnabledAgents -join ', ')" Write-Host " Copying shared context files..." -Copy-SingleFile -Source (Join-Path $ScriptDir 'core/AGENTS.md') -Destination (Join-Path $script:Target 'AGENTS.md') -Copy-DirectoryContents -Source (Join-Path $ScriptDir 'core/.context') -Destination (Join-Path $script:Target '.context') +Copy-SingleFile -Source (Join-Path $SourceRoot 'core/AGENTS.md') -Destination (Join-Path $script:Target 'AGENTS.md') +Copy-DirectoryContents -Source (Join-Path $SourceRoot 'core/.context') -Destination (Join-Path $script:Target '.context') if (Test-AgentEnabled 'claude') { Write-Host " Copying Claude Code files..." - Copy-SingleFile -Source (Join-Path $ScriptDir 'core/CLAUDE.md') -Destination (Join-Path $script:Target 'CLAUDE.md') - Copy-SingleFile -Source (Join-Path $ScriptDir 'core/.claude/settings.json') -Destination (Join-Path $script:Target '.claude/settings.json') + Copy-SingleFile -Source (Join-Path $SourceRoot 'core/CLAUDE.md') -Destination (Join-Path $script:Target 'CLAUDE.md') + Copy-SingleFile -Source (Join-Path $SourceRoot 'core/.claude/settings.json') -Destination (Join-Path $script:Target '.claude/settings.json') } if (Test-AgentEnabled 'copilot') { Write-Host " Copying GitHub Copilot files..." - Copy-SingleFile -Source (Join-Path $ScriptDir 'core/.github/copilot-instructions.md') -Destination (Join-Path $script:Target '.github/copilot-instructions.md') + Copy-SingleFile -Source (Join-Path $SourceRoot 'core/.github/copilot-instructions.md') -Destination (Join-Path $script:Target '.github/copilot-instructions.md') } if (Test-AgentEnabled 'cursor') { Write-Host " Copying Cursor files..." - Copy-SingleFile -Source (Join-Path $ScriptDir 'core/.cursor/rules/standards.mdc') -Destination (Join-Path $script:Target '.cursor/rules/standards.mdc') + Copy-SingleFile -Source (Join-Path $SourceRoot 'core/.cursor/rules/standards.mdc') -Destination (Join-Path $script:Target '.cursor/rules/standards.mdc') } if (Test-AgentEnabled 'devin') { Write-Host " Copying Devin files..." - Copy-SingleFile -Source (Join-Path $ScriptDir 'core/.devin/devin.json') -Destination (Join-Path $script:Target '.devin/devin.json') + Copy-SingleFile -Source (Join-Path $SourceRoot 'core/.devin/devin.json') -Destination (Join-Path $script:Target '.devin/devin.json') } if (Test-AgentEnabled 'windsurf') { Write-Host " Copying Windsurf files..." - Copy-SingleFile -Source (Join-Path $ScriptDir 'core/.windsurfrules') -Destination (Join-Path $script:Target '.windsurfrules') + Copy-SingleFile -Source (Join-Path $SourceRoot 'core/.windsurfrules') -Destination (Join-Path $script:Target '.windsurfrules') } Write-Host " Copying standards\ -> $($script:Target)\.context\standards\" -Copy-DirectoryContents -Source (Join-Path $ScriptDir 'standards') -Destination (Join-Path $script:Target '.context/standards') +Copy-DirectoryContents -Source (Join-Path $SourceRoot 'standards') -Destination (Join-Path $script:Target '.context/standards') Write-Host " Copying playbooks\ -> $($script:Target)\.context\playbooks\" -Copy-DirectoryContents -Source (Join-Path $ScriptDir 'playbooks') -Destination (Join-Path $script:Target '.context/playbooks') +Copy-DirectoryContents -Source (Join-Path $SourceRoot 'playbooks') -Destination (Join-Path $script:Target '.context/playbooks') if ((Test-AgentEnabled 'claude') -or (Test-AgentEnabled 'copilot')) { Write-Host " Generating skill wrappers from playbooks..." @@ -610,7 +611,7 @@ if ((Test-AgentEnabled 'claude') -or (Test-AgentEnabled 'copilot')) { ) foreach ($category in $playbookCategories) { - $dir = Join-Path $ScriptDir "playbooks/$($category.Dir)" + $dir = Join-Path $SourceRoot "playbooks/$($category.Dir)" if (Test-Path $dir) { $playbooks = Get-ChildItem -Path $dir -Filter '*.md' -File -ErrorAction SilentlyContinue foreach ($playbook in $playbooks) { diff --git a/deploy.sh b/scripts/deploy.sh similarity index 91% rename from deploy.sh rename to scripts/deploy.sh index e4a9aee..51641a1 100755 --- a/deploy.sh +++ b/scripts/deploy.sh @@ -533,45 +533,47 @@ if [[ ! -d "$TARGET" ]]; then fi SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# Content lives one level up: this script sits in scripts/, sources are at the repo root. +SOURCE_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" echo "Deploying agent-contexts to $TARGET" echo " Selected agents: $(join_by ', ' "${ENABLED_AGENTS[@]}")" echo " Copying shared context files..." -copy_file "$SCRIPT_DIR/core/AGENTS.md" "$TARGET/AGENTS.md" -copy_dir_contents "$SCRIPT_DIR/core/.context" "$TARGET/.context" +copy_file "$SOURCE_ROOT/core/AGENTS.md" "$TARGET/AGENTS.md" +copy_dir_contents "$SOURCE_ROOT/core/.context" "$TARGET/.context" if agent_enabled claude; then echo " Copying Claude Code files..." - copy_file "$SCRIPT_DIR/core/CLAUDE.md" "$TARGET/CLAUDE.md" - copy_file "$SCRIPT_DIR/core/.claude/settings.json" "$TARGET/.claude/settings.json" + copy_file "$SOURCE_ROOT/core/CLAUDE.md" "$TARGET/CLAUDE.md" + copy_file "$SOURCE_ROOT/core/.claude/settings.json" "$TARGET/.claude/settings.json" fi if agent_enabled copilot; then echo " Copying GitHub Copilot files..." - copy_file "$SCRIPT_DIR/core/.github/copilot-instructions.md" "$TARGET/.github/copilot-instructions.md" + copy_file "$SOURCE_ROOT/core/.github/copilot-instructions.md" "$TARGET/.github/copilot-instructions.md" fi if agent_enabled cursor; then echo " Copying Cursor files..." - copy_file "$SCRIPT_DIR/core/.cursor/rules/standards.mdc" "$TARGET/.cursor/rules/standards.mdc" + copy_file "$SOURCE_ROOT/core/.cursor/rules/standards.mdc" "$TARGET/.cursor/rules/standards.mdc" fi if agent_enabled devin; then echo " Copying Devin files..." - copy_file "$SCRIPT_DIR/core/.devin/devin.json" "$TARGET/.devin/devin.json" + copy_file "$SOURCE_ROOT/core/.devin/devin.json" "$TARGET/.devin/devin.json" fi if agent_enabled windsurf; then echo " Copying Windsurf files..." - copy_file "$SCRIPT_DIR/core/.windsurfrules" "$TARGET/.windsurfrules" + copy_file "$SOURCE_ROOT/core/.windsurfrules" "$TARGET/.windsurfrules" fi echo " Copying standards/ → $TARGET/.context/standards/" -copy_dir_contents "$SCRIPT_DIR/standards" "$TARGET/.context/standards" +copy_dir_contents "$SOURCE_ROOT/standards" "$TARGET/.context/standards" echo " Copying playbooks/ → $TARGET/.context/playbooks/" -copy_dir_contents "$SCRIPT_DIR/playbooks" "$TARGET/.context/playbooks" +copy_dir_contents "$SOURCE_ROOT/playbooks" "$TARGET/.context/playbooks" if agent_enabled claude || agent_enabled copilot; then echo " Generating skill wrappers from playbooks..." @@ -584,29 +586,29 @@ if agent_enabled claude || agent_enabled copilot; then mkdir -p "$TARGET/.github/skills" fi - for playbook in "$SCRIPT_DIR"/playbooks/assess/*.md; do + for playbook in "$SOURCE_ROOT"/playbooks/assess/*.md; do filename=$(basename "$playbook") generate_skills_for_selected_agents "$playbook" "assess/$filename" done - for playbook in "$SCRIPT_DIR"/playbooks/review/*.md; do + for playbook in "$SOURCE_ROOT"/playbooks/review/*.md; do filename=$(basename "$playbook") # Review playbooks get read-only tools for Claude Code generate_skills_for_selected_agents "$playbook" "review/$filename" "Read, Grep, Glob, Bash(git *)" done - for playbook in "$SCRIPT_DIR"/playbooks/plan/*.md; do + for playbook in "$SOURCE_ROOT"/playbooks/plan/*.md; do filename=$(basename "$playbook") generate_skills_for_selected_agents "$playbook" "plan/$filename" done - for playbook in "$SCRIPT_DIR"/playbooks/refactor/*.md; do + for playbook in "$SOURCE_ROOT"/playbooks/refactor/*.md; do filename=$(basename "$playbook") generate_skills_for_selected_agents "$playbook" "refactor/$filename" done - if [[ -d "$SCRIPT_DIR/playbooks/debug" ]]; then - for playbook in "$SCRIPT_DIR"/playbooks/debug/*.md; do + if [[ -d "$SOURCE_ROOT/playbooks/debug" ]]; then + for playbook in "$SOURCE_ROOT"/playbooks/debug/*.md; do [[ -f "$playbook" ]] || continue filename=$(basename "$playbook") generate_skills_for_selected_agents "$playbook" "debug/$filename" \ @@ -614,16 +616,16 @@ if agent_enabled claude || agent_enabled copilot; then done fi - if [[ -d "$SCRIPT_DIR/playbooks/docs" ]]; then - for playbook in "$SCRIPT_DIR"/playbooks/docs/*.md; do + if [[ -d "$SOURCE_ROOT/playbooks/docs" ]]; then + for playbook in "$SOURCE_ROOT"/playbooks/docs/*.md; do [[ -f "$playbook" ]] || continue filename=$(basename "$playbook") generate_skills_for_selected_agents "$playbook" "docs/$filename" done fi - if [[ -d "$SCRIPT_DIR/playbooks/setup" ]]; then - for playbook in "$SCRIPT_DIR"/playbooks/setup/*.md; do + if [[ -d "$SOURCE_ROOT/playbooks/setup" ]]; then + for playbook in "$SOURCE_ROOT"/playbooks/setup/*.md; do [[ -f "$playbook" ]] || continue filename=$(basename "$playbook") generate_skills_for_selected_agents "$playbook" "setup/$filename" \ diff --git a/tests/fixtures/assess-observability-skill.md b/scripts/tests/fixtures/assess-observability-skill.md similarity index 100% rename from tests/fixtures/assess-observability-skill.md rename to scripts/tests/fixtures/assess-observability-skill.md diff --git a/tests/test-deploy.ps1 b/scripts/tests/test-deploy.ps1 similarity index 96% rename from tests/test-deploy.ps1 rename to scripts/tests/test-deploy.ps1 index f325c50..58c258a 100644 --- a/tests/test-deploy.ps1 +++ b/scripts/tests/test-deploy.ps1 @@ -11,7 +11,8 @@ $ErrorActionPreference = "Stop" $ScriptDir = Split-Path -Parent $PSCommandPath -$RepoDir = Split-Path -Parent $ScriptDir +$RepoDir = Split-Path -Parent (Split-Path -Parent $ScriptDir) +$ScriptsDir = Split-Path -Parent $ScriptDir $script:Passed = 0 $script:Failed = 0 @@ -109,7 +110,7 @@ Write-Host "" Write-Host "=== TC1: Fresh deploy — all agents ===" $tc1Dir = Join-Path ([System.IO.Path]::GetTempPath()) "tc1-$([guid]::NewGuid().ToString('N').Substring(0,8))" New-Item -ItemType Directory -Path $tc1Dir -Force | Out-Null -& "$RepoDir/deploy.ps1" -Agents all -Overwrite -Target $tc1Dir *>$null +& "$ScriptsDir/deploy.ps1" -Agents all -Overwrite -Target $tc1Dir *>$null Write-Host " --- Playbook files ---" Assert-FileExists "create-local-otel-stack.md" "$tc1Dir/.context/playbooks/setup/create-local-otel-stack.md" @@ -179,7 +180,7 @@ Write-Host "" Write-Host "=== TC2: Agent-scoped deploy — Claude only ===" $tc2Dir = Join-Path ([System.IO.Path]::GetTempPath()) "tc2-$([guid]::NewGuid().ToString('N').Substring(0,8))" New-Item -ItemType Directory -Path $tc2Dir -Force | Out-Null -& "$RepoDir/deploy.ps1" -Agents claude -Overwrite -Target $tc2Dir *>$null +& "$ScriptsDir/deploy.ps1" -Agents claude -Overwrite -Target $tc2Dir *>$null Assert-FileExists "claude wrapper present" "$tc2Dir/.claude/skills/setup-create-local-otel-stack/SKILL.md" Assert-DirNotExists "copilot dir absent" "$tc2Dir/.github/skills/setup-create-local-otel-stack" @@ -193,7 +194,7 @@ Write-Host "" Write-Host "=== TC3: Agent-scoped deploy — Copilot only ===" $tc3Dir = Join-Path ([System.IO.Path]::GetTempPath()) "tc3-$([guid]::NewGuid().ToString('N').Substring(0,8))" New-Item -ItemType Directory -Path $tc3Dir -Force | Out-Null -& "$RepoDir/deploy.ps1" -Agents copilot -Overwrite -Target $tc3Dir *>$null +& "$ScriptsDir/deploy.ps1" -Agents copilot -Overwrite -Target $tc3Dir *>$null Assert-FileExists "copilot wrapper present" "$tc3Dir/.github/skills/setup-create-local-otel-stack/SKILL.md" Assert-DirNotExists "claude dir absent" "$tc3Dir/.claude/skills/setup-create-local-otel-stack" @@ -207,7 +208,7 @@ Write-Host "" Write-Host "=== TC4: No regressions — existing thin wrappers ===" $tc4Dir = Join-Path ([System.IO.Path]::GetTempPath()) "tc4-$([guid]::NewGuid().ToString('N').Substring(0,8))" New-Item -ItemType Directory -Path $tc4Dir -Force | Out-Null -& "$RepoDir/deploy.ps1" -Agents claude -Overwrite -Target $tc4Dir *>$null +& "$ScriptsDir/deploy.ps1" -Agents claude -Overwrite -Target $tc4Dir *>$null Assert-FileExists "assess-observability" "$tc4Dir/.claude/skills/assess-observability/SKILL.md" @@ -235,7 +236,7 @@ $originalCurrentDirectory = [Environment]::CurrentDirectory try { Set-Location $tc5Launch [Environment]::CurrentDirectory = $tc5Corrupt - & "$RepoDir/deploy.ps1" -Agents claude -Overwrite -Target "../reltarget" *>$null + & "$ScriptsDir/deploy.ps1" -Agents claude -Overwrite -Target "../reltarget" *>$null } finally { Set-Location $originalCwd [Environment]::CurrentDirectory = $originalCurrentDirectory diff --git a/tests/test-deploy.sh b/scripts/tests/test-deploy.sh similarity index 96% rename from tests/test-deploy.sh rename to scripts/tests/test-deploy.sh index c6b009a..d487b2a 100644 --- a/tests/test-deploy.sh +++ b/scripts/tests/test-deploy.sh @@ -11,7 +11,8 @@ set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" -REPO_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" +REPO_DIR="$(cd "$SCRIPT_DIR/../.." && pwd)" +SCRIPTS_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" # Detect whether the source filesystem supports permission differentiation. # On WSL2-mounted Windows filesystems, all files are rwxrwxrwx regardless @@ -144,7 +145,7 @@ assert_files_identical() { echo "" echo "=== TC1: Fresh deploy — all agents ===" TC1_DIR=$(mktemp -d) -"$REPO_DIR/deploy.sh" --agents all --overwrite "$TC1_DIR" >/dev/null 2>&1 +"$SCRIPTS_DIR/deploy.sh" --agents all --overwrite "$TC1_DIR" >/dev/null 2>&1 echo " --- Playbook files ---" assert_file_exists "create-local-otel-stack.md" "$TC1_DIR/.context/playbooks/setup/create-local-otel-stack.md" @@ -223,7 +224,7 @@ rm -rf "$TC1_DIR" echo "" echo "=== TC2: Agent-scoped deploy — Claude only ===" TC2_DIR=$(mktemp -d) -"$REPO_DIR/deploy.sh" --agents claude --overwrite "$TC2_DIR" >/dev/null 2>&1 +"$SCRIPTS_DIR/deploy.sh" --agents claude --overwrite "$TC2_DIR" >/dev/null 2>&1 assert_file_exists "claude wrapper present" "$TC2_DIR/.claude/skills/setup-create-local-otel-stack/SKILL.md" assert_dir_not_exists "copilot dir absent" "$TC2_DIR/.github/skills/setup-create-local-otel-stack" @@ -236,7 +237,7 @@ rm -rf "$TC2_DIR" echo "" echo "=== TC3: Agent-scoped deploy — Copilot only ===" TC3_DIR=$(mktemp -d) -"$REPO_DIR/deploy.sh" --agents copilot --overwrite "$TC3_DIR" >/dev/null 2>&1 +"$SCRIPTS_DIR/deploy.sh" --agents copilot --overwrite "$TC3_DIR" >/dev/null 2>&1 assert_file_exists "copilot wrapper present" "$TC3_DIR/.github/skills/setup-create-local-otel-stack/SKILL.md" assert_dir_not_exists "claude dir absent" "$TC3_DIR/.claude/skills/setup-create-local-otel-stack" @@ -249,7 +250,7 @@ rm -rf "$TC3_DIR" echo "" echo "=== TC4: No regressions — existing thin wrappers ===" TC4_DIR=$(mktemp -d) -"$REPO_DIR/deploy.sh" --agents claude --overwrite "$TC4_DIR" >/dev/null 2>&1 +"$SCRIPTS_DIR/deploy.sh" --agents claude --overwrite "$TC4_DIR" >/dev/null 2>&1 assert_file_exists "assess-observability" "$TC4_DIR/.claude/skills/assess-observability/SKILL.md" assert_file_exists "review-security" "$TC4_DIR/.claude/skills/review-security/SKILL.md" @@ -274,11 +275,11 @@ TC5_CHECKSUMS2=$(mktemp) TC5_PERMS1=$(mktemp) TC5_PERMS2=$(mktemp) -"$REPO_DIR/deploy.sh" --agents all --overwrite "$TC5_DIR" >/dev/null 2>&1 +"$SCRIPTS_DIR/deploy.sh" --agents all --overwrite "$TC5_DIR" >/dev/null 2>&1 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 +"$SCRIPTS_DIR/deploy.sh" --agents all --overwrite "$TC5_DIR" >/dev/null 2>&1 checksum_tree "$TC5_DIR" > "$TC5_CHECKSUMS2" list_executables "$TC5_DIR" > "$TC5_PERMS2" @@ -304,7 +305,7 @@ rm -rf "$TC5_DIR" "$TC5_CHECKSUMS1" "$TC5_CHECKSUMS2" "$TC5_PERMS1" "$TC5_PERMS2 echo "" echo "=== TC6: validate-config passes ===" TC6_DIR=$(mktemp -d) -"$REPO_DIR/deploy.sh" --agents all --overwrite "$TC6_DIR" >/dev/null 2>&1 +"$SCRIPTS_DIR/deploy.sh" --agents all --overwrite "$TC6_DIR" >/dev/null 2>&1 if "$TC6_DIR/.context/playbooks/setup/create-local-otel-stack/validate-config.sh" >/dev/null 2>&1; then pass "Deployed validate-config.sh exits 0" From c73e9a5de9ca746eeae3c61600d33e0bed290c52 Mon Sep 17 00:00:00 2001 From: ldastey-dev Date: Sun, 6 Sep 2026 16:09:33 +0100 Subject: [PATCH 02/20] feat(overrides): add immutable base with consumer-owned override layer Introduces the override architecture that makes divergence structurally impossible instead of merely detectable. Base content under .context/{standards,playbooks,conventions} is now framework-owned and replaced wholesale on update. Consumers place changes in .context/overrides/ mirroring the base path, with frontmatter declaring mode: replace (ignore base) or mode: extend (layer over base). The resolution rule is stated in .context/index.md and the AGENTS.md managed block, costing roughly three lines of always-in-context budget. AGENTS.md gains agentic-context:begin/end markers. Only that block is rewritten on deploy or update, so [CONFIGURE] sections and any other consumer content survive untouched. Adds scripts/lib/common.sh with SemVer comparison (numeric, so 1.10.0 correctly exceeds 1.9.0), pin matching, fail-open network helpers and manifest hashing; scripts/update.sh for staleness checks and in-place updates; scripts/migrate.sh to upgrade pre-2.0 deployments by promoting existing divergence into overrides automatically; and the 1.0.0 baseline hashes that make that promotion possible. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- core/.context/index.md | 12 + core/.context/overrides/README.md | 134 +++++++ core/.context/overrides/conventions/.gitkeep | 0 core/.context/overrides/playbooks/.gitkeep | 0 core/.context/overrides/standards/.gitkeep | 0 core/AGENTS.md | 37 ++ scripts/baselines/1.0.0.sha256 | 78 +++++ scripts/deploy.sh | 161 ++++++++- scripts/lib/common.sh | 224 ++++++++++++ scripts/migrate.sh | 293 ++++++++++++++++ scripts/update.sh | 345 +++++++++++++++++++ 11 files changed, 1283 insertions(+), 1 deletion(-) create mode 100644 core/.context/overrides/README.md create mode 100644 core/.context/overrides/conventions/.gitkeep create mode 100644 core/.context/overrides/playbooks/.gitkeep create mode 100644 core/.context/overrides/standards/.gitkeep create mode 100644 scripts/baselines/1.0.0.sha256 create mode 100644 scripts/lib/common.sh create mode 100644 scripts/migrate.sh create mode 100644 scripts/update.sh diff --git a/core/.context/index.md b/core/.context/index.md index 2b35f97..684262a 100644 --- a/core/.context/index.md +++ b/core/.context/index.md @@ -4,6 +4,18 @@ Before starting any task, scan this index for matching keywords. Load the referenced files into your context before proceeding. Combine multiple matches when a task spans domains. +## Override resolution — apply to every file below + +Before loading any `.context/**` file, check whether `.context/overrides/` exists. If it does, that override takes precedence: + +- Override frontmatter `mode: replace` (or no `mode`) — load **only** the override; ignore the base file. +- Override frontmatter `mode: extend` — load the base file first, then apply the override on top. Where they conflict, the override wins. + +Base files under `.context/standards/`, `.context/playbooks/` and `.context/conventions/` are +managed by the framework and are replaced wholesale on update. Never edit them; put changes in +`.context/overrides/` instead. See `.context/overrides/README.md`. + --- ## Standards (reference — load when working in the domain) diff --git a/core/.context/overrides/README.md b/core/.context/overrides/README.md new file mode 100644 index 0000000..b6b6845 --- /dev/null +++ b/core/.context/overrides/README.md @@ -0,0 +1,134 @@ +# Overrides + +This directory is **yours**. The framework never reads, writes or deletes anything in it. + +Everything else under `.context/` is **base content**: it belongs to the framework, and +`update.sh` replaces it wholesale every time you take a new version. If you edit a base file +directly, your change is silently reverted on the next update. + +Put your changes here instead. They survive every update, forever. + +--- + +## How resolution works + +Mirror the path of the file you want to change, relative to `.context/`. + +| Base file | Your override | +| --- | --- | +| `.context/standards/security.md` | `.context/overrides/standards/security.md` | +| `.context/playbooks/review/code-quality.md` | `.context/overrides/playbooks/review/code-quality.md` | +| `.context/conventions/code.md` | `.context/overrides/conventions/code.md` | + +When an agent is about to load a base file, it checks for the override first. If one exists, +the override takes precedence. + +--- + +## The two modes + +Every override declares what it does in YAML frontmatter. + +### `mode: replace` — throw the base away + +Use when the framework's version is wrong for you and you want your own rules entirely. + +```markdown +--- +overrides: standards/testing.md +mode: replace +--- + +# Testing Standard + +We use a different model from the framework default. This file is the whole truth; +the base standard is ignored. + +## Non-Negotiables + +- Minimum coverage is 75%, measured on the service layer only. +``` + +### `mode: extend` — keep the base, layer on top + +Use when the framework is broadly right and you want to tighten, relax or add to it. This is +the common case, and it is the one that ages best — you keep inheriting upstream improvements. + +```markdown +--- +overrides: standards/testing.md +mode: extend +--- + +## Coverage + +Coverage minimum is **95%**, not the framework default of 90%. + +## Additional Requirement + +Every bug fix must ship with a regression test that fails without the fix. +``` + +The base standard loads first, then this file. Where the two conflict, this file wins. +Everything the base says that this file does not contradict still applies. + +**Prefer `extend`.** A `replace` override is a permanent fork of that file: you stop receiving +upstream improvements to it entirely. + +--- + +## Frontmatter reference + +| Field | Required | Values | Meaning | +| --- | --- | --- | --- | +| `overrides` | Yes | Path relative to `.context/` | The base file this override applies to | +| `mode` | No | `replace` (default) or `extend` | Whether the base is discarded or layered under | + +`overrides` must match the file's own location. `.context/overrides/standards/security.md` +must declare `overrides: standards/security.md`. `update.sh` warns when they disagree, +because a mismatch usually means a file was copied and the frontmatter was not updated. + +--- + +## Adding something entirely new + +An override does not have to correspond to a base file. To add a standard or playbook the +framework does not ship, put it here with no `overrides` field: + +```markdown +--- +name: standards-billing-domain +description: "Rules specific to our billing domain." +keywords: [billing, invoice, dunning, payment retry] +--- +``` + +Then add a keyword route for it in your `AGENTS.md`, **outside** the managed block, so agents +can discover it. Do not add it to `.context/index.md` — that file is base content and your +edit would be reverted on the next update. + +--- + +## What happens on update + +`update.sh` will: + +- Replace every base file under `.context/standards/`, `.context/playbooks/` and + `.context/conventions/` with the new version. +- **Never touch this directory.** +- Report any base file you had edited directly, and restore it — with a pointer to the + override path you should have used. +- Report standards and playbooks that were **added** or **removed** upstream. A removed base + file whose override still exists is flagged: your override now points at nothing, so either + delete it or convert it into a standalone addition. + +--- + +## Checking your overrides + +```bash +.context/bin/update.sh --check +``` + +Reports the current version, whether a newer one is available, and any override whose +`overrides:` target no longer exists. diff --git a/core/.context/overrides/conventions/.gitkeep b/core/.context/overrides/conventions/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/core/.context/overrides/playbooks/.gitkeep b/core/.context/overrides/playbooks/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/core/.context/overrides/standards/.gitkeep b/core/.context/overrides/standards/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/core/AGENTS.md b/core/AGENTS.md index 9226551..6469892 100644 --- a/core/AGENTS.md +++ b/core/AGENTS.md @@ -98,6 +98,12 @@ Infrastructure (Database / External APIs / Messaging) --- + + + ## Context System This repository uses on-demand context loading. Before starting any task, read `.context/index.md` and load files matching the current task's domain. @@ -108,6 +114,21 @@ Available context types: - **Playbooks** in `.context/playbooks/` — step-by-step procedures for assessments, reviews, plans, and refactoring - **Conventions** in `.context/conventions/` — workflow, communication, and coding style guidance +### Override Resolution + +Before loading any `.context/**` file, check whether `.context/overrides/` exists. If it does, that override takes precedence: + +- `mode: replace` (or no `mode`) — load **only** the override; ignore the base file. +- `mode: extend` — load the base file first, then apply the override on top. Where they conflict, the override wins. + +Base files under `.context/standards/`, `.context/playbooks/` and `.context/conventions/` are framework-managed and are replaced on update. Never edit them; put changes in `.context/overrides/`. See `.context/overrides/README.md`. + +### Framework Updates + +Check frequency: **weekly** + +At session start, if `.context/.last-update-check` is missing or older than the frequency above, run `.context/bin/update.sh --check` once. Report at most one line. **Never block the session.** If the check fails for any reason, continue silently. + --- ## Mandated Standards @@ -162,8 +183,24 @@ The following standards are non-negotiable. Do not weaken them. Detailed guidanc | OpenTelemetry | Cross-language SDK patterns, OTLP protocol, backends | `.context/standards/opentelemetry.md` | | OpenTelemetry .NET | .NET instrumentation, pitfalls, testing patterns | `.context/standards/opentelemetry-dotnet.md` | + + --- ## Project-Specific Rules [CONFIGURE] + +--- + +## Additional Context [CONFIGURE] + + + diff --git a/scripts/baselines/1.0.0.sha256 b/scripts/baselines/1.0.0.sha256 new file mode 100644 index 0000000..6098fe5 --- /dev/null +++ b/scripts/baselines/1.0.0.sha256 @@ -0,0 +1,78 @@ +conventions/code.md 306b9916bd0ebc87c418b4ded90a56c600708b0144a37de4aaca53484e19fb25 +conventions/communication.md 508a9981cc3830e9992eb6edb30473c06ed149bcc63bab63fb8683e277d6ee03 +conventions/workflow.md 6cc7f88c4dde53b9a713f9257060497abdf3e343c6e09b515b0cdb38464eb8cd +index.md 32a985ba27f992afdbe28da8f415c4e391ca7cd4ca87922e2ede8325ea4524bf +playbooks/assess/accessibility.md b9349df70e40518df65ef528f7366d288ffb5183fa2ef95f0641feb02ba6363c +playbooks/assess/api-design.md ffb618ba0ce0e6909d89e18f1fceef214e1df5eac4619593091b415aa4318763 +playbooks/assess/architecture.md e8b1de975d950f1d85a5ee7380e5a2385809711bc71824886c87c3399f0fa305 +playbooks/assess/aws-well-architected.md 962ca955caf258746d4b50b72ac6d48390a330f524a8d2e2065d1f185adb8d2d +playbooks/assess/azure-well-architected.md 05ccf31371bd07c918a2a2ff255e37a14acdcbf584927663f2dc03d0dc7fa66a +playbooks/assess/ci-cd.md 339e25cb98ebd5d2c3d9092d67f8a66d28388b4b92849c3c082f7d7f5b7e6f91 +playbooks/assess/code-quality.md 8695ebbdf1be36e82e704e32dd3655e894873686801861b704b9f36b0494eab0 +playbooks/assess/cost-optimisation.md 04dda1b90eb7db6726cdfb6fa0eb2a3ae29f897488a47502887654c3a7e56931 +playbooks/assess/domain-security.md 3b7ab6ddfee426b9c956c948f54890cdf124a31b09c7bb4ead4e96f49547504b +playbooks/assess/full.md d560735f7deab0b9f377ce33458590c8d6bd76da81c5e91b5ed55ef5da2ec3ac +playbooks/assess/gdpr.md c3a0727188cecae887be68adae8ba98eb1f2c598e06d4db6c439479191c60258 +playbooks/assess/iac.md 9977d505a067d47ad8b40a6e14fd501cec1b9082d432f51dd76d93b174e07711 +playbooks/assess/observability.md 04e05fbfbbc762871faab52de3ba85acc8711b7ddd1adfdf22edd35047b95cf3 +playbooks/assess/operational-excellence.md 6e1c03adbdf22a7a6dc6cc809736fe97c71d0043cd8e83834fa102fc9ab0f6a6 +playbooks/assess/pci-dss.md dbf890ac9882322d6a3019465f7e2b0a1d9f030410f97b7583f77a8d3b190446 +playbooks/assess/pen-test.md d2897e4e7d8c229228085ec9cabd86a390a00867a0ef55bd7057114e6a1698f0 +playbooks/assess/performance.md b7e080aea72e168ebc3aedb7dc97cf410cd98f102ded3dda0df86017a61d9f23 +playbooks/assess/resilience.md 666e2ab2db632f4525a69fd767870bf30f7759e30e4960383cbf28d3d83e48fc +playbooks/assess/security.md 5788e854bc32e1a1acdca278a579616c3e6f0e329b0a78280fcdc848895f7d38 +playbooks/assess/tech-debt.md bd7f183b5f13556d01852be2ecd29f168725e5da8cb3fb93d5b5defec415fe89 +playbooks/assess/testing.md 3e37c8ab2ac430184b0a80510ab9228862e9bcf0a3811d14bb64082296cf2cb7 +playbooks/debug/scientific-debugging.md 1a52e094b429b34074428d3d215feffbdf5a44d6cb66d8d64cdbbea8208531eb +playbooks/docs/gitbook.md 5951d16a59db69e693e07845f11129bb122a13bebbaa3ab177a733365d619f06 +playbooks/plan/adr.md 1015979a43df16e3f469b4f4f5fb8afe15df300d29cda400f05fc977f4ea7c7a +playbooks/plan/design-doc.md 938a2ea9ee36e10e0caf70ef51149ef2c44d45dbe4f94cecf457e4c536dfb5ea +playbooks/plan/research.md 8b0c774783cb369bafd09e37b4be641224ee836caf564f14ab7f3536f5e73347 +playbooks/plan/risk-assessment.md e68d1dbbcecf3ac31f0842c2ea37f28e176d9edb72bffea0c7c95d36d221711e +playbooks/plan/spike.md 29b9f19ff3a030eb7bdcf67b9903fb919282cd78997ca2103c4729328fc2cef4 +playbooks/refactor/dependency-upgrade.md b8bdf61de51e5f98fea320971c356a9f8bd4e73706ba3f87ee6e98dffd85e86a +playbooks/refactor/extract-module.md c3d67a579216457e471263d09e6783bf93e1c9e701682fa72a015402469764d3 +playbooks/refactor/safe-refactor.md c767d5bd189d3868163dcdaa786371e4b88e246a3ecd0a64aaba09f10d185416 +playbooks/review/accessibility.md 21e31f3116b8690373dae060d850f776f05154bee0c5f038ba4f0c2f16f14ed1 +playbooks/review/api-design.md 113721e0474bb09580fd11dc4b96edccf9b9916330dd794988e2aa10a6a57ee9 +playbooks/review/architecture.md 8deb5eb26771519a7f0612d2eaedb0a78f12b1560f10e18c377b80b5fef05c7c +playbooks/review/code-quality.md aedfb78f9c96d827096f8714a4e2f872423aacd9c1eb54ca6e8cabec1a850988 +playbooks/review/compliance.md f7a6c4d568b70ecc133ba7bd7747139a715cc19a0b06b5821b7e7e11f54c46bf +playbooks/review/cost-optimisation.md 69a8bd5c8bc5ec3372c5a7ba39e076c40c3729a1b645f75f33c6e1a12dd448bc +playbooks/review/iac.md 8449c682b18e38040cfa42cb721c532556c0b190878691fe6dc5581a327e3070 +playbooks/review/observability.md 4796c378b6067fd8192a10b5ff21aaf04375c9d08acde77e64f6f733e0f767b2 +playbooks/review/performance.md 36a17da7faaeaf6848ed24d1528f4ad98369d2a3cb40479477fafc16099032c7 +playbooks/review/security.md 5a209101a4c54199e370c54079464843f4ca480597e42080d4e61eb6dc2baf37 +playbooks/review/testing.md eae8dec22058ccf5db9771ea461f1dd404a549ce072f61ad725174e78cb882b4 +playbooks/setup/create-local-otel-stack.md d085a19dd7d426a89fa7900bbaadfa581aafd69c6117f7b35312a6d9f8d43049 +playbooks/setup/discover-local-otel-stack.md b226d151297ad068c6195d9dc52ef001c605219f0e6593aa71f8656649bbb9f4 +playbooks/setup/use-local-otel-stack.md 75f5ffbbf83f87495e00d8994b7de79409434b4f1e656734bffa00cdb3a3a2d3 +standards/accessibility.md 222c94e68e30f73081913e46eeb2d423eee8306b7753899729ba83f9560ffc75 +standards/ado-pipelines.md ecfaf7686ec26502e945864d3babed3da156c955e937a845c9802d78f24d5b34 +standards/api-design.md 93fbe27830ce29ddc0510088a178e9a27cfa56b1e697a88b9a93adad48b0658b +standards/architecture.md a1a1ff3f370f56c4ce82519bb137389985d666646c9aa252bc644233d1067762 +standards/aws-well-architected.md 3ef134cee4986cb4ef85cdd113663966d85917bf7e7b602b3b79ef43e87aa7e6 +standards/azure-well-architected.md 6b18818e64c4b17fbf5e348f1ba78ff6e0bd78c3e77560b5b1e11018d309c30f +standards/ci-cd.md 98a2e7e7424e8a3739372f9982a5d9643567c329e21af899824db1a2d859a977 +standards/code-quality.md 93d89564bc31ee8c795156ef0d0f64daf0ef651f3dda891bb81c64921d4b63f7 +standards/cost-optimisation.md 73c60ce6e398a703ebf035d960ccc17ddf05cac3d26ab6a1f9bb2a2a3581eb0e +standards/debugging.md af61c44c446009d72adfadf0c996e3161ca5c533e85e0bfd5a64aa6fd9a6b0ae +standards/docker.md af46fa7c7a95a313082587015f72f44421300502ef91e0f97f7df7f5282b846c +standards/dotnet.md ae7aecfe9167bf442e318e8336384326917f6823095bf88d6dfe250764cd8095 +standards/gdpr.md 6fdd28381e69ee097839b1f2eeeceead1a17b98e9e5b906727a6702022b58cc8 +standards/iac.md 42e3f14947e6e666efcac2a90da19308828c87b0d0f2ffb374e7205320a2f73e +standards/mssql.md 2d66ad11557e317a1bf52163c01900a3ff0f6f71655c1289efb7cf806f1de25b +standards/observability.md 715b0e76ad1a42049d83c7d5afb694e1cd52d0289c0341cd97c32646fe816a1d +standards/opentelemetry-dotnet.md 9c7fb12d69c14d4baeeb2fc0e9d580db148751905061c51fa3e5bad4c124cd87 +standards/opentelemetry.md aa46ebaa4ab8ecab0fb1e1d9edb50a3303b37bb46e738c90d6631275946e754d +standards/operational-excellence.md b3ab56143a895b8b38ca549a7899555e1013a7486b0a428fcd33409bf0959e8c +standards/pci-dss.md 1ed1186eca0f2009d43e2aade351a932f4ba35b69b9153a39d38986107a6b504 +standards/performance.md b3f6963bf3a21ca3a23a5cb9b4678d1eeeb96316452305649c4fe95ca250bbd6 +standards/playwright.md 435c1630df617db05a217ee9d2cf81736db011fa982bab9f2f6d810b54cf5861 +standards/powershell.md 4644e1ef2f28360566f5beebde38ca23ea7e611e5e3d3cfa7f55cb59467a5f05 +standards/react.md f4cdad1447ef376de05583d69cbaed1f83fc2e0371b8476d34efd29c373df57a +standards/resilience.md 4e6550f5ffca539c78cc392ed9094096d97c3a73f8382cd38bff8000d6aaad1e +standards/security.md d43c02e6e464c2703354bac503b931856073a0ba2d82d6f3419882e8aa1a00a0 +standards/tech-debt.md 5fd717bd084d6453b8616a14dd1ff7a6ccb73f88e65a33a210d3680e1352a427 +standards/terraform.md 0a1ef3e7f39800aa348e947c6ae40c775f84ed9ced2d71a46bddbeb7fc240c26 +standards/testing.md 1dfbcc360460498eaab906b98f145489bd5d5f572c8dfaa73fa81b6bcd42c9de diff --git a/scripts/deploy.sh b/scripts/deploy.sh index 51641a1..70bf3d4 100755 --- a/scripts/deploy.sh +++ b/scripts/deploy.sh @@ -165,6 +165,125 @@ copy_dir_contents() { done < <(find "$src" -type f -print0 | sort -z) } +# --- managed block --------------------------------------------------------- +# +# The consumer owns AGENTS.md: it carries their [CONFIGURE] sections. The +# framework owns only the region between the begin/end markers. Rewriting just +# that region is the only safe way to refresh a file we do not own. + +AC_BEGIN_MARKER='' + +# Return 0 when the file contains a well-formed managed block. +has_managed_block() { + local file="$1" + [ -f "$file" ] || return 1 + grep -q "^$AC_BEGIN_MARKER" "$file" 2>/dev/null || return 1 + grep -qF "$AC_END_MARKER" "$file" 2>/dev/null || return 1 + return 0 +} + +# Print the managed block (markers included) from the source template. +extract_managed_block() { + local file="$1" + awk -v b="$AC_BEGIN_MARKER" -v e="$AC_END_MARKER" ' + index($0, b) == 1 { inblock = 1 } + inblock { print } + index($0, e) == 1 { inblock = 0 } + ' "$file" +} + +# Replace the managed block in $dst with the one from $src, preserving +# everything outside the markers byte for byte. +replace_managed_block() { + local src="$1" dst="$2" version="$3" + local block_file tmp_file + + block_file="$(mktemp)" + tmp_file="$(mktemp)" + + extract_managed_block "$src" \ + | sed "1s|^$AC_BEGIN_MARKER.*|$AC_BEGIN_MARKER $version -->|" > "$block_file" + + awk -v b="$AC_BEGIN_MARKER" -v e="$AC_END_MARKER" -v blockfile="$block_file" ' + index($0, b) == 1 { + inblock = 1 + while ((getline line < blockfile) > 0) { print line } + close(blockfile) + next + } + index($0, e) == 1 && inblock { inblock = 0; next } + !inblock { print } + ' "$dst" > "$tmp_file" + + cat "$tmp_file" > "$dst" + rm -f "$block_file" "$tmp_file" +} + +# Deploy AGENTS.md safely. +# - absent -> copy the template whole +# - has managed block -> rewrite only that block, keep the rest untouched +# - no managed block -> defer to the normal overwrite prompt +deploy_agents_md() { + local src="$1" dst="$2" version="$3" + + if [ ! -f "$dst" ]; then + mkdir -p "$(dirname "$dst")" + sed "s|^$AC_BEGIN_MARKER.*|$AC_BEGIN_MARKER $version -->|" "$src" > "$dst" + return 0 + fi + + if has_managed_block "$dst"; then + replace_managed_block "$src" "$dst" "$version" + echo " AGENTS.md: refreshed managed block (your content preserved)" + return 0 + fi + + copy_file "$src" "$dst" +} + +# --- manifest -------------------------------------------------------------- + +# Write .context/manifest.json describing what was deployed, so update and +# migrate can tell pristine base files from consumer-edited ones. +write_manifest() { + local target="$1" version="$2" agents="$3" + local manifest="$target/.context/manifest.json" + local ctx="$target/.context" + local now first=1 + + now="$(date -u '+%Y-%m-%dT%H:%M:%SZ')" + + mkdir -p "$ctx" + { + printf '{\n' + printf ' "schema": 1,\n' + printf ' "version": "%s",\n' "$version" + printf ' "source": "%s",\n' "$AC_SOURCE_REPO" + printf ' "pin": "%s",\n' "$(ac_semver_major "$version").x" + printf ' "checkFrequency": "weekly",\n' + printf ' "deployedAt": "%s",\n' "$now" + printf ' "agents": [' + for agent in $agents; do + if [ $first -eq 1 ]; then first=0; else printf ','; fi + printf '"%s"' "$agent" + done + printf '],\n' + printf ' "files": {\n' + + first=1 + ac_hash_context_tree "$ctx" | while IFS= read -r line; do + rel="${line%% *}" + hash="${line##* }" + if [ $first -eq 1 ]; then first=0; else printf ',\n'; fi + printf ' "%s": "%s"' "$rel" "$hash" + done + + printf '\n }\n' + printf '}\n' + } > "$manifest" +} + interactive_select_agents() { local options=(all "${VALID_AGENTS[@]}" "clear and exit") local options_count=${#options[@]} @@ -536,11 +655,18 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # Content lives one level up: this script sits in scripts/, sources are at the repo root. SOURCE_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +# shellcheck source=scripts/lib/common.sh +. "$SCRIPT_DIR/lib/common.sh" + +DEPLOY_VERSION="$(ac_semver_normalise "$(cat "$SOURCE_ROOT/VERSION" 2>/dev/null || echo '0.0.0')")" +ac_semver_is_valid "$DEPLOY_VERSION" || DEPLOY_VERSION="0.0.0" + echo "Deploying agent-contexts to $TARGET" +echo " Version: $DEPLOY_VERSION" echo " Selected agents: $(join_by ', ' "${ENABLED_AGENTS[@]}")" echo " Copying shared context files..." -copy_file "$SOURCE_ROOT/core/AGENTS.md" "$TARGET/AGENTS.md" +deploy_agents_md "$SOURCE_ROOT/core/AGENTS.md" "$TARGET/AGENTS.md" "$DEPLOY_VERSION" copy_dir_contents "$SOURCE_ROOT/core/.context" "$TARGET/.context" if agent_enabled claude; then @@ -636,6 +762,28 @@ else echo " Skipping skill wrapper generation (no selected agent uses skills)." fi +# Consumer-owned override tree. Created empty; never touched again by update. +echo " Creating override layer → $TARGET/.context/overrides/" +mkdir -p "$TARGET/.context/overrides" +copy_dir_contents "$SOURCE_ROOT/core/.context/overrides" "$TARGET/.context/overrides" + +# Update tooling, shipped into the target so it can maintain itself. +echo " Installing update tooling → $TARGET/.context/bin/" +mkdir -p "$TARGET/.context/bin" +for tool in update.sh update.ps1 migrate.sh migrate.ps1; do + if [[ -f "$SOURCE_ROOT/scripts/$tool" ]]; then + cp "$SOURCE_ROOT/scripts/$tool" "$TARGET/.context/bin/$tool" + fi +done +mkdir -p "$TARGET/.context/bin/lib" +cp "$SOURCE_ROOT/scripts/lib/common.sh" "$TARGET/.context/bin/lib/common.sh" +chmod +x "$TARGET/.context/bin"/*.sh 2>/dev/null || true + +printf '%s\n' "$DEPLOY_VERSION" > "$TARGET/.context/VERSION" + +echo " Writing manifest → $TARGET/.context/manifest.json" +write_manifest "$TARGET" "$DEPLOY_VERSION" "${ENABLED_AGENTS[*]}" + echo "" echo "Done. Next steps:" step=1 @@ -655,6 +803,17 @@ if agent_enabled copilot; then next_step "Review $TARGET/.github/copilot-instructions.md" fi +next_step "Never edit .context/standards|playbooks|conventions directly — use .context/overrides/ (see .context/overrides/README.md)" + +# Report staleness on every deploy. Fails open and never blocks. +if latest="$(ac_fetch_latest_version "$AC_SOURCE_REPO" 2>/dev/null)" && [[ -n "$latest" ]]; then + if ac_semver_gt "$latest" "$DEPLOY_VERSION"; then + echo "" + echo " Note: you deployed $DEPLOY_VERSION but $latest is available upstream." + echo " Pull the latest agentic-context and re-run this script." + fi +fi + if [[ ${#SKIPPED_FILES[@]} -gt 0 ]]; then echo "" echo "Skipped files (not overwritten — manual merge may be required):" diff --git a/scripts/lib/common.sh b/scripts/lib/common.sh new file mode 100644 index 0000000..7fa9691 --- /dev/null +++ b/scripts/lib/common.sh @@ -0,0 +1,224 @@ +#!/bin/bash +# scripts/lib/common.sh — shared helpers for deploy, update and migrate. +# +# Portability: must run under macOS bash 3.2 with BSD userland as well as Linux +# with GNU userland. No bash 4+ syntax (no declare -A, no mapfile, no ${var,,}), +# no GNU-only utilities. +# +# Source this file; do not execute it. + +AC_SOURCE_REPO="${AC_SOURCE_REPO:-ldastey-dev/agentic-context}" +AC_RAW_BASE="${AC_RAW_BASE:-https://raw.githubusercontent.com}" +AC_WEB_BASE="${AC_WEB_BASE:-https://github.com}" + +# --- hashing --------------------------------------------------------------- + +# Print the sha256 of a file as a bare hex digest. +# macOS has shasum but not always sha256sum; Linux usually has both. +ac_sha256() { + local file="$1" + if command -v shasum >/dev/null 2>&1; then + shasum -a 256 "$file" | awk '{print $1}' + elif command -v sha256sum >/dev/null 2>&1; then + sha256sum "$file" | awk '{print $1}' + else + echo "ERROR: neither shasum nor sha256sum is available" >&2 + return 1 + fi +} + +# --- semver ---------------------------------------------------------------- + +# Strip a leading "v" and any surrounding whitespace. +ac_semver_normalise() { + printf '%s' "$1" | tr -d '[:space:]' | sed 's/^[vV]//' +} + +# Return 0 when the string is a bare X.Y.Z of non-negative integers. +ac_semver_is_valid() { + local v + v="$(ac_semver_normalise "$1")" + case "$v" in + ''|*[!0-9.]*) return 1 ;; + esac + # Exactly three dot-separated numeric components. + echo "$v" | grep -q '^[0-9][0-9]*\.[0-9][0-9]*\.[0-9][0-9]*$' +} + +ac_semver_major() { ac_semver_normalise "$1" | cut -d. -f1; } +ac_semver_minor() { ac_semver_normalise "$1" | cut -d. -f2; } +ac_semver_patch() { ac_semver_normalise "$1" | cut -d. -f3; } + +# ac_semver_gt A B -> 0 (true) when A is strictly greater than B. +# +# Compares numerically component by component. sort -V is deliberately avoided: +# BSD sort on older macOS lacks -V, and its ordering of equal values would make +# "strictly greater" ambiguous. +ac_semver_gt() { + local a b a1 a2 a3 b1 b2 b3 + a="$(ac_semver_normalise "$1")" + b="$(ac_semver_normalise "$2")" + + ac_semver_is_valid "$a" || return 1 + ac_semver_is_valid "$b" || return 1 + + a1="$(ac_semver_major "$a")"; a2="$(ac_semver_minor "$a")"; a3="$(ac_semver_patch "$a")" + b1="$(ac_semver_major "$b")"; b2="$(ac_semver_minor "$b")"; b3="$(ac_semver_patch "$b")" + + if [ "$a1" -gt "$b1" ]; then return 0; fi + if [ "$a1" -lt "$b1" ]; then return 1; fi + if [ "$a2" -gt "$b2" ]; then return 0; fi + if [ "$a2" -lt "$b2" ]; then return 1; fi + if [ "$a3" -gt "$b3" ]; then return 0; fi + return 1 +} + +# ac_semver_bump +ac_semver_bump() { + local cur="$1" kind="$2" x y z + cur="$(ac_semver_normalise "$cur")" + ac_semver_is_valid "$cur" || { echo "ERROR: invalid version '$1'" >&2; return 1; } + x="$(ac_semver_major "$cur")"; y="$(ac_semver_minor "$cur")"; z="$(ac_semver_patch "$cur")" + case "$kind" in + major) x=$((x + 1)); y=0; z=0 ;; + minor) y=$((y + 1)); z=0 ;; + patch) z=$((z + 1)) ;; + *) echo "ERROR: invalid bump kind '$kind'" >&2; return 1 ;; + esac + printf '%s.%s.%s' "$x" "$y" "$z" +} + +# Return 0 when satisfies . Pin forms: "" or "*" (any), +# "2.x" / "2" (major line), or an exact "2.3.1". +ac_semver_satisfies_pin() { + local version="$1" pin="$2" + version="$(ac_semver_normalise "$version")" + pin="$(printf '%s' "$pin" | tr -d '[:space:]' | sed 's/^[vV]//')" + + if [ -z "$pin" ] || [ "$pin" = "*" ]; then + return 0 + fi + + case "$pin" in + *.x|*.X) + [ "$(ac_semver_major "$version")" = "${pin%.[xX]}" ] && return 0 + return 1 + ;; + *.*.*) + [ "$version" = "$pin" ] && return 0 + return 1 + ;; + *) + [ "$(ac_semver_major "$version")" = "$pin" ] && return 0 + return 1 + ;; + esac +} + +# --- network --------------------------------------------------------------- +# +# Every network helper FAILS OPEN: on any error it prints nothing and returns +# non-zero. Callers must treat "no answer" as "up to date" and never block. + +AC_CURL_TIMEOUT="${AC_CURL_TIMEOUT:-5}" + +ac_have_curl() { command -v curl >/dev/null 2>&1; } + +# Fetch the canonical VERSION from the default branch. Cheapest transport: +# ~6 bytes, CDN-cached, unauthenticated, no rate limit. +ac_fetch_latest_version_raw() { + local repo="${1:-$AC_SOURCE_REPO}" branch="${2:-main}" out + ac_have_curl || return 1 + out="$(curl -fsS --max-time "$AC_CURL_TIMEOUT" \ + "$AC_RAW_BASE/$repo/$branch/VERSION" 2>/dev/null)" || return 1 + out="$(ac_semver_normalise "$out")" + ac_semver_is_valid "$out" || return 1 + printf '%s' "$out" +} + +# Fallback: resolve the newest release from the /releases/latest 302 redirect. +# Unauthenticated and not subject to the REST API's 60/hr per-IP limit. +ac_fetch_latest_version_release() { + local repo="${1:-$AC_SOURCE_REPO}" loc out + ac_have_curl || return 1 + loc="$(curl -fsSI --max-time "$AC_CURL_TIMEOUT" \ + "$AC_WEB_BASE/$repo/releases/latest" 2>/dev/null \ + | tr -d '\r' | awk 'tolower($1) == "location:" { print $2 }' | tail -1)" || return 1 + [ -n "$loc" ] || return 1 + out="$(ac_semver_normalise "${loc##*/}")" + ac_semver_is_valid "$out" || return 1 + printf '%s' "$out" +} + +# Try the cheap transport, then the fallback. Prints nothing on total failure. +ac_fetch_latest_version() { + local repo="${1:-$AC_SOURCE_REPO}" v + if v="$(ac_fetch_latest_version_raw "$repo")"; then + printf '%s' "$v" + return 0 + fi + if v="$(ac_fetch_latest_version_release "$repo")"; then + printf '%s' "$v" + return 0 + fi + return 1 +} + +# --- manifest -------------------------------------------------------------- +# +# The manifest is JSON so PowerShell can use ConvertFrom-Json natively and bash +# needs only grep/sed. It is written by us and read by us, so a full parser is +# unnecessary; these helpers read one scalar key at a time. + +# ac_manifest_get +ac_manifest_get() { + local file="$1" key="$2" + [ -f "$file" ] || return 1 + sed -n 's/.*"'"$key"'"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "$file" | head -1 +} + +# Hash every deployable file in a deployed .context tree, printing +# " " lines sorted by path. +# +# Relative paths are relative to the .context directory. Overrides and the +# bin/ directory are excluded: overrides belong to the consumer, and bin/ is +# refreshed like any other base file but is not part of the content baseline. +ac_hash_context_tree() { + local ctx="$1" f rel + [ -d "$ctx" ] || return 1 + find "$ctx" -type f -name '*.md' \ + ! -path "$ctx/overrides/*" \ + ! -path "$ctx/bin/*" \ + 2>/dev/null | LC_ALL=C sort | while IFS= read -r f; do + rel="${f#"$ctx"/}" + printf '%s %s\n' "$rel" "$(ac_sha256 "$f")" + done +} + +# Hash the deployable source files in this repository, printing the same +# " " shape so the two can be diffed directly. +ac_hash_source_tree() { + local root="$1" f rel + [ -d "$root" ] || return 1 + { + find "$root/standards" -type f -name '*.md' 2>/dev/null | LC_ALL=C sort | while IFS= read -r f; do + printf 'standards/%s %s\n' "${f#"$root"/standards/}" "$(ac_sha256 "$f")" + done + find "$root/playbooks" -type f -name '*.md' 2>/dev/null | LC_ALL=C sort | while IFS= read -r f; do + printf 'playbooks/%s %s\n' "${f#"$root"/playbooks/}" "$(ac_sha256 "$f")" + done + find "$root/core/.context/conventions" -type f -name '*.md' 2>/dev/null | LC_ALL=C sort | while IFS= read -r f; do + printf 'conventions/%s %s\n' "${f#"$root"/core/.context/conventions/}" "$(ac_sha256 "$f")" + done + if [ -f "$root/core/.context/index.md" ]; then + printf 'index.md %s\n' "$(ac_sha256 "$root/core/.context/index.md")" + fi + } | LC_ALL=C sort +} + +# Look up one path's expected hash in a baseline file. +ac_baseline_lookup() { + local baseline="$1" path="$2" + [ -f "$baseline" ] || return 1 + awk -v p="$path" '$1 == p { print $2; found = 1; exit } END { exit !found }' "$baseline" +} diff --git a/scripts/migrate.sh b/scripts/migrate.sh new file mode 100644 index 0000000..33cb5e6 --- /dev/null +++ b/scripts/migrate.sh @@ -0,0 +1,293 @@ +#!/bin/bash +# migrate.sh — upgrade a pre-2.0 agentic-context deployment to the override model. +# +# Pre-2.0 deployments have no .context/manifest.json and no .context/overrides/. +# Consumers may have edited standards and playbooks directly. This script finds +# those edits by comparing against a published baseline for the version they are +# on, and promotes each edited file into .context/overrides/ so their intent is +# preserved before base content is restored. +# +# Safe by default: dry-run unless --apply is given, and refuses to run on a +# dirty git tree so every change is reviewable. +# +# Portability: macOS bash 3.2 with BSD userland, and Linux with GNU userland. + +set -uo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +if [ -f "$SCRIPT_DIR/lib/common.sh" ]; then + # shellcheck source=scripts/lib/common.sh + . "$SCRIPT_DIR/lib/common.sh" +else + echo "ERROR: cannot locate lib/common.sh next to migrate.sh" >&2 + exit 1 +fi + +APPLY=0 +TARGET="" +FROM_VERSION="1.0.0" +BASELINE="" + +usage() { + cat <] [--baseline ] [target-repo] + +Upgrade a pre-2.0 deployment to the override model. + + --apply Write changes. Without it, reports what would happen and exits. + --from Version the target was deployed from. Default: 1.0.0 + --baseline Baseline hash file. Default: scripts/baselines/.sha256 + target-repo Repository to migrate. Default: current directory. + +What it does: + 1. Refuses to run on a dirty git tree. + 2. Hashes .context/{standards,playbooks,conventions} against the baseline. + 3. Moves every file that differs into .context/overrides/, adding frontmatter. + 4. Restores base files to the framework version. + 5. Prepends the AGENTS.md managed block, leaving your content intact below. + 6. Writes .context/manifest.json. +EOF +} + +while [ $# -gt 0 ]; do + case "$1" in + --apply) APPLY=1 ;; + --from) shift; FROM_VERSION="${1:-}" ;; + --baseline) shift; BASELINE="${1:-}" ;; + -h|--help) usage; exit 0 ;; + -*) echo "Unknown option: $1" >&2; usage >&2; exit 2 ;; + *) TARGET="$1" ;; + esac + shift +done + +[ -n "$TARGET" ] || TARGET="$(pwd)" +TARGET="$(cd "$TARGET" 2>/dev/null && pwd)" || { echo "ERROR: no such directory." >&2; exit 1; } + +CONTEXT_DIR="$TARGET/.context" +SOURCE_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" + +[ -n "$BASELINE" ] || BASELINE="$SOURCE_ROOT/scripts/baselines/$FROM_VERSION.sha256" + +# --- preconditions --------------------------------------------------------- + +if [ ! -d "$CONTEXT_DIR" ]; then + echo "ERROR: $TARGET has no .context/ directory — nothing to migrate." >&2 + exit 1 +fi + +if [ -f "$CONTEXT_DIR/manifest.json" ]; then + existing="$(ac_manifest_get "$CONTEXT_DIR/manifest.json" version)" + echo "This deployment already has a manifest (version ${existing:-unknown})." + echo "Migration is only for pre-2.0 deployments. Use update.sh --apply instead." + exit 0 +fi + +if [ ! -f "$BASELINE" ]; then + echo "ERROR: baseline not found: $BASELINE" >&2 + echo " Pass --baseline explicitly, or --from with a published version." >&2 + exit 1 +fi + +# A dirty tree makes the migration unreviewable, so refuse outright. +if command -v git >/dev/null 2>&1 && git -C "$TARGET" rev-parse --git-dir >/dev/null 2>&1; then + if [ -n "$(git -C "$TARGET" status --porcelain 2>/dev/null)" ]; then + echo "ERROR: $TARGET has uncommitted changes." >&2 + echo " Commit or stash first so this migration can be reviewed as a diff." >&2 + exit 1 + fi +else + echo "WARNING: $TARGET is not a git repository. Changes will not be reviewable." >&2 + if [ "$APPLY" -eq 1 ]; then + printf "Continue anyway? [y/N] " + read -r reply + case "$reply" in [yY]*) ;; *) echo "Aborted."; exit 1 ;; esac + fi +fi + +if [ "$APPLY" -eq 1 ]; then + echo "Migrating $TARGET (from $FROM_VERSION)" +else + echo "DRY RUN — no files will be written. Re-run with --apply to commit." + echo "Analysing $TARGET (from $FROM_VERSION)" +fi +echo "" + +# --- classify -------------------------------------------------------------- + +DIVERGED_LIST="$(mktemp)" +MISSING_LIST="$(mktemp)" +cleanup() { rm -f "$DIVERGED_LIST" "$MISSING_LIST"; } +trap cleanup EXIT INT TERM + +for area in standards playbooks conventions; do + [ -d "$CONTEXT_DIR/$area" ] || continue + find "$CONTEXT_DIR/$area" -type f -name '*.md' 2>/dev/null | LC_ALL=C sort | while IFS= read -r f; do + rel="$area/${f#"$CONTEXT_DIR"/"$area"/}" + actual="$(ac_sha256 "$f")" + if expected="$(ac_baseline_lookup "$BASELINE" "$rel")"; then + if [ "$expected" != "$actual" ]; then + printf '%s\n' "$rel" >> "$DIVERGED_LIST" + fi + else + # Not in the baseline at all: a file the consumer added themselves. + printf '%s\n' "$rel" >> "$MISSING_LIST" + fi + done +done + +diverged_count=$(wc -l < "$DIVERGED_LIST" | tr -d ' ') +added_count=$(wc -l < "$MISSING_LIST" | tr -d ' ') + +if [ "$diverged_count" -eq 0 ] && [ "$added_count" -eq 0 ]; then + echo "No local modifications detected — this deployment is pristine." +else + if [ "$diverged_count" -gt 0 ]; then + echo "Modified framework files ($diverged_count) — will become overrides:" + while IFS= read -r rel; do + [ -n "$rel" ] || continue + echo " $rel -> .context/overrides/$rel" + done < "$DIVERGED_LIST" + echo "" + fi + if [ "$added_count" -gt 0 ]; then + echo "Files you added ($added_count) — will move to overrides as standalone additions:" + while IFS= read -r rel; do + [ -n "$rel" ] || continue + echo " $rel -> .context/overrides/$rel" + done < "$MISSING_LIST" + echo "" + fi +fi + +if [ "$APPLY" -eq 0 ]; then + echo "Nothing written. Re-run with --apply to perform the migration." + exit 0 +fi + +# --- apply ----------------------------------------------------------------- + +mkdir -p "$CONTEXT_DIR/overrides" + +promote() { + local rel="$1" mode="$2" + local src="$CONTEXT_DIR/$rel" + local dst="$CONTEXT_DIR/overrides/$rel" + [ -f "$src" ] || return 0 + + mkdir -p "$(dirname "$dst")" + + if [ "$mode" = "replace" ]; then + { + printf -- '---\n' + printf 'overrides: %s\n' "$rel" + printf 'mode: replace\n' + printf -- '---\n\n' + printf '\n\n' + cat "$src" + } > "$dst" + else + cat "$src" > "$dst" + fi +} + +while IFS= read -r rel; do + [ -n "$rel" ] || continue + promote "$rel" replace +done < "$DIVERGED_LIST" + +while IFS= read -r rel; do + [ -n "$rel" ] || continue + promote "$rel" standalone + rm -f "$CONTEXT_DIR/$rel" +done < "$MISSING_LIST" + +echo "Restoring base content from $SOURCE_ROOT ..." +for pair in "standards:$SOURCE_ROOT/standards" "playbooks:$SOURCE_ROOT/playbooks" "conventions:$SOURCE_ROOT/core/.context/conventions"; do + name="${pair%%:*}" + from="${pair#*:}" + [ -d "$from" ] || continue + rm -rf "${CONTEXT_DIR:?}/$name" + mkdir -p "$CONTEXT_DIR/$name" + (cd "$from" && tar cf - .) | (cd "$CONTEXT_DIR/$name" && tar xf -) +done + +[ -f "$SOURCE_ROOT/core/.context/index.md" ] && cp "$SOURCE_ROOT/core/.context/index.md" "$CONTEXT_DIR/index.md" +[ -f "$SOURCE_ROOT/core/.context/overrides/README.md" ] && cp "$SOURCE_ROOT/core/.context/overrides/README.md" "$CONTEXT_DIR/overrides/README.md" + +mkdir -p "$CONTEXT_DIR/bin/lib" +for tool in update.sh update.ps1 migrate.sh migrate.ps1; do + [ -f "$SOURCE_ROOT/scripts/$tool" ] && cp "$SOURCE_ROOT/scripts/$tool" "$CONTEXT_DIR/bin/$tool" +done +cp "$SOURCE_ROOT/scripts/lib/common.sh" "$CONTEXT_DIR/bin/lib/common.sh" +chmod +x "$CONTEXT_DIR/bin"/*.sh 2>/dev/null || true + +# AGENTS.md: prepend the managed block, leave everything the consumer has intact. +# Identifying which of their existing regions were framework-authored is guesswork, +# so this deliberately does not try — a human reviews one file instead. +NEW_VERSION="$(ac_semver_normalise "$(cat "$SOURCE_ROOT/VERSION" 2>/dev/null || echo '0.0.0')")" +AGENTS_FILE="$TARGET/AGENTS.md" + +if [ -f "$SOURCE_ROOT/core/AGENTS.md" ]; then + if [ ! -f "$AGENTS_FILE" ]; then + sed "s|^|" \ + "$SOURCE_ROOT/core/AGENTS.md" > "$AGENTS_FILE" + elif grep -q '^' ' + index($0, b) == 1 { inblock = 1 } + inblock { print } + index($0, e) == 1 { inblock = 0 } + ' "$SOURCE_ROOT/core/AGENTS.md" \ + | sed "1s|^|" + printf '\n---\n\n' + printf '\n\n' + cat "$AGENTS_FILE" + } > "$tmp" + cat "$tmp" > "$AGENTS_FILE" + rm -f "$tmp" + echo " AGENTS.md: managed block prepended; your original content kept below for review." + fi +fi + +# Write the manifest last, so its hashes reflect the final state. +now="$(date -u '+%Y-%m-%dT%H:%M:%SZ')" +{ + printf '{\n' + printf ' "schema": 1,\n' + printf ' "version": "%s",\n' "$NEW_VERSION" + printf ' "source": "%s",\n' "$AC_SOURCE_REPO" + printf ' "pin": "%s",\n' "$(ac_semver_major "$NEW_VERSION").x" + printf ' "checkFrequency": "weekly",\n' + printf ' "deployedAt": "%s",\n' "$now" + printf ' "agents": [],\n' + printf ' "files": {\n' + first=1 + ac_hash_context_tree "$CONTEXT_DIR" | while IFS= read -r line; do + rel="${line%% *}" + hash="${line##* }" + if [ $first -eq 1 ]; then first=0; else printf ',\n'; fi + printf ' "%s": "%s"' "$rel" "$hash" + done + printf '\n }\n' + printf '}\n' +} > "$CONTEXT_DIR/manifest.json" + +printf '%s\n' "$NEW_VERSION" > "$CONTEXT_DIR/VERSION" + +echo "" +echo "Migration complete. Now on $NEW_VERSION." +echo "" +echo "Review before committing:" +echo " git -C $TARGET diff --stat" +echo " $TARGET/AGENTS.md — remove sections duplicated by the managed block" +echo " $TARGET/.context/overrides/ — convert 'mode: replace' to 'mode: extend' where you can" diff --git a/scripts/update.sh b/scripts/update.sh new file mode 100644 index 0000000..65e93f5 --- /dev/null +++ b/scripts/update.sh @@ -0,0 +1,345 @@ +#!/bin/bash +# update.sh — check for and apply agentic-context updates. +# +# Deployed into target repositories as .context/bin/update.sh, and also usable +# from the library itself as scripts/update.sh. +# +# Usage: +# .context/bin/update.sh --check Report status only. Never writes. Never blocks. +# .context/bin/update.sh --apply Fetch the latest version and update base content. +# .context/bin/update.sh --status Show local state without any network call. +# +# Portability: macOS bash 3.2 with BSD userland, and Linux with GNU userland. + +set -uo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +# The library lives at scripts/lib/common.sh; the deployed copy at .context/bin/lib/common.sh. +if [ -f "$SCRIPT_DIR/lib/common.sh" ]; then + # shellcheck source=scripts/lib/common.sh + . "$SCRIPT_DIR/lib/common.sh" +else + echo "ERROR: cannot locate lib/common.sh next to update.sh" >&2 + exit 1 +fi + +MODE="check" +FORCE=0 +QUIET=0 + +usage() { + cat <&2; usage >&2; exit 2 ;; + esac + shift +done + +# --- locate the deployed .context directory -------------------------------- + +find_context_dir() { + # Deployed layout: .context/bin/update.sh -> .context + if [ -f "$SCRIPT_DIR/../manifest.json" ]; then + (cd "$SCRIPT_DIR/.." && pwd) + return 0 + fi + # Walk up from the current directory looking for a deployed .context. + local dir + dir="$(pwd)" + while [ "$dir" != "/" ]; do + if [ -f "$dir/.context/manifest.json" ]; then + printf '%s' "$dir/.context" + return 0 + fi + dir="$(dirname "$dir")" + done + return 1 +} + +CONTEXT_DIR="$(find_context_dir)" || { + echo "ERROR: no deployed .context/manifest.json found." >&2 + echo " Run this from a repository where agentic-context is deployed." >&2 + exit 1 +} +TARGET_ROOT="$(cd "$CONTEXT_DIR/.." && pwd)" +MANIFEST="$CONTEXT_DIR/manifest.json" +STAMP="$CONTEXT_DIR/.last-update-check" + +LOCAL_VERSION="$(ac_manifest_get "$MANIFEST" version)" +[ -n "$LOCAL_VERSION" ] || LOCAL_VERSION="0.0.0" +SOURCE_REPO="$(ac_manifest_get "$MANIFEST" source)" +[ -n "$SOURCE_REPO" ] || SOURCE_REPO="$AC_SOURCE_REPO" +PIN="$(ac_manifest_get "$MANIFEST" pin)" + +# --- divergence and override reporting ------------------------------------- + +# List base files whose current hash differs from the manifest record. +list_diverged() { + local rel recorded actual + ac_hash_context_tree "$CONTEXT_DIR" | while IFS= read -r line; do + rel="${line%% *}" + actual="${line##* }" + recorded="$(sed -n 's|.*"'"$rel"'"[[:space:]]*:[[:space:]]*"\([a-f0-9]*\)".*|\1|p' "$MANIFEST" | head -1)" + if [ -n "$recorded" ] && [ "$recorded" != "$actual" ]; then + printf '%s\n' "$rel" + fi + done +} + +# List overrides whose declared target no longer exists in the base tree. +list_orphan_overrides() { + local f rel target + [ -d "$CONTEXT_DIR/overrides" ] || return 0 + find "$CONTEXT_DIR/overrides" -type f -name '*.md' 2>/dev/null | LC_ALL=C sort | while IFS= read -r f; do + rel="${f#"$CONTEXT_DIR"/overrides/}" + [ "$rel" = "README.md" ] && continue + target="$(sed -n 's/^overrides:[[:space:]]*//p' "$f" | head -1 | tr -d '[:space:]')" + [ -n "$target" ] || continue + if [ ! -f "$CONTEXT_DIR/$target" ]; then + printf '%s -> %s\n' "$rel" "$target" + fi + done +} + +touch_stamp() { date -u '+%Y-%m-%dT%H:%M:%SZ' > "$STAMP" 2>/dev/null || true; } + +# --- modes ----------------------------------------------------------------- + +report_local_state() { + local diverged orphans + diverged="$(list_diverged)" + orphans="$(list_orphan_overrides)" + + if [ -n "$diverged" ]; then + echo "" + echo "Locally modified base files (these will be restored on update):" + printf '%s\n' "$diverged" | while IFS= read -r rel; do + [ -n "$rel" ] || continue + echo " - $rel" + echo " move your change to .context/overrides/$rel" + done + fi + + if [ -n "$orphans" ]; then + echo "" + echo "Overrides pointing at files that no longer exist:" + printf '%s\n' "$orphans" | while IFS= read -r line; do + [ -n "$line" ] || continue + echo " - $line" + done + fi +} + +if [ "$MODE" = "status" ]; then + echo "agentic-context $LOCAL_VERSION (source: $SOURCE_REPO, pin: ${PIN:-none})" + report_local_state + exit 0 +fi + +# Both --check and --apply need the upstream version. Fail open. +LATEST="$(ac_fetch_latest_version "$SOURCE_REPO" 2>/dev/null)" || LATEST="" + +if [ -z "$LATEST" ]; then + touch_stamp + [ "$QUIET" -eq 1 ] && exit 0 + echo "agentic-context $LOCAL_VERSION — update check unavailable (offline or unreachable)." + exit 0 +fi + +touch_stamp + +if ! ac_semver_gt "$LATEST" "$LOCAL_VERSION"; then + [ "$QUIET" -eq 1 ] && exit 0 + echo "agentic-context $LOCAL_VERSION is up to date." + report_local_state + exit 0 +fi + +# An update exists. Honour the pin unless forced. +PIN_OK=0 +if [ "$FORCE" -eq 1 ] || ac_semver_satisfies_pin "$LATEST" "$PIN"; then + PIN_OK=1 +fi + +if [ "$MODE" = "check" ]; then + if [ "$PIN_OK" -eq 1 ]; then + echo "agentic-context update available: $LOCAL_VERSION -> $LATEST (run .context/bin/update.sh --apply)" + else + echo "agentic-context $LATEST is available but outside your pin '$PIN'. See MIGRATIONS.md, then use --force." + fi + [ "$QUIET" -eq 1 ] && exit 0 + report_local_state + exit 0 +fi + +# --- apply ----------------------------------------------------------------- + +if [ "$PIN_OK" -ne 1 ]; then + echo "Refusing to update: $LATEST is outside the pin '$PIN'." >&2 + echo "This is a major upgrade. Read MIGRATIONS.md, then re-run with --force." >&2 + exit 1 +fi + +command -v curl >/dev/null 2>&1 || { echo "ERROR: curl is required to apply updates." >&2; exit 1; } +command -v tar >/dev/null 2>&1 || { echo "ERROR: tar is required to apply updates." >&2; exit 1; } + +echo "Updating agentic-context $LOCAL_VERSION -> $LATEST" + +WORK_DIR="$(mktemp -d)" +cleanup() { rm -rf "$WORK_DIR"; } +trap cleanup EXIT INT TERM + +TARBALL="$WORK_DIR/src.tar.gz" +if ! curl -fsSL --max-time 60 \ + "$AC_WEB_BASE/$SOURCE_REPO/archive/refs/tags/v$LATEST.tar.gz" -o "$TARBALL"; then + echo "ERROR: could not download v$LATEST." >&2 + exit 1 +fi + +tar -xzf "$TARBALL" -C "$WORK_DIR" || { echo "ERROR: could not extract archive." >&2; exit 1; } + +SRC="$(find "$WORK_DIR" -maxdepth 1 -type d -name '*-*' | head -1)" +[ -d "$SRC" ] || { echo "ERROR: unexpected archive layout." >&2; exit 1; } + +# Record divergence before we overwrite anything, so we can report it after. +DIVERGED="$(list_diverged)" + +# Detect removals: base files present locally but absent upstream. +REMOVED="" +for rel in $(ac_hash_context_tree "$CONTEXT_DIR" | sed 's/ .*//'); do + case "$rel" in + standards/*) [ -f "$SRC/${rel}" ] || REMOVED="$REMOVED $rel" ;; + playbooks/*) [ -f "$SRC/${rel}" ] || REMOVED="$REMOVED $rel" ;; + conventions/*) [ -f "$SRC/core/.context/${rel}" ] || REMOVED="$REMOVED $rel" ;; + esac +done + +# Replace base trees wholesale. Overrides are untouched by construction. +for pair in "standards:$SRC/standards" "playbooks:$SRC/playbooks" "conventions:$SRC/core/.context/conventions"; do + name="${pair%%:*}" + from="${pair#*:}" + [ -d "$from" ] || continue + rm -rf "${CONTEXT_DIR:?}/$name" + mkdir -p "$CONTEXT_DIR/$name" + (cd "$from" && tar cf - .) | (cd "$CONTEXT_DIR/$name" && tar xf -) +done + +[ -f "$SRC/core/.context/index.md" ] && cp "$SRC/core/.context/index.md" "$CONTEXT_DIR/index.md" + +# Refresh the update tooling itself, so a fixed updater reaches consumers. +mkdir -p "$CONTEXT_DIR/bin/lib" +for tool in update.sh update.ps1 migrate.sh migrate.ps1; do + [ -f "$SRC/scripts/$tool" ] && cp "$SRC/scripts/$tool" "$CONTEXT_DIR/bin/$tool" +done +[ -f "$SRC/scripts/lib/common.sh" ] && cp "$SRC/scripts/lib/common.sh" "$CONTEXT_DIR/bin/lib/common.sh" +chmod +x "$CONTEXT_DIR/bin"/*.sh 2>/dev/null || true + +# Refresh only the managed block in AGENTS.md. +AGENTS_FILE="$TARGET_ROOT/AGENTS.md" +if [ -f "$AGENTS_FILE" ] && [ -f "$SRC/core/AGENTS.md" ]; then + if grep -q '^' ' + index($0, b) == 1 { inblock = 1 } + inblock { print } + index($0, e) == 1 { inblock = 0 } + ' "$SRC/core/AGENTS.md" \ + | sed "1s|^|" > "$block" + + awk -v b='' -v blockfile="$block" ' + index($0, b) == 1 { + inblock = 1 + while ((getline line < blockfile) > 0) { print line } + close(blockfile) + next + } + index($0, e) == 1 && inblock { inblock = 0; next } + !inblock { print } + ' "$AGENTS_FILE" > "$WORK_DIR/agents.md" + cat "$WORK_DIR/agents.md" > "$AGENTS_FILE" + echo " AGENTS.md: managed block refreshed; your content preserved." + else + echo " AGENTS.md: no managed block found — left untouched. Merge manually if required." + fi +fi + +# Rewrite the manifest with the new version and fresh hashes. +now="$(date -u '+%Y-%m-%dT%H:%M:%SZ')" +agents_json="$(sed -n 's/.*"agents"[[:space:]]*:[[:space:]]*\(\[[^]]*\]\).*/\1/p' "$MANIFEST" | head -1)" +[ -n "$agents_json" ] || agents_json='[]' +freq="$(ac_manifest_get "$MANIFEST" checkFrequency)" +[ -n "$freq" ] || freq="weekly" + +{ + printf '{\n' + printf ' "schema": 1,\n' + printf ' "version": "%s",\n' "$LATEST" + printf ' "source": "%s",\n' "$SOURCE_REPO" + printf ' "pin": "%s",\n' "$(ac_semver_major "$LATEST").x" + printf ' "checkFrequency": "%s",\n' "$freq" + printf ' "deployedAt": "%s",\n' "$now" + printf ' "agents": %s,\n' "$agents_json" + printf ' "files": {\n' + first=1 + ac_hash_context_tree "$CONTEXT_DIR" | while IFS= read -r line; do + rel="${line%% *}" + hash="${line##* }" + if [ $first -eq 1 ]; then first=0; else printf ',\n'; fi + printf ' "%s": "%s"' "$rel" "$hash" + done + printf '\n }\n' + printf '}\n' +} > "$MANIFEST" + +printf '%s\n' "$LATEST" > "$CONTEXT_DIR/VERSION" + +echo "" +echo "Updated to $LATEST." + +if [ -n "$DIVERGED" ]; then + echo "" + echo "The following base files had local edits. They have been restored to the" + echo "framework version. Re-apply your changes as overrides:" + printf '%s\n' "$DIVERGED" | while IFS= read -r rel; do + [ -n "$rel" ] || continue + echo " - $rel -> .context/overrides/$rel" + done +fi + +if [ -n "$REMOVED" ]; then + echo "" + echo "Removed upstream (no longer part of the framework):" + for rel in $REMOVED; do + echo " - $rel" + done +fi + +orphans="$(list_orphan_overrides)" +if [ -n "$orphans" ]; then + echo "" + echo "Overrides now pointing at files that no longer exist:" + printf '%s\n' "$orphans" | while IFS= read -r line; do + [ -n "$line" ] || continue + echo " - $line" + done +fi From 2a965a7d11c0c72920ad5605b29c8cbdc8e7b1d6 Mon Sep 17 00:00:00 2001 From: ldastey-dev Date: Sun, 6 Sep 2026 16:34:00 +0100 Subject: [PATCH 03/20] feat(update): add update and migrate tooling for deployed repos Adds update.sh/update.ps1 for staleness checks against the upstream VERSION and wholesale base refresh, and migrate.sh/migrate.ps1 to promote pre-2.0 local edits into the override layer. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- scripts/deploy.Tests.ps1 | 11 +- scripts/deploy.ps1 | 81 ++++++++- scripts/deploy.sh | 1 + scripts/lib/common.ps1 | 360 +++++++++++++++++++++++++++++++++++++++ scripts/migrate.ps1 | 293 +++++++++++++++++++++++++++++++ scripts/migrate.sh | 5 +- scripts/update.ps1 | 297 ++++++++++++++++++++++++++++++++ scripts/update.sh | 8 +- 8 files changed, 1050 insertions(+), 6 deletions(-) create mode 100644 scripts/lib/common.ps1 create mode 100644 scripts/migrate.ps1 create mode 100644 scripts/update.ps1 diff --git a/scripts/deploy.Tests.ps1 b/scripts/deploy.Tests.ps1 index 65d3d90..fda55d3 100644 --- a/scripts/deploy.Tests.ps1 +++ b/scripts/deploy.Tests.ps1 @@ -35,8 +35,15 @@ Describe 'deploy.ps1 (PowerShell version/platform compatibility)' { # TC5 in scripts/tests/test-deploy.ps1 exist separately. It 'has no PSScriptAnalyzer compatibility findings for Windows PowerShell 5.1 / PowerShell 7.0' -Skip:(-not $script:PSScriptAnalyzerAvailable) { Import-Module PSScriptAnalyzer - $results = Invoke-ScriptAnalyzer -Path (Join-Path $PSScriptRoot 'deploy.ps1') -Settings (Join-Path (Split-Path -Parent $PSScriptRoot) 'PSScriptAnalyzerSettings.psd1') - $results | Should -BeNullOrEmpty + $settings = Join-Path (Split-Path -Parent $PSScriptRoot) 'PSScriptAnalyzerSettings.psd1' + $targets = @('deploy.ps1', 'update.ps1', 'migrate.ps1', 'lib/common.ps1') + $findings = @() + foreach ($target in $targets) { + $path = Join-Path $PSScriptRoot $target + if (-not (Test-Path -LiteralPath $path)) { continue } + $findings += Invoke-ScriptAnalyzer -Path $path -Settings $settings + } + $findings | Should -BeNullOrEmpty } } diff --git a/scripts/deploy.ps1 b/scripts/deploy.ps1 index 396f1c8..b709f8a 100644 --- a/scripts/deploy.ps1 +++ b/scripts/deploy.ps1 @@ -550,11 +550,45 @@ if (-not (Test-Path $script:Target -PathType Container)) { # Content lives one level up: this script sits in scripts/, sources are at the repo root. $SourceRoot = Split-Path -Parent $PSScriptRoot +. (Join-Path $PSScriptRoot 'lib/common.ps1') + +$DeployVersion = '0.0.0' +$versionFile = Join-Path $SourceRoot 'VERSION' +if (Test-Path -LiteralPath $versionFile) { + $candidate = ConvertTo-AcSemVer ((Get-Content -LiteralPath $versionFile -Raw)) + if (Test-AcSemVer $candidate) { $DeployVersion = $candidate } +} + Write-Host "Deploying agent-contexts to $($script:Target)" +Write-Host " Version: $DeployVersion" Write-Host " Selected agents: $($script:EnabledAgents -join ', ')" Write-Host " Copying shared context files..." -Copy-SingleFile -Source (Join-Path $SourceRoot 'core/AGENTS.md') -Destination (Join-Path $script:Target 'AGENTS.md') + +# AGENTS.md belongs to the consumer: it carries their [CONFIGURE] sections. +# Only the managed block is ours, so refresh just that when it is present. +$agentsSrc = Join-Path $SourceRoot 'core/AGENTS.md' +$agentsDst = Join-Path $script:Target 'AGENTS.md' +if (-not (Test-Path -LiteralPath $agentsDst)) { + $parent = Split-Path -Parent $agentsDst + if (-not (Test-Path -LiteralPath $parent)) { + New-Item -ItemType Directory -Path $parent -Force | Out-Null + } + $seeded = Get-Content -LiteralPath $agentsSrc | ForEach-Object { + if ($_.StartsWith('" + } else { + $_ + } + } + Set-Content -LiteralPath $agentsDst -Value $seeded -Encoding UTF8 +} elseif (Test-AcManagedBlock -Path $agentsDst) { + Update-AcManagedBlock -Source $agentsSrc -Destination $agentsDst -Version $DeployVersion + Write-Host " AGENTS.md: refreshed managed block (your content preserved)" +} else { + Copy-SingleFile -Source $agentsSrc -Destination $agentsDst +} + Copy-DirectoryContents -Source (Join-Path $SourceRoot 'core/.context') -Destination (Join-Path $script:Target '.context') if (Test-AgentEnabled 'claude') { @@ -631,6 +665,41 @@ if ((Test-AgentEnabled 'claude') -or (Test-AgentEnabled 'copilot')) { Write-Host " Skipping skill wrapper generation (no selected agent uses skills)." } +# Consumer-owned override tree. Created here; never touched again by update. +Write-Host " Creating override layer -> $(Join-Path $script:Target '.context/overrides')" +$overrideDst = Join-Path $script:Target '.context/overrides' +if (-not (Test-Path -LiteralPath $overrideDst)) { + New-Item -ItemType Directory -Path $overrideDst -Force | Out-Null +} +Copy-DirectoryContents -Source (Join-Path $SourceRoot 'core/.context/overrides') -Destination $overrideDst + +# Update tooling, shipped into the target so it can maintain itself. +Write-Host " Installing update tooling -> $(Join-Path $script:Target '.context/bin')" +$binDst = Join-Path $script:Target '.context/bin' +$binLibDst = Join-Path $binDst 'lib' +foreach ($dir in @($binDst, $binLibDst)) { + if (-not (Test-Path -LiteralPath $dir)) { + New-Item -ItemType Directory -Path $dir -Force | Out-Null + } +} +foreach ($tool in @('update.sh', 'update.ps1', 'migrate.sh', 'migrate.ps1')) { + $toolSrc = Join-Path $SourceRoot "scripts/$tool" + if (Test-Path -LiteralPath $toolSrc) { + Copy-Item -LiteralPath $toolSrc -Destination (Join-Path $binDst $tool) -Force + } +} +foreach ($libFile in @('common.sh', 'common.ps1')) { + $libSrc = Join-Path $SourceRoot "scripts/lib/$libFile" + if (Test-Path -LiteralPath $libSrc) { + Copy-Item -LiteralPath $libSrc -Destination (Join-Path $binLibDst $libFile) -Force + } +} + +Set-Content -LiteralPath (Join-Path $script:Target '.context/VERSION') -Value $DeployVersion -Encoding UTF8 + +Write-Host " Writing manifest -> $(Join-Path $script:Target '.context/manifest.json')" +Write-AcManifest -ContextDir (Join-Path $script:Target '.context') -Version $DeployVersion -Agents $script:EnabledAgents + Write-Host "" Write-Host "Done. Next steps:" $step = 1 @@ -652,6 +721,16 @@ if (Test-AgentEnabled 'copilot') { Show-NextStep "Review $($script:Target)\.github\copilot-instructions.md" } +Show-NextStep "Never edit .context/standards|playbooks|conventions directly - use .context/overrides/ (see .context/overrides/README.md)" + +# Report staleness on every deploy. Fails open and never blocks. +$latest = Get-AcLatestVersion +if ($latest -and (Test-AcSemVerGreater $latest $DeployVersion)) { + Write-Host "" + Write-Host " Note: you deployed $DeployVersion but $latest is available upstream." + Write-Host " Pull the latest agentic-context and re-run this script." +} + if ($script:SkippedFiles.Count -gt 0) { Write-Host "" Write-Host "Skipped files (not overwritten - manual merge may be required):" diff --git a/scripts/deploy.sh b/scripts/deploy.sh index 70bf3d4..ff97c3e 100755 --- a/scripts/deploy.sh +++ b/scripts/deploy.sh @@ -777,6 +777,7 @@ for tool in update.sh update.ps1 migrate.sh migrate.ps1; do done mkdir -p "$TARGET/.context/bin/lib" cp "$SOURCE_ROOT/scripts/lib/common.sh" "$TARGET/.context/bin/lib/common.sh" +[[ -f "$SOURCE_ROOT/scripts/lib/common.ps1" ]] && cp "$SOURCE_ROOT/scripts/lib/common.ps1" "$TARGET/.context/bin/lib/common.ps1" chmod +x "$TARGET/.context/bin"/*.sh 2>/dev/null || true printf '%s\n' "$DEPLOY_VERSION" > "$TARGET/.context/VERSION" diff --git a/scripts/lib/common.ps1 b/scripts/lib/common.ps1 new file mode 100644 index 0000000..406d5c2 --- /dev/null +++ b/scripts/lib/common.ps1 @@ -0,0 +1,360 @@ +# scripts/lib/common.ps1 — shared helpers for deploy, update and migrate. +# +# Portability: must run on Windows PowerShell 5.1 as well as PowerShell 7+. +# No PowerShell 7-only syntax (no ternaries, no ??, no -Parallel). +# +# Dot-source this file; do not execute it. + +$script:AcSourceRepo = 'ldastey-dev/agentic-context' +$script:AcRawBase = 'https://raw.githubusercontent.com' +$script:AcWebBase = 'https://github.com' +$script:AcTimeoutSec = 5 + +# --- hashing --------------------------------------------------------------- + +function Get-AcFileHash { + param([Parameter(Mandatory)][string]$Path) + $hash = Get-FileHash -Path $Path -Algorithm SHA256 + return $hash.Hash.ToLowerInvariant() +} + +# --- semver ---------------------------------------------------------------- + +function ConvertTo-AcSemVer { + param([string]$Version) + if ($null -eq $Version) { return '' } + return ($Version -replace '\s', '') -replace '^[vV]', '' +} + +function Test-AcSemVer { + param([string]$Version) + $v = ConvertTo-AcSemVer $Version + return ($v -match '^\d+\.\d+\.\d+$') +} + +function Get-AcSemVerPart { + param([string]$Version, [ValidateSet('Major', 'Minor', 'Patch')][string]$Part) + $v = ConvertTo-AcSemVer $Version + $bits = $v.Split('.') + switch ($Part) { + 'Major' { return [int]$bits[0] } + 'Minor' { return [int]$bits[1] } + 'Patch' { return [int]$bits[2] } + } +} + +# Returns $true when A is strictly greater than B. Compares numerically, so +# 1.10.0 correctly exceeds 1.9.0 where a string comparison would not. +function Test-AcSemVerGreater { + param([string]$A, [string]$B) + if (-not (Test-AcSemVer $A)) { return $false } + if (-not (Test-AcSemVer $B)) { return $false } + + $av = ConvertTo-AcSemVer $A + $bv = ConvertTo-AcSemVer $B + $ap = $av.Split('.') + $bp = $bv.Split('.') + + for ($i = 0; $i -lt 3; $i++) { + $x = [int]$ap[$i] + $y = [int]$bp[$i] + if ($x -gt $y) { return $true } + if ($x -lt $y) { return $false } + } + return $false +} + +function Step-AcSemVer { + param( + [Parameter(Mandatory)][string]$Version, + [Parameter(Mandatory)][ValidateSet('major', 'minor', 'patch')][string]$Kind + ) + if (-not (Test-AcSemVer $Version)) { throw "Invalid version '$Version'" } + $x = Get-AcSemVerPart $Version 'Major' + $y = Get-AcSemVerPart $Version 'Minor' + $z = Get-AcSemVerPart $Version 'Patch' + + switch ($Kind) { + 'major' { $x++; $y = 0; $z = 0 } + 'minor' { $y++; $z = 0 } + 'patch' { $z++ } + } + return "$x.$y.$z" +} + +# Pin forms: '' or '*' (any), '2.x' / '2' (major line), or exact '2.3.1'. +function Test-AcPinSatisfied { + param([string]$Version, [string]$Pin) + + $v = ConvertTo-AcSemVer $Version + $p = ConvertTo-AcSemVer $Pin + + if ([string]::IsNullOrEmpty($p) -or $p -eq '*') { return $true } + + if ($p -match '^(\d+)\.[xX]$') { + return ((Get-AcSemVerPart $v 'Major') -eq [int]$Matches[1]) + } + if ($p -match '^\d+\.\d+\.\d+$') { + return ($v -eq $p) + } + if ($p -match '^\d+$') { + return ((Get-AcSemVerPart $v 'Major') -eq [int]$p) + } + return $false +} + +# --- network --------------------------------------------------------------- +# +# Every network helper FAILS OPEN: on any error it returns $null and never +# throws. Callers must treat $null as "up to date" and never block. + +function Get-AcLatestVersionRaw { + param([string]$Repo = $script:AcSourceRepo, [string]$Branch = 'main') + try { + $url = "$script:AcRawBase/$Repo/$Branch/VERSION" + $resp = Invoke-WebRequest -Uri $url -UseBasicParsing -TimeoutSec $script:AcTimeoutSec -ErrorAction Stop + $v = ConvertTo-AcSemVer ([string]$resp.Content) + if (Test-AcSemVer $v) { return $v } + return $null + } catch { + return $null + } +} + +function Get-AcLatestVersionRelease { + param([string]$Repo = $script:AcSourceRepo) + # Invoke-WebRequest handles redirects inconsistently across versions: + # Windows PowerShell 5.1 returns the 302 response when redirection is + # disabled, whereas PowerShell 7 raises. HttpWebRequest behaves identically + # on both, so read the Location header directly. + try { + $url = "$script:AcWebBase/$Repo/releases/latest" + $req = [System.Net.HttpWebRequest]::Create($url) + $req.AllowAutoRedirect = $false + $req.Method = 'HEAD' + $req.Timeout = $script:AcTimeoutSec * 1000 + $req.UserAgent = 'agentic-context-update' + + $loc = $null + try { + $resp = $req.GetResponse() + $loc = $resp.Headers['Location'] + $resp.Close() + } catch [System.Net.WebException] { + $wr = $_.Exception.Response + if ($wr) { + $loc = $wr.Headers['Location'] + $wr.Close() + } + } + + if ([string]::IsNullOrEmpty($loc)) { return $null } + $tag = $loc.Substring($loc.LastIndexOf('/') + 1) + $v = ConvertTo-AcSemVer $tag + if (Test-AcSemVer $v) { return $v } + return $null + } catch { + return $null + } +} + +function Get-AcLatestVersion { + param([string]$Repo = $script:AcSourceRepo) + $v = Get-AcLatestVersionRaw -Repo $Repo + if ($v) { return $v } + return Get-AcLatestVersionRelease -Repo $Repo +} + +# --- manifest -------------------------------------------------------------- + +function Get-AcManifest { + param([Parameter(Mandatory)][string]$Path) + if (-not (Test-Path -LiteralPath $Path)) { return $null } + try { + return (Get-Content -LiteralPath $Path -Raw | ConvertFrom-Json) + } catch { + return $null + } +} + +# Hash every base .md file in a deployed .context tree. +# Returns an ordered hashtable of relative path -> sha256. +# Overrides and bin/ are excluded: overrides belong to the consumer, and bin/ +# is refreshed like any other base file but is not part of the content baseline. +function Get-AcContextHashes { + param([Parameter(Mandatory)][string]$ContextDir) + + $result = [ordered]@{} + if (-not (Test-Path -LiteralPath $ContextDir)) { return $result } + + $full = (Resolve-Path -LiteralPath $ContextDir).Path + $sep = [System.IO.Path]::DirectorySeparatorChar + + $files = Get-ChildItem -LiteralPath $full -Recurse -File -Filter '*.md' -ErrorAction SilentlyContinue | + Where-Object { + $rel = $_.FullName.Substring($full.Length).TrimStart($sep) + $relNorm = $rel.Replace('\', '/') + (-not $relNorm.StartsWith('overrides/')) -and (-not $relNorm.StartsWith('bin/')) + } | + Sort-Object { $_.FullName.Substring($full.Length).TrimStart($sep).Replace('\', '/') } + + foreach ($f in $files) { + $rel = $f.FullName.Substring($full.Length).TrimStart($sep).Replace('\', '/') + $result[$rel] = Get-AcFileHash -Path $f.FullName + } + return $result +} + +# Write .context/manifest.json describing a deployment. +function Write-AcManifest { + param( + [Parameter(Mandatory)][string]$ContextDir, + [Parameter(Mandatory)][string]$Version, + [string[]]$Agents = @(), + [string]$SourceRepo = $script:AcSourceRepo, + [string]$CheckFrequency = 'weekly' + ) + + if (-not (Test-Path -LiteralPath $ContextDir)) { + New-Item -ItemType Directory -Path $ContextDir -Force | Out-Null + } + + $hashes = Get-AcContextHashes -ContextDir $ContextDir + $files = [ordered]@{} + foreach ($k in $hashes.Keys) { $files[$k] = $hashes[$k] } + + $manifest = [ordered]@{ + schema = 1 + version = $Version + source = $SourceRepo + pin = "$(Get-AcSemVerPart $Version 'Major').x" + checkFrequency = $CheckFrequency + deployedAt = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ') + agents = @($Agents) + files = $files + } + + $json = $manifest | ConvertTo-Json -Depth 5 + Set-Content -LiteralPath (Join-Path $ContextDir 'manifest.json') -Value $json -Encoding UTF8 +} + +# --- managed block --------------------------------------------------------- + +$script:AcBeginMarker = '' + +function Test-AcManagedBlock { + param([Parameter(Mandatory)][string]$Path) + if (-not (Test-Path -LiteralPath $Path)) { return $false } + $lines = Get-Content -LiteralPath $Path + $hasBegin = $false + $hasEnd = $false + foreach ($line in $lines) { + if ($line.StartsWith($script:AcBeginMarker)) { $hasBegin = $true } + if ($line.StartsWith($script:AcEndMarker)) { $hasEnd = $true } + } + return ($hasBegin -and $hasEnd) +} + +function Get-AcManagedBlock { + param([Parameter(Mandatory)][string]$Path, [string]$Version) + + $out = New-Object System.Collections.Generic.List[string] + $inBlock = $false + foreach ($line in (Get-Content -LiteralPath $Path)) { + if ($line.StartsWith($script:AcBeginMarker)) { + $inBlock = $true + if ($Version) { + $out.Add("$script:AcBeginMarker $Version -->") + } else { + $out.Add($line) + } + continue + } + if ($line.StartsWith($script:AcEndMarker)) { + if ($inBlock) { $out.Add($line) } + $inBlock = $false + continue + } + if ($inBlock) { $out.Add($line) } + } + return $out +} + +# Replace the managed block in $Destination with the one from $Source, +# preserving everything outside the markers exactly. +function Update-AcManagedBlock { + param( + [Parameter(Mandatory)][string]$Source, + [Parameter(Mandatory)][string]$Destination, + [Parameter(Mandatory)][string]$Version + ) + + $block = Get-AcManagedBlock -Path $Source -Version $Version + $out = New-Object System.Collections.Generic.List[string] + $inBlock = $false + + foreach ($line in (Get-Content -LiteralPath $Destination)) { + if ($line.StartsWith($script:AcBeginMarker)) { + $inBlock = $true + foreach ($b in $block) { $out.Add($b) } + continue + } + if ($line.StartsWith($script:AcEndMarker)) { + if ($inBlock) { $inBlock = $false; continue } + } + if (-not $inBlock) { $out.Add($line) } + } + + Set-Content -LiteralPath $Destination -Value $out -Encoding UTF8 +} + +# List base files whose current hash differs from the manifest record. +function Get-AcDivergedFiles { + param( + [Parameter(Mandatory)][string]$ContextDir, + [Parameter(Mandatory)][string]$ManifestPath + ) + + $result = New-Object System.Collections.Generic.List[string] + $manifest = Get-AcManifest -Path $ManifestPath + if (-not $manifest -or -not $manifest.files) { return $result } + + $current = Get-AcContextHashes -ContextDir $ContextDir + foreach ($rel in $current.Keys) { + $recorded = $manifest.files.$rel + if ($recorded -and $recorded -ne $current[$rel]) { + $result.Add($rel) + } + } + return $result +} + +# List overrides whose declared target no longer exists in the base tree. +function Get-AcOrphanOverrides { + param([Parameter(Mandatory)][string]$ContextDir) + + $result = New-Object System.Collections.Generic.List[string] + $overrideDir = Join-Path $ContextDir 'overrides' + if (-not (Test-Path -LiteralPath $overrideDir)) { return $result } + + $files = Get-ChildItem -LiteralPath $overrideDir -Recurse -File -Filter '*.md' -ErrorAction SilentlyContinue + foreach ($f in $files) { + if ($f.Name -eq 'README.md') { continue } + $target = $null + foreach ($line in (Get-Content -LiteralPath $f.FullName)) { + if ($line -match '^overrides:\s*(.+)$') { + $target = $Matches[1].Trim() + break + } + } + if (-not $target) { continue } + if (-not (Test-Path -LiteralPath (Join-Path $ContextDir $target))) { + $sep = [System.IO.Path]::DirectorySeparatorChar + $full = (Resolve-Path -LiteralPath $overrideDir).Path + $rel = $f.FullName.Substring($full.Length).TrimStart($sep).Replace('\', '/') + $result.Add("$rel -> $target") + } + } + return $result +} diff --git a/scripts/migrate.ps1 b/scripts/migrate.ps1 new file mode 100644 index 0000000..ae67715 --- /dev/null +++ b/scripts/migrate.ps1 @@ -0,0 +1,293 @@ +# migrate.ps1 - upgrade a pre-2.0 agentic-context deployment to the override model. +# +# Pre-2.0 deployments have no .context/manifest.json and no .context/overrides/. +# Consumers may have edited standards and playbooks directly. This script finds +# those edits by comparing against a published baseline for the version they are +# on, and promotes each edited file into .context/overrides/ so their intent is +# preserved before base content is restored. +# +# Safe by default: dry-run unless -Apply is given, and refuses to run on a dirty +# git tree so every change is reviewable. +# +# Portability: Windows PowerShell 5.1 and PowerShell 7+. + +[CmdletBinding()] +param( + [Parameter(Position = 0)] + [string]$Target = '', + [switch]$Apply, + [string]$From = '1.0.0', + [string]$Baseline = '' +) + +$ErrorActionPreference = 'Stop' + +$libPath = Join-Path $PSScriptRoot 'lib/common.ps1' +if (-not (Test-Path -LiteralPath $libPath)) { + Write-Error "Cannot locate lib/common.ps1 next to migrate.ps1" + exit 1 +} +. $libPath + +if ([string]::IsNullOrEmpty($Target)) { $Target = (Get-Location).Path } +if (-not (Test-Path -LiteralPath $Target)) { + Write-Error "No such directory: $Target" + exit 1 +} +$Target = (Resolve-Path -LiteralPath $Target).Path + +$ContextDir = Join-Path $Target '.context' +$SourceRoot = Split-Path -Parent $PSScriptRoot + +if ([string]::IsNullOrEmpty($Baseline)) { + $Baseline = Join-Path $SourceRoot "scripts/baselines/$From.sha256" +} + +# --- preconditions --------------------------------------------------------- + +if (-not (Test-Path -LiteralPath $ContextDir)) { + Write-Error "$Target has no .context/ directory - nothing to migrate." + exit 1 +} + +$manifestPath = Join-Path $ContextDir 'manifest.json' +if (Test-Path -LiteralPath $manifestPath) { + $existing = Get-AcManifest -Path $manifestPath + $ver = 'unknown' + if ($existing -and $existing.version) { $ver = $existing.version } + Write-Host "This deployment already has a manifest (version $ver)." + Write-Host "Migration is only for pre-2.0 deployments. Use update.ps1 -Apply instead." + exit 0 +} + +if (-not (Test-Path -LiteralPath $Baseline)) { + Write-Error "Baseline not found: $Baseline. Pass -Baseline explicitly, or -From with a published version." + exit 1 +} + +# A dirty tree makes the migration unreviewable, so refuse outright. +$isGit = $false +try { + $null = & git -C $Target rev-parse --git-dir 2>$null + if ($LASTEXITCODE -eq 0) { $isGit = $true } +} catch { + $isGit = $false +} + +if ($isGit) { + $dirty = & git -C $Target status --porcelain 2>$null + if ($dirty) { + Write-Error "$Target has uncommitted changes. Commit or stash first so this migration can be reviewed as a diff." + exit 1 + } +} else { + Write-Warning "$Target is not a git repository. Changes will not be reviewable." + if ($Apply) { + $reply = Read-Host "Continue anyway? [y/N]" + if ($reply -notmatch '^[yY]') { + Write-Host "Aborted." + exit 1 + } + } +} + +if ($Apply) { + Write-Host "Migrating $Target (from $From)" +} else { + Write-Host "DRY RUN - no files will be written. Re-run with -Apply to commit." + Write-Host "Analysing $Target (from $From)" +} +Write-Host "" + +# --- classify -------------------------------------------------------------- + +$baselineMap = @{} +foreach ($line in (Get-Content -LiteralPath $Baseline)) { + if ($line -match '^(\S+)\s+([a-f0-9]+)$') { + $baselineMap[$Matches[1]] = $Matches[2] + } +} + +$diverged = New-Object System.Collections.Generic.List[string] +$added = New-Object System.Collections.Generic.List[string] + +foreach ($area in @('standards', 'playbooks', 'conventions')) { + $areaDir = Join-Path $ContextDir $area + if (-not (Test-Path -LiteralPath $areaDir)) { continue } + + $full = (Resolve-Path -LiteralPath $areaDir).Path + $sep = [System.IO.Path]::DirectorySeparatorChar + $files = Get-ChildItem -LiteralPath $full -Recurse -File -Filter '*.md' -ErrorAction SilentlyContinue | + Sort-Object FullName + + foreach ($f in $files) { + $rel = $area + '/' + $f.FullName.Substring($full.Length).TrimStart($sep).Replace('\', '/') + $actual = Get-AcFileHash -Path $f.FullName + if ($baselineMap.ContainsKey($rel)) { + if ($baselineMap[$rel] -ne $actual) { $diverged.Add($rel) } + } else { + # Not in the baseline at all: a file the consumer added themselves. + $added.Add($rel) + } + } +} + +if ($diverged.Count -eq 0 -and $added.Count -eq 0) { + Write-Host "No local modifications detected - this deployment is pristine." +} else { + if ($diverged.Count -gt 0) { + Write-Host "Modified framework files ($($diverged.Count)) - will become overrides:" + foreach ($rel in $diverged) { Write-Host " $rel -> .context/overrides/$rel" } + Write-Host "" + } + if ($added.Count -gt 0) { + Write-Host "Files you added ($($added.Count)) - will move to overrides as standalone additions:" + foreach ($rel in $added) { Write-Host " $rel -> .context/overrides/$rel" } + Write-Host "" + } +} + +if (-not $Apply) { + Write-Host "Nothing written. Re-run with -Apply to perform the migration." + exit 0 +} + +# --- apply ----------------------------------------------------------------- + +$overrideRoot = Join-Path $ContextDir 'overrides' +if (-not (Test-Path -LiteralPath $overrideRoot)) { + New-Item -ItemType Directory -Path $overrideRoot -Force | Out-Null +} + +function Move-AcToOverride { + param([string]$Rel, [string]$Mode) + + $src = Join-Path $ContextDir $Rel + $dst = Join-Path $overrideRoot $Rel + if (-not (Test-Path -LiteralPath $src)) { return } + + $parent = Split-Path -Parent $dst + if (-not (Test-Path -LiteralPath $parent)) { + New-Item -ItemType Directory -Path $parent -Force | Out-Null + } + + if ($Mode -eq 'replace') { + $header = @( + '---', + "overrides: $Rel", + 'mode: replace', + '---', + '', + '', + '' + ) + $body = Get-Content -LiteralPath $src + Set-Content -LiteralPath $dst -Value (@($header) + @($body)) -Encoding UTF8 + } else { + Copy-Item -LiteralPath $src -Destination $dst -Force + } +} + +foreach ($rel in $diverged) { Move-AcToOverride -Rel $rel -Mode 'replace' } +foreach ($rel in $added) { + Move-AcToOverride -Rel $rel -Mode 'standalone' + Remove-Item -LiteralPath (Join-Path $ContextDir $rel) -Force -ErrorAction SilentlyContinue +} + +Write-Host "Restoring base content from $SourceRoot ..." +$areas = @( + @{ Name = 'standards'; From = (Join-Path $SourceRoot 'standards') }, + @{ Name = 'playbooks'; From = (Join-Path $SourceRoot 'playbooks') }, + @{ Name = 'conventions'; From = (Join-Path $SourceRoot 'core/.context/conventions') } +) +foreach ($area in $areas) { + if (-not (Test-Path -LiteralPath $area.From)) { continue } + $dest = Join-Path $ContextDir $area.Name + if (Test-Path -LiteralPath $dest) { Remove-Item -LiteralPath $dest -Recurse -Force } + New-Item -ItemType Directory -Path $dest -Force | Out-Null + Copy-Item -Path (Join-Path $area.From '*') -Destination $dest -Recurse -Force +} + +$srcIndex = Join-Path $SourceRoot 'core/.context/index.md' +if (Test-Path -LiteralPath $srcIndex) { + Copy-Item -LiteralPath $srcIndex -Destination (Join-Path $ContextDir 'index.md') -Force +} +$srcOverrideReadme = Join-Path $SourceRoot 'core/.context/overrides/README.md' +if (Test-Path -LiteralPath $srcOverrideReadme) { + Copy-Item -LiteralPath $srcOverrideReadme -Destination (Join-Path $overrideRoot 'README.md') -Force +} + +$binDir = Join-Path $ContextDir 'bin' +$binLib = Join-Path $binDir 'lib' +foreach ($d in @($binDir, $binLib)) { + if (-not (Test-Path -LiteralPath $d)) { New-Item -ItemType Directory -Path $d -Force | Out-Null } +} +foreach ($tool in @('update.sh', 'update.ps1', 'migrate.sh', 'migrate.ps1')) { + $toolSrc = Join-Path $SourceRoot "scripts/$tool" + if (Test-Path -LiteralPath $toolSrc) { + Copy-Item -LiteralPath $toolSrc -Destination (Join-Path $binDir $tool) -Force + } +} +foreach ($libFile in @('common.sh', 'common.ps1')) { + $libSrc = Join-Path $SourceRoot "scripts/lib/$libFile" + if (Test-Path -LiteralPath $libSrc) { + Copy-Item -LiteralPath $libSrc -Destination (Join-Path $binLib $libFile) -Force + } +} + +# AGENTS.md: prepend the managed block, leave everything the consumer has intact. +# Identifying which of their existing regions were framework-authored is guesswork, +# so this deliberately does not try - a human reviews one file instead. +$NewVersion = '0.0.0' +$versionFile = Join-Path $SourceRoot 'VERSION' +if (Test-Path -LiteralPath $versionFile) { + $candidate = ConvertTo-AcSemVer ((Get-Content -LiteralPath $versionFile -Raw)) + if (Test-AcSemVer $candidate) { $NewVersion = $candidate } +} + +$agentsFile = Join-Path $Target 'AGENTS.md' +$agentsSrc = Join-Path $SourceRoot 'core/AGENTS.md' + +if (Test-Path -LiteralPath $agentsSrc) { + if (-not (Test-Path -LiteralPath $agentsFile)) { + $seeded = Get-Content -LiteralPath $agentsSrc | ForEach-Object { + if ($_.StartsWith('" + } else { + $_ + } + } + Set-Content -LiteralPath $agentsFile -Value $seeded -Encoding UTF8 + } elseif (Test-AcManagedBlock -Path $agentsFile) { + Write-Host " AGENTS.md already has a managed block - left as is." + } else { + $block = Get-AcManagedBlock -Path $agentsSrc -Version $NewVersion + $notice = @( + '', + '---', + '', + '', + '' + ) + $original = Get-Content -LiteralPath $agentsFile + Set-Content -LiteralPath $agentsFile -Value (@($block) + $notice + @($original)) -Encoding UTF8 + Write-Host " AGENTS.md: managed block prepended; your original content kept below for review." + } +} + +# Write the manifest last, so its hashes reflect the final state. +Write-AcManifest -ContextDir $ContextDir -Version $NewVersion -Agents @() +Set-Content -LiteralPath (Join-Path $ContextDir 'VERSION') -Value $NewVersion -Encoding UTF8 + +Write-Host "" +Write-Host "Migration complete. Now on $NewVersion." +Write-Host "" +Write-Host "Review before committing:" +Write-Host " git -C $Target diff --stat" +Write-Host " $Target/AGENTS.md - remove sections duplicated by the managed block" +Write-Host " $Target/.context/overrides/ - convert 'mode: replace' to 'mode: extend' where you can" diff --git a/scripts/migrate.sh b/scripts/migrate.sh index 33cb5e6..dd85667 100644 --- a/scripts/migrate.sh +++ b/scripts/migrate.sh @@ -184,7 +184,7 @@ promote() { printf 'overrides: %s\n' "$rel" printf 'mode: replace\n' printf -- '---\n\n' - printf '\n\n' @@ -224,6 +224,7 @@ for tool in update.sh update.ps1 migrate.sh migrate.ps1; do [ -f "$SOURCE_ROOT/scripts/$tool" ] && cp "$SOURCE_ROOT/scripts/$tool" "$CONTEXT_DIR/bin/$tool" done cp "$SOURCE_ROOT/scripts/lib/common.sh" "$CONTEXT_DIR/bin/lib/common.sh" +[ -f "$SOURCE_ROOT/scripts/lib/common.ps1" ] && cp "$SOURCE_ROOT/scripts/lib/common.ps1" "$CONTEXT_DIR/bin/lib/common.ps1" chmod +x "$CONTEXT_DIR/bin"/*.sh 2>/dev/null || true # AGENTS.md: prepend the managed block, leave everything the consumer has intact. @@ -248,7 +249,7 @@ if [ -f "$SOURCE_ROOT/core/AGENTS.md" ]; then ' "$SOURCE_ROOT/core/AGENTS.md" \ | sed "1s|^|" printf '\n---\n\n' - printf '\n\n' cat "$AGENTS_FILE" diff --git a/scripts/update.ps1 b/scripts/update.ps1 new file mode 100644 index 0000000..eebc4f5 --- /dev/null +++ b/scripts/update.ps1 @@ -0,0 +1,297 @@ +# update.ps1 - check for and apply agentic-context updates. +# +# Deployed into target repositories as .context/bin/update.ps1, and also usable +# from the library itself as scripts/update.ps1. +# +# Usage: +# .context\bin\update.ps1 -Check Report status only. Never writes. Never blocks. +# .context\bin\update.ps1 -Apply Fetch the latest version and update base content. +# .context\bin\update.ps1 -Status Show local state without any network call. +# +# Portability: Windows PowerShell 5.1 and PowerShell 7+. + +[CmdletBinding()] +param( + [switch]$Check, + [switch]$Apply, + [switch]$Status, + [switch]$Force, + [switch]$Quiet +) + +$ErrorActionPreference = 'Stop' + +$libPath = Join-Path $PSScriptRoot 'lib/common.ps1' +if (-not (Test-Path -LiteralPath $libPath)) { + Write-Error "Cannot locate lib/common.ps1 next to update.ps1" + exit 1 +} +. $libPath + +$mode = 'check' +if ($Status) { $mode = 'status' } +if ($Apply) { $mode = 'apply' } +if ($Check) { $mode = 'check' } + +# --- locate the deployed .context directory -------------------------------- + +function Find-AcContextDir { + $candidate = Join-Path (Split-Path -Parent $PSScriptRoot) 'manifest.json' + if (Test-Path -LiteralPath $candidate) { + return (Resolve-Path -LiteralPath (Split-Path -Parent $PSScriptRoot)).Path + } + + $dir = (Get-Location).Path + while ($dir) { + $probe = Join-Path $dir '.context/manifest.json' + if (Test-Path -LiteralPath $probe) { + return (Resolve-Path -LiteralPath (Join-Path $dir '.context')).Path + } + $parent = Split-Path -Parent $dir + if ($parent -eq $dir) { break } + $dir = $parent + } + return $null +} + +$ContextDir = Find-AcContextDir +if (-not $ContextDir) { + Write-Error "No deployed .context/manifest.json found. Run this from a repository where agentic-context is deployed." + exit 1 +} + +$TargetRoot = (Resolve-Path -LiteralPath (Split-Path -Parent $ContextDir)).Path +$ManifestPath = Join-Path $ContextDir 'manifest.json' +$StampPath = Join-Path $ContextDir '.last-update-check' + +$manifest = Get-AcManifest -Path $ManifestPath +$LocalVersion = '0.0.0' +$SourceRepo = $script:AcSourceRepo +$Pin = '' +$Freq = 'weekly' + +if ($manifest) { + if ($manifest.version) { $LocalVersion = [string]$manifest.version } + if ($manifest.source) { $SourceRepo = [string]$manifest.source } + if ($manifest.pin) { $Pin = [string]$manifest.pin } + if ($manifest.checkFrequency) { $Freq = [string]$manifest.checkFrequency } +} + +function Update-AcStamp { + try { + Set-Content -LiteralPath $StampPath -Value ((Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ')) -Encoding UTF8 + } catch { + # A stamp we cannot write must never stop the caller. + } +} + +function Show-AcLocalState { + $overridesDir = Join-Path $ContextDir 'overrides' + if (Test-Path -LiteralPath $overridesDir) { + $overrideFiles = @(Get-ChildItem -LiteralPath $overridesDir -Recurse -File -Filter '*.md' -ErrorAction SilentlyContinue | + Where-Object { $_.Name -ne 'README.md' }) + if ($overrideFiles.Count -gt 0) { + Write-Host (" {0} override file(s) active." -f $overrideFiles.Count) + } + } + + $diverged = Get-AcDivergedFiles -ContextDir $ContextDir -ManifestPath $ManifestPath + if ($diverged.Count -gt 0) { + Write-Host "" + Write-Host "Locally modified base files (these will be restored on update):" + foreach ($rel in $diverged) { + Write-Host " - $rel" + Write-Host " move your change to .context/overrides/$rel" + } + } + + $orphans = Get-AcOrphanOverrides -ContextDir $ContextDir + if ($orphans.Count -gt 0) { + Write-Host "" + Write-Host "Overrides pointing at files that no longer exist:" + foreach ($o in $orphans) { Write-Host " - $o" } + } +} + +if ($mode -eq 'status') { + $pinText = $Pin + if ([string]::IsNullOrEmpty($pinText)) { $pinText = 'none' } + Write-Host "agentic-context $LocalVersion (source: $SourceRepo, pin: $pinText)" + Show-AcLocalState + exit 0 +} + +# Both check and apply need the upstream version. Fail open. +$Latest = Get-AcLatestVersion -Repo $SourceRepo + +if (-not $Latest) { + Update-AcStamp + if ($Quiet) { exit 0 } + Write-Host "agentic-context $LocalVersion - update check unavailable (offline or unreachable)." + exit 0 +} + +Update-AcStamp + +if (-not (Test-AcSemVerGreater $Latest $LocalVersion)) { + if ($Quiet) { exit 0 } + Write-Host "agentic-context $LocalVersion is up to date." + Show-AcLocalState + exit 0 +} + +$pinOk = $false +if ($Force -or (Test-AcPinSatisfied -Version $Latest -Pin $Pin)) { $pinOk = $true } + +if ($mode -eq 'check') { + if ($pinOk) { + Write-Host "agentic-context update available: $LocalVersion -> $Latest (run .context/bin/update.ps1 -Apply)" + } else { + Write-Host "agentic-context $Latest is available but outside your pin '$Pin'. See MIGRATIONS.md, then use -Force." + } + if ($Quiet) { exit 0 } + Show-AcLocalState + exit 0 +} + +# --- apply ----------------------------------------------------------------- + +if (-not $pinOk) { + Write-Error "Refusing to update: $Latest is outside the pin '$Pin'. This is a major upgrade. Read MIGRATIONS.md, then re-run with -Force." + exit 1 +} + +Write-Host "Updating agentic-context $LocalVersion -> $Latest" + +$WorkDir = Join-Path ([System.IO.Path]::GetTempPath()) ("ac-update-" + [Guid]::NewGuid().ToString('N')) +New-Item -ItemType Directory -Path $WorkDir -Force | Out-Null + +try { + $tarball = Join-Path $WorkDir 'src.tar.gz' + $url = "$script:AcWebBase/$SourceRepo/archive/refs/tags/v$Latest.tar.gz" + try { + Invoke-WebRequest -Uri $url -OutFile $tarball -UseBasicParsing -TimeoutSec 60 -ErrorAction Stop + } catch { + Write-Error "Could not download v$Latest." + exit 1 + } + + # tar is present on Windows 10 1803+, and on macOS and Linux. + & tar -xzf $tarball -C $WorkDir + if ($LASTEXITCODE -ne 0) { + Write-Error "Could not extract archive." + exit 1 + } + + $src = Get-ChildItem -LiteralPath $WorkDir -Directory | Select-Object -First 1 + if (-not $src) { + Write-Error "Unexpected archive layout." + exit 1 + } + $srcPath = $src.FullName + + # Record divergence before overwriting, so it can be reported afterwards. + $diverged = Get-AcDivergedFiles -ContextDir $ContextDir -ManifestPath $ManifestPath + + # Detect removals: base files present locally but absent upstream. + $removed = New-Object System.Collections.Generic.List[string] + $currentHashes = Get-AcContextHashes -ContextDir $ContextDir + foreach ($rel in $currentHashes.Keys) { + $probe = $null + if ($rel.StartsWith('standards/') -or $rel.StartsWith('playbooks/')) { + $probe = Join-Path $srcPath $rel + } elseif ($rel.StartsWith('conventions/')) { + $probe = Join-Path $srcPath "core/.context/$rel" + } + if ($probe -and -not (Test-Path -LiteralPath $probe)) { + $removed.Add($rel) + } + } + + # Replace base trees wholesale. Overrides are untouched by construction. + $areas = @( + @{ Name = 'standards'; From = (Join-Path $srcPath 'standards') }, + @{ Name = 'playbooks'; From = (Join-Path $srcPath 'playbooks') }, + @{ Name = 'conventions'; From = (Join-Path $srcPath 'core/.context/conventions') } + ) + foreach ($area in $areas) { + if (-not (Test-Path -LiteralPath $area.From)) { continue } + $dest = Join-Path $ContextDir $area.Name + if (Test-Path -LiteralPath $dest) { Remove-Item -LiteralPath $dest -Recurse -Force } + New-Item -ItemType Directory -Path $dest -Force | Out-Null + Copy-Item -Path (Join-Path $area.From '*') -Destination $dest -Recurse -Force + } + + $srcIndex = Join-Path $srcPath 'core/.context/index.md' + if (Test-Path -LiteralPath $srcIndex) { + Copy-Item -LiteralPath $srcIndex -Destination (Join-Path $ContextDir 'index.md') -Force + } + + # Refresh the update tooling itself, so a fixed updater reaches consumers. + $binDir = Join-Path $ContextDir 'bin' + $binLib = Join-Path $binDir 'lib' + foreach ($d in @($binDir, $binLib)) { + if (-not (Test-Path -LiteralPath $d)) { New-Item -ItemType Directory -Path $d -Force | Out-Null } + } + foreach ($tool in @('update.sh', 'update.ps1', 'migrate.sh', 'migrate.ps1')) { + $toolSrc = Join-Path $srcPath "scripts/$tool" + if (Test-Path -LiteralPath $toolSrc) { + Copy-Item -LiteralPath $toolSrc -Destination (Join-Path $binDir $tool) -Force + } + } + foreach ($libFile in @('common.sh', 'common.ps1')) { + $libSrc = Join-Path $srcPath "scripts/lib/$libFile" + if (Test-Path -LiteralPath $libSrc) { + Copy-Item -LiteralPath $libSrc -Destination (Join-Path $binLib $libFile) -Force + } + } + + # Refresh only the managed block in AGENTS.md. + $agentsFile = Join-Path $TargetRoot 'AGENTS.md' + $agentsSrc = Join-Path $srcPath 'core/AGENTS.md' + if ((Test-Path -LiteralPath $agentsFile) -and (Test-Path -LiteralPath $agentsSrc)) { + if (Test-AcManagedBlock -Path $agentsFile) { + Update-AcManagedBlock -Source $agentsSrc -Destination $agentsFile -Version $Latest + Write-Host " AGENTS.md: managed block refreshed; your content preserved." + } else { + Write-Host " AGENTS.md: no managed block found - left untouched. Merge manually if required." + } + } + + $agents = @() + if ($manifest -and $manifest.agents) { $agents = @($manifest.agents) } + + Write-AcManifest -ContextDir $ContextDir -Version $Latest -Agents $agents ` + -SourceRepo $SourceRepo -CheckFrequency $Freq + + Set-Content -LiteralPath (Join-Path $ContextDir 'VERSION') -Value $Latest -Encoding UTF8 + + Write-Host "" + Write-Host "Updated to $Latest." + + if ($diverged.Count -gt 0) { + Write-Host "" + Write-Host "The following base files had local edits. They have been restored to the" + Write-Host "framework version. Re-apply your changes as overrides:" + foreach ($rel in $diverged) { + Write-Host " - $rel -> .context/overrides/$rel" + } + } + + if ($removed.Count -gt 0) { + Write-Host "" + Write-Host "Removed upstream (no longer part of the framework):" + foreach ($rel in $removed) { Write-Host " - $rel" } + } + + $orphans = Get-AcOrphanOverrides -ContextDir $ContextDir + if ($orphans.Count -gt 0) { + Write-Host "" + Write-Host "Overrides now pointing at files that no longer exist:" + foreach ($o in $orphans) { Write-Host " - $o" } + } +} finally { + if (Test-Path -LiteralPath $WorkDir) { + Remove-Item -LiteralPath $WorkDir -Recurse -Force -ErrorAction SilentlyContinue + } +} diff --git a/scripts/update.sh b/scripts/update.sh index 65e93f5..0dc233f 100644 --- a/scripts/update.sh +++ b/scripts/update.sh @@ -126,10 +126,16 @@ touch_stamp() { date -u '+%Y-%m-%dT%H:%M:%SZ' > "$STAMP" 2>/dev/null || true; } # --- modes ----------------------------------------------------------------- report_local_state() { - local diverged orphans + local diverged orphans override_count diverged="$(list_diverged)" orphans="$(list_orphan_overrides)" + override_count=0 + if [ -d "$CONTEXT_DIR/overrides" ]; then + override_count="$(find "$CONTEXT_DIR/overrides" -type f -name '*.md' ! -name 'README.md' 2>/dev/null | wc -l | tr -d ' ')" + fi + [ "$override_count" -gt 0 ] && echo " $override_count override file(s) active." + if [ -n "$diverged" ]; then echo "" echo "Locally modified base files (these will be restored on update):" From 8c0eca0884ab36a5dc99d6a2f77ba0f7c2264224 Mon Sep 17 00:00:00 2001 From: ldastey-dev Date: Sun, 6 Sep 2026 16:37:44 +0100 Subject: [PATCH 04/20] ci(release): add version gate, PR title check and release automation Adds three workflows and the shared bump computation they both use, so the version shown on a pull request is the version its merge cuts. VERSION, CHANGELOG.md and scripts/baselines/ become CI-owned and hand-edits are rejected. Corrects the 1.0.0 baseline to hash main rather than this branch. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/pr-title.yml | 44 ++++++++++ .github/workflows/release.yml | 133 +++++++++++++++++++++++++++++ .github/workflows/version-gate.yml | 103 ++++++++++++++++++++++ scripts/baselines/1.0.0.sha256 | 2 +- scripts/ci/next-version.sh | 92 ++++++++++++++++++++ scripts/ci/write-baseline.sh | 30 +++++++ scripts/ci/write-changelog.sh | 104 ++++++++++++++++++++++ 7 files changed, 507 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/pr-title.yml create mode 100644 .github/workflows/release.yml create mode 100644 .github/workflows/version-gate.yml create mode 100644 scripts/ci/next-version.sh create mode 100644 scripts/ci/write-baseline.sh create mode 100644 scripts/ci/write-changelog.sh diff --git a/.github/workflows/pr-title.yml b/.github/workflows/pr-title.yml new file mode 100644 index 0000000..ce5db03 --- /dev/null +++ b/.github/workflows/pr-title.yml @@ -0,0 +1,44 @@ +name: PR title + +# The release job derives the version bump size from the merge commit subject, +# which is the pull request title when a PR is squashed. If the title is not a +# valid Conventional Commit the bump cannot be derived, so the title is checked +# on every pull request rather than discovered at merge time. +on: + pull_request: + types: [opened, edited, reopened, synchronize] + +concurrency: + group: pr-title-${{ github.event.pull_request.number }} + cancel-in-progress: true + +permissions: + pull-requests: read + +jobs: + lint: + name: Conventional Commit title + runs-on: ubuntu-latest + steps: + - uses: amannn/action-semantic-pull-request@v5 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + types: | + feat + fix + docs + style + refactor + perf + test + build + ci + chore + revert + requireScope: false + # A single-line subject keeps the squashed commit parseable. + subjectPattern: ^(?![A-Z])(?!.*\.$).+$ + subjectPatternError: | + The subject "{subject}" is invalid: it must start with a lower-case + letter and must not end with a full stop. diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..b926362 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,133 @@ +name: Release + +# Cuts a release when deployable content lands on main. This is the only writer +# of VERSION, CHANGELOG.md, tags and scripts/baselines/, which is what lets a +# deployed consumer trust a single 6-byte fetch of VERSION to detect staleness. +on: + push: + branches: [main] + +# Releases must queue, never cancel. Cancelling an in-flight release could leave +# a tag pointing at a commit whose VERSION was never pushed. +concurrency: + group: release-main + cancel-in-progress: false + +permissions: + contents: write + +jobs: + release: + name: Cut release + runs-on: ubuntu-latest + # Loop guard, belt and braces: the release commit is authored by the Actions + # bot and carries [skip ci]. Either condition alone would do; both together + # mean a change to one convention cannot cause an infinite release loop. + if: >- + github.actor != 'github-actions[bot]' && + !contains(github.event.head_commit.message, '[skip ci]') + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + # Persist the token so the later push and tag reuse it. + persist-credentials: true + + - name: Resolve changed files for this push + env: + BEFORE: ${{ github.event.before }} + AFTER: ${{ github.sha }} + run: | + set -euo pipefail + # github.event.before is all-zeroes for the first push to a new branch + # and can point at a commit that no longer exists after a force-push. + # Fall back to the first-parent diff, which is the squashed merge. + if [ -z "${BEFORE//0/}" ] || ! git cat-file -e "$BEFORE^{commit}" 2>/dev/null; then + git show --name-only --pretty=format: "$AFTER" > /tmp/changed-files.txt + else + git diff --name-only "$BEFORE" "$AFTER" > /tmp/changed-files.txt + fi + grep -v '^$' /tmp/changed-files.txt > /tmp/changed-files.tmp || true + mv /tmp/changed-files.tmp /tmp/changed-files.txt + echo "Changed files:" + sed 's/^/ /' /tmp/changed-files.txt + + - name: Compute next version + id: next + run: | + set -euo pipefail + subject="$(git log -1 --pretty=format:'%s')" + current="$(tr -d ' \t\r\n' < VERSION)" + next="$(scripts/ci/next-version.sh --changed-files /tmp/changed-files.txt --subject "$subject")" + echo "current=$current" >> "$GITHUB_OUTPUT" + echo "next=$next" >> "$GITHUB_OUTPUT" + if [ "$next" = "none" ]; then + echo "No deployable content changed. Skipping release." >> "$GITHUB_STEP_SUMMARY" + else + echo "Releasing $current -> $next" >> "$GITHUB_STEP_SUMMARY" + fi + + - name: Write VERSION, CHANGELOG and baseline + if: steps.next.outputs.next != 'none' + env: + NEXT: ${{ steps.next.outputs.next }} + run: | + set -euo pipefail + prev_tag="$(git tag --list 'v*' --sort=-v:refname | head -n 1 || true)" + printf '%s\n' "$NEXT" > VERSION + # The baseline must hash the released content, so write it after + # VERSION but from the same working tree that is about to be tagged. + scripts/ci/write-baseline.sh "$NEXT" + scripts/ci/write-changelog.sh "$NEXT" "$prev_tag" + + - name: Commit, then tag that commit + if: steps.next.outputs.next != 'none' + env: + NEXT: ${{ steps.next.outputs.next }} + run: | + set -euo pipefail + git config user.name 'github-actions[bot]' + git config user.email '41898282+github-actions[bot]@users.noreply.github.com' + git add VERSION CHANGELOG.md "scripts/baselines/$NEXT.sha256" + git commit -m "chore(release): $NEXT [skip ci]" + + # Push the commit before tagging it. Tagging first would produce a tag + # whose tree still holds the previous VERSION, so a consumer resolving + # the tag would download content that disagrees with the version they + # were told to expect. + # + # Another release may have landed while this job queued, so rebase and + # retry rather than failing the release outright. + attempt=1 + until git push origin HEAD:main; do + if [ "$attempt" -ge 3 ]; then + echo "::error::Could not push the release commit after $attempt attempts." + exit 1 + fi + echo "Push rejected; rebasing onto the latest main (attempt $attempt)." + git fetch origin main + git rebase origin/main + attempt=$((attempt + 1)) + done + + git tag -a "v$NEXT" -m "v$NEXT" HEAD + git push origin "v$NEXT" + + - name: Publish GitHub release + if: steps.next.outputs.next != 'none' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + NEXT: ${{ steps.next.outputs.next }} + run: | + set -euo pipefail + # Extract just this version's section from the changelog for the notes. + awk -v ver="## $NEXT " ' + index($0, ver) == 1 { inside = 1; next } + /^## / && inside { exit } + inside { print } + ' CHANGELOG.md > /tmp/notes.md + [ -s /tmp/notes.md ] || echo "See CHANGELOG.md." > /tmp/notes.md + gh release create "v$NEXT" \ + --title "v$NEXT" \ + --notes-file /tmp/notes.md \ + "scripts/baselines/$NEXT.sha256#baseline-$NEXT.sha256" diff --git a/.github/workflows/version-gate.yml b/.github/workflows/version-gate.yml new file mode 100644 index 0000000..d9d4239 --- /dev/null +++ b/.github/workflows/version-gate.yml @@ -0,0 +1,103 @@ +name: Version gate + +# VERSION is owned by the release job, not by contributors. This gate enforces +# that ownership in both directions: it rejects hand-edits to the generated +# files, and it tells the reviewer exactly which version the merge will cut. +# +# No path filters: the gate must run on every pull request so it is always +# available as a required status check. +on: + pull_request: + +concurrency: + group: version-gate-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + gate: + name: Version bump + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + # The gate diffs against the base branch, so the merge-base must be + # reachable. A shallow clone would not contain it. + fetch-depth: 0 + + - name: Resolve changed files + id: changed + env: + BASE_SHA: ${{ github.event.pull_request.base.sha }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + run: | + set -euo pipefail + git diff --name-only "$BASE_SHA" "$HEAD_SHA" > /tmp/changed-files.txt + echo "Changed files:" + sed 's/^/ /' /tmp/changed-files.txt + + - name: Reject hand-edits to generated files + env: + BASE_SHA: ${{ github.event.pull_request.base.sha }} + run: | + set -euo pipefail + fail=0 + + if grep -qx 'VERSION' /tmp/changed-files.txt; then + echo "::error file=VERSION::VERSION is written by the release workflow on merge to main. Revert your change to it - the version for this pull request is derived from its title." + fail=1 + fi + + if grep -q '^CHANGELOG\.md$' /tmp/changed-files.txt; then + echo "::error file=CHANGELOG.md::CHANGELOG.md is generated by the release workflow. Revert your change to it - it is written from commit messages on merge." + fail=1 + fi + + # Baselines are published per release so migrate can tell a pristine + # file from an edited one. A hand-written baseline would silently + # corrupt every future migration, so only the release job may add one. + if grep -q '^scripts/baselines/' /tmp/changed-files.txt; then + echo "::error::scripts/baselines/ is published by the release workflow. Do not add or edit baseline files by hand." + fail=1 + fi + + exit "$fail" + + - name: Compute the version this merge will cut + id: next + env: + PR_TITLE: ${{ github.event.pull_request.title }} + run: | + set -euo pipefail + current="$(tr -d ' \t\r\n' < VERSION)" + next="$(scripts/ci/next-version.sh --changed-files /tmp/changed-files.txt --subject "$PR_TITLE")" + { + echo "### Version gate" + echo "" + if [ "$next" = "none" ]; then + echo "No deployable content changed. **No release will be cut** and \`VERSION\` stays at \`$current\`." + echo "" + echo "Deployable paths are \`core/\`, \`standards/\`, \`playbooks/\`, and the deploy, update, migrate and shared library scripts. Documentation, workflows and tests do not reach a consumer repository, so they do not earn a version." + else + echo "Merging this pull request will release **\`$next\`** (currently \`$current\`)." + echo "" + echo "The bump size comes from the pull request title, with a patch floor: \`feat\` gives a minor, a \`!\` marker or \`BREAKING CHANGE\` gives a major, anything else gives a patch." + fi + } >> "$GITHUB_STEP_SUMMARY" + echo "next=$next" >> "$GITHUB_OUTPUT" + + - name: Verify the computed version is a real increment + if: steps.next.outputs.next != 'none' + env: + NEXT: ${{ steps.next.outputs.next }} + run: | + set -euo pipefail + . scripts/lib/common.sh + current="$(tr -d ' \t\r\n' < VERSION)" + if ! ac_semver_gt "$NEXT" "$current"; then + echo "::error::Computed version '$NEXT' is not greater than the current '$current'. This is a bug in scripts/ci/next-version.sh." + exit 1 + fi + echo "OK: $current -> $NEXT" diff --git a/scripts/baselines/1.0.0.sha256 b/scripts/baselines/1.0.0.sha256 index 6098fe5..1985945 100644 --- a/scripts/baselines/1.0.0.sha256 +++ b/scripts/baselines/1.0.0.sha256 @@ -1,7 +1,7 @@ conventions/code.md 306b9916bd0ebc87c418b4ded90a56c600708b0144a37de4aaca53484e19fb25 conventions/communication.md 508a9981cc3830e9992eb6edb30473c06ed149bcc63bab63fb8683e277d6ee03 conventions/workflow.md 6cc7f88c4dde53b9a713f9257060497abdf3e343c6e09b515b0cdb38464eb8cd -index.md 32a985ba27f992afdbe28da8f415c4e391ca7cd4ca87922e2ede8325ea4524bf +index.md 9081522a512ac330d633c4cedd3f07edb792928b35ad8640b0b853c27fbb1789 playbooks/assess/accessibility.md b9349df70e40518df65ef528f7366d288ffb5183fa2ef95f0641feb02ba6363c playbooks/assess/api-design.md ffb618ba0ce0e6909d89e18f1fceef214e1df5eac4619593091b415aa4318763 playbooks/assess/architecture.md e8b1de975d950f1d85a5ee7380e5a2385809711bc71824886c87c3399f0fa305 diff --git a/scripts/ci/next-version.sh b/scripts/ci/next-version.sh new file mode 100644 index 0000000..9f5bbca --- /dev/null +++ b/scripts/ci/next-version.sh @@ -0,0 +1,92 @@ +#!/bin/bash +# next-version.sh - single source of truth for "what version does this merge produce?". +# +# Both the version gate (on a pull request) and the release job (on push to main) +# call this, so the version shown on the PR is the version that is actually cut. +# +# Usage: +# next-version.sh --changed-files --subject [--current ] +# +# Reads the current version from ./VERSION unless --current is given. +# --changed-files points at a newline-delimited list of paths changed by the merge. +# +# Prints one of: +# when the change touches deployable content +# none when it does not, so no release is cut +# +# Portability: macOS bash 3.2 with BSD userland, and Linux with GNU userland. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" +# shellcheck source=scripts/lib/common.sh +. "$REPO_ROOT/scripts/lib/common.sh" + +CHANGED_FILES="" +SUBJECT="" +CURRENT="" + +while [ $# -gt 0 ]; do + case "$1" in + --changed-files) CHANGED_FILES="${2:-}"; shift 2 ;; + --subject) SUBJECT="${2:-}"; shift 2 ;; + --current) CURRENT="${2:-}"; shift 2 ;; + *) echo "ERROR: unknown argument '$1'" >&2; exit 2 ;; + esac +done + +[ -n "$CHANGED_FILES" ] || { echo "ERROR: --changed-files is required." >&2; exit 2; } +[ -f "$CHANGED_FILES" ] || { echo "ERROR: no such file: $CHANGED_FILES" >&2; exit 2; } + +if [ -z "$CURRENT" ]; then + CURRENT="$(tr -d ' \t\r\n' < "$REPO_ROOT/VERSION")" +fi +ac_semver_is_valid "$CURRENT" || { echo "ERROR: VERSION is not valid SemVer: '$CURRENT'" >&2; exit 1; } + +# --- is this change deployable? -------------------------------------------- +# +# Only content that lands in a consumer repository can make a deployment stale, +# so only that content earns a version. Workflows, tests, the README and the +# maintainer AGENTS.md never reach a consumer and never cut a release. +is_deployable() { + case "$1" in + core/*|standards/*|playbooks/*) return 0 ;; + scripts/deploy.sh|scripts/deploy.ps1) return 0 ;; + scripts/update.sh|scripts/update.ps1) return 0 ;; + scripts/migrate.sh|scripts/migrate.ps1) return 0 ;; + scripts/lib/common.sh|scripts/lib/common.ps1) return 0 ;; + *) return 1 ;; + esac +} + +DEPLOYABLE=0 +while IFS= read -r path; do + [ -n "$path" ] || continue + if is_deployable "$path"; then DEPLOYABLE=1; break; fi +done < "$CHANGED_FILES" + +if [ "$DEPLOYABLE" -eq 0 ]; then + echo "none" + exit 0 +fi + +# --- how big a bump? ------------------------------------------------------- +# +# The Conventional Commit type sets the size, never whether a bump happens: a +# deployable change is a release regardless of how its author labelled it. An +# unrecognised type therefore falls through to the patch floor rather than +# blocking the release. +BUMP="patch" +case "$SUBJECT" in + feat*) BUMP="minor" ;; +esac +case "$SUBJECT" in + # "type!: subject" and "type(scope)!: subject" both mark a breaking change. + *"!:"*) BUMP="major" ;; +esac +if printf '%s' "$SUBJECT" | grep -q 'BREAKING[ -]CHANGE'; then + BUMP="major" +fi + +ac_semver_bump "$CURRENT" "$BUMP" diff --git a/scripts/ci/write-baseline.sh b/scripts/ci/write-baseline.sh new file mode 100644 index 0000000..0362f7d --- /dev/null +++ b/scripts/ci/write-baseline.sh @@ -0,0 +1,30 @@ +#!/bin/bash +# write-baseline.sh - publish the content baseline for a release. +# +# migrate.sh compares a consumer's deployed files against the baseline for the +# version they are on to tell a pristine file from an edited one. Without a +# baseline for a version, nobody deployed on that version can ever migrate, so +# the release job writes one for every release. +# +# Usage: write-baseline.sh +# +# Portability: macOS bash 3.2 with BSD userland, and Linux with GNU userland. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" +# shellcheck source=scripts/lib/common.sh +. "$REPO_ROOT/scripts/lib/common.sh" + +VERSION="${1:-}" +[ -n "$VERSION" ] || { echo "ERROR: usage: write-baseline.sh " >&2; exit 2; } +ac_semver_is_valid "$VERSION" || { echo "ERROR: not valid SemVer: '$VERSION'" >&2; exit 1; } + +OUT="$REPO_ROOT/scripts/baselines/$VERSION.sha256" +mkdir -p "$REPO_ROOT/scripts/baselines" +ac_hash_source_tree "$REPO_ROOT" > "$OUT" + +count="$(grep -c . "$OUT" || true)" +[ "$count" -gt 0 ] || { echo "ERROR: baseline for $VERSION is empty - refusing to publish." >&2; rm -f "$OUT"; exit 1; } +echo "Wrote $OUT ($count entries)" diff --git a/scripts/ci/write-changelog.sh b/scripts/ci/write-changelog.sh new file mode 100644 index 0000000..79c710d --- /dev/null +++ b/scripts/ci/write-changelog.sh @@ -0,0 +1,104 @@ +#!/bin/bash +# write-changelog.sh - prepend a release section to CHANGELOG.md. +# +# Deliberately not git-cliff or a third-party action: the release job holds +# contents:write, so every dependency it pulls is a supply-chain risk, and the +# grouping we need is a dozen lines of shell. Fewer moving parts also means the +# release cannot fail because an action changed its interface. +# +# Usage: write-changelog.sh [] +# +# With no previous tag, every commit reachable from HEAD is included. +# +# Portability: macOS bash 3.2 with BSD userland, and Linux with GNU userland. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" + +VERSION="${1:-}" +PREV_TAG="${2:-}" +[ -n "$VERSION" ] || { echo "ERROR: usage: write-changelog.sh [previous-tag]" >&2; exit 2; } + +CHANGELOG="$REPO_ROOT/CHANGELOG.md" +TODAY="$(date -u '+%Y-%m-%d')" + +if [ -n "$PREV_TAG" ]; then + RANGE="$PREV_TAG..HEAD" +else + RANGE="HEAD" +fi + +RAW="$(mktemp)" +SECTION="$(mktemp)" +trap 'rm -f "$RAW" "$SECTION"' EXIT + +git -C "$REPO_ROOT" log --no-merges --pretty=format:'%s' "$RANGE" > "$RAW" + +# Emit one heading per Conventional Commit type, in a fixed order so the file +# reads consistently release to release. Types with no commits are omitted. +emit_group() { + local pattern="$1" heading="$2" matched + matched="$(grep -E "$pattern" "$RAW" || true)" + [ -n "$matched" ] || return 0 + echo "### $heading" >> "$SECTION" + echo "" >> "$SECTION" + printf '%s\n' "$matched" | sed -E 's/^[a-z]+(\([^)]*\))?!?: */- /' >> "$SECTION" + echo "" >> "$SECTION" +} + +{ + echo "## $VERSION - $TODAY" + echo "" +} > "$SECTION" + +# Breaking changes lead: they are the reason a consumer would read this at all. +BREAKING="$(grep -E '^[a-z]+(\([^)]*\))?!:' "$RAW" || true)" +if [ -n "$BREAKING" ]; then + echo "### Breaking changes" >> "$SECTION" + echo "" >> "$SECTION" + printf '%s\n' "$BREAKING" | sed -E 's/^[a-z]+(\([^)]*\))?!: */- /' >> "$SECTION" + echo "" >> "$SECTION" +fi + +emit_group '^feat(\([^)]*\))?!?: ' 'Features' +emit_group '^fix(\([^)]*\))?!?: ' 'Fixes' +emit_group '^(docs|refactor|perf|style)(\([^)]*\))?!?: ' 'Other changes' + +if [ ! -s "$RAW" ]; then + echo "- No user-facing changes recorded." >> "$SECTION" + echo "" >> "$SECTION" +fi + +if [ ! -f "$CHANGELOG" ]; then + { + echo "# Changelog" + echo "" + echo "Every release of this library. Versions follow [Semantic Versioning](https://semver.org):" + echo "a major means a consumer must act, a minor adds content, a patch corrects it." + echo "" + echo "This file is generated by the release workflow. Do not edit it by hand." + echo "" + } > "$CHANGELOG" +fi + +# Insert the new section directly after the preamble, so newest is first. +NEW="$(mktemp)" +awk -v section_file="$SECTION" ' + BEGIN { inserted = 0 } + # The first "## " line is the previous release; insert above it. + /^## / && !inserted { + while ((getline line < section_file) > 0) print line + inserted = 1 + } + { print } + END { + if (!inserted) { + while ((getline line < section_file) > 0) print line + } + } +' "$CHANGELOG" > "$NEW" +mv "$NEW" "$CHANGELOG" + +echo "Prepended $VERSION to CHANGELOG.md" From 6bcd4cb67a5489081660ec70d429ff7b2af8cc53 Mon Sep 17 00:00:00 2001 From: ldastey-dev Date: Sun, 6 Sep 2026 16:46:21 +0100 Subject: [PATCH 05/20] docs(versioning): document the override model, versioning and update flow Adds MIGRATIONS.md for the 1.x to 2.0.0 upgrade, README sections covering customisation, versioning and staying current, and maintainer guidance on deployable paths and CI-owned files. Extends both suites to cover the manifest, override layer, managed block and version computation. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/deploy-ps1-tests.yml | 14 ++- AGENTS.md | 50 ++++++++- MIGRATIONS.md | 90 +++++++++++++++ README.md | 93 ++++++++++++++++ scripts/tests/test-deploy.ps1 | 62 +++++++++++ scripts/tests/test-deploy.sh | 146 ++++++++++++++++++++++++- 6 files changed, 450 insertions(+), 5 deletions(-) create mode 100644 MIGRATIONS.md diff --git a/.github/workflows/deploy-ps1-tests.yml b/.github/workflows/deploy-ps1-tests.yml index af4d573..edd600d 100644 --- a/.github/workflows/deploy-ps1-tests.yml +++ b/.github/workflows/deploy-ps1-tests.yml @@ -56,7 +56,19 @@ jobs: run: | $ErrorActionPreference = 'Stop' Import-Module PSScriptAnalyzer - $results = Invoke-ScriptAnalyzer -Path ./scripts/deploy.ps1 -Settings ./PSScriptAnalyzerSettings.psd1 + # Every PowerShell file that ships to a consumer must clear the 5.1 + # baseline, not just deploy.ps1 - update and migrate run on their + # machines too. + $targets = @( + './scripts/deploy.ps1', + './scripts/update.ps1', + './scripts/migrate.ps1', + './scripts/lib/common.ps1' + ) + $results = @() + foreach ($target in $targets) { + $results += Invoke-ScriptAnalyzer -Path $target -Settings ./PSScriptAnalyzerSettings.psd1 + } if ($results) { $results | Format-Table -AutoSize | Out-String | Write-Host throw "PSScriptAnalyzer reported $($results.Count) compatibility finding(s)." diff --git a/AGENTS.md b/AGENTS.md index 0ed9ef6..edbaf16 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -61,6 +61,14 @@ When adding a new standard or playbook, the new entry in `core/.context/index.md Standards use "must", "never", "always". They are not suggestions. Every standard ends with a `## Non-Negotiables` and a `## Decision Checklist` so a reader can act without rereading the body. +### 6. The base is disposable; only the override layer is owned + +Everything the deploy scripts write into a target's `.context/` base — standards, playbooks, conventions, `index.md` — is **replaced wholesale on every update**. This is deliberate. It is what removes merging from the update path entirely: there is no three-way merge, no conflict, and no drift to reconcile. + +Consumers customise through `.context/overrides/`, which updates never touch, and through the region of their `AGENTS.md` outside the managed block. + +The consequence for maintainers: **nothing generated into the base may carry consumer state.** If you add a file to the base that a consumer would reasonably want to edit, you have created a file that updates will silently destroy. Either it belongs in the override layer, or it must be generated from the manifest. + --- ## Source → Target Layout @@ -71,15 +79,19 @@ The directory layout in this repo is **not** the layout in target repos. The dep | Source here | Target repo path | | --- | --- | -| `core/AGENTS.md` | `/AGENTS.md` | +| `core/AGENTS.md` | `/AGENTS.md` (managed block only after first deploy) | | `core/CLAUDE.md` | `/CLAUDE.md` | | `core/.context/index.md` | `/.context/index.md` | | `core/.context/conventions/*` | `/.context/conventions/*` | +| `core/.context/overrides/*` | `/.context/overrides/*` (scaffold only — never overwritten) | | `standards/*.md` | `/.context/standards/*.md` | | `playbooks/**/*.md` | `/.context/playbooks/**/*.md` | +| `scripts/{update,migrate}.{sh,ps1}`, `scripts/lib/*` | `/.context/bin/` | | `core/.cursor/`, `.devin/`, `.windsurfrules`, `.github/copilot-instructions.md` | mirrored to target (only when the agent is selected) | | Skill wrappers generated from `playbooks/**/*.md` frontmatter | `/.claude/skills/` and `/.github/skills/` (Claude/Copilot only) | +Generated into the target, with no source file here: `/.context/manifest.json` and `/.context/VERSION`. + --- ## Working in This Repository @@ -136,6 +148,30 @@ The directory layout in this repo is **not** the layout in target repos. The dep --- +### Versioning and releases + +**Never edit `VERSION`, `CHANGELOG.md`, or `scripts/baselines/` by hand.** All three are written by `.github/workflows/release.yml` when a change lands on `main`, and `.github/workflows/version-gate.yml` rejects any pull request that touches them. A hand-written baseline is the most damaging of the three: `migrate.sh` uses it to tell a pristine file from a consumer's edit, so a wrong baseline silently corrupts every future migration. + +A release is cut only when **deployable content** changes. Deployable means content that reaches a consumer repository: + +| Deployable — cuts a release | Not deployable — cuts nothing | +| --- | --- | +| `core/**`, `standards/**`, `playbooks/**` | `README.md`, this file, `MIGRATIONS.md` | +| `scripts/deploy.{sh,ps1}` | `.github/workflows/**` | +| `scripts/update.{sh,ps1}` | `scripts/tests/**`, `scripts/deploy.Tests.ps1` | +| `scripts/migrate.{sh,ps1}` | `scripts/ci/**` | +| `scripts/lib/common.{sh,ps1}` | `.gitignore`, editor config | + +This list lives in exactly one place — `is_deployable` in `scripts/ci/next-version.sh` — and both the gate and the release job call that script, so the version reported on a pull request is always the version its merge cuts. If you add a new file that ships to consumers, add it there or it will never trigger a release and every deployment will silently miss it. + +The pull request title sets the **size** of the bump, never whether one happens: `feat` gives a minor, `!` or `BREAKING CHANGE` gives a major, anything else falls to the patch floor. An unrecognised type must never block a release — a deployable change is a release regardless of how its author labelled it. + +Ordering inside the release job is load-bearing. The commit must be pushed **before** the tag is created, because tagging first produces a tag whose tree still holds the previous `VERSION` — a consumer resolving that tag would download content that contradicts the version it was told to expect. The job also runs under `concurrency: cancel-in-progress: false` so releases queue rather than cancel, and guards against re-triggering itself with both an actor check and `[skip ci]`. + +Any change that requires a consumer to act needs a section in `MIGRATIONS.md` and a matching baseline, or adopters on the previous version cannot upgrade. + +--- + ## What Belongs Where | If you are adding... | Put it in... | @@ -147,6 +183,10 @@ The directory layout in this repo is **not** the layout in target repos. The dep | A pointer for a specific agent to find AGENTS.md | `core/` | | A keyword route to discover a standard or playbook | `core/.context/index.md` | | Anything about how files are written to target repos | `scripts/deploy.sh` and `scripts/deploy.ps1` | +| Anything about how a deployed repo detects or applies updates | `scripts/update.sh` and `scripts/update.ps1` | +| Logic shared by the deploy, update and migrate scripts | `scripts/lib/common.sh` and `scripts/lib/common.ps1` | +| Anything about how a release is computed or published | `scripts/ci/*` and `.github/workflows/release.yml` | +| What a consumer must do to upgrade across a MAJOR | `MIGRATIONS.md` | If a change does not fit any row above, it probably does not belong in this repo. @@ -183,7 +223,11 @@ Additional rules that apply specifically to maintainers of this template repo: - `scripts/deploy.sh` and `scripts/deploy.ps1` must remain behaviour-equivalent. - `scripts/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. - `scripts/deploy.ps1` must run on Windows PowerShell 5.1 as well as PowerShell 7+. No PowerShell 7-only syntax. +- The same portability and behaviour-equivalence rules apply in full to `scripts/update.*`, `scripts/migrate.*` and `scripts/lib/common.*`. They ship to consumers and run on their machines, not ours. - The deploy-script workflows must run on every pull request from any branch, with no path filters. Never narrow their triggers. +- Never edit `VERSION`, `CHANGELOG.md` or `scripts/baselines/` by hand. CI owns all three. +- Never add a file to a consumer's base layer that a consumer would want to edit. It belongs in the override layer, or it will be destroyed on the next update. +- Never let an unrecognised commit type block a release. The type sets the size of a bump, never whether one happens. ## Decision Checklist @@ -194,6 +238,10 @@ Before opening a PR, confirm: - [ ] 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 `scripts/deploy.sh` and `scripts/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 an update or migrate script change: the bash and PowerShell versions were run against the same fixture and their output trees diffed for parity. +- [ ] If a new file now ships to consumers: `is_deployable` in `scripts/ci/next-version.sh` was updated, or it will never trigger a release. +- [ ] If consumers must act to upgrade: `MIGRATIONS.md` has a section for it. +- [ ] No hand-edits to `VERSION`, `CHANGELOG.md` or `scripts/baselines/`. - [ ] 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/MIGRATIONS.md b/MIGRATIONS.md new file mode 100644 index 0000000..5e7c8c1 --- /dev/null +++ b/MIGRATIONS.md @@ -0,0 +1,90 @@ +# Migrations + +Upgrade notes for consumers of this library. Only versions that require you to +act appear here; ordinary releases are picked up by `update.sh` with no manual +step. + +--- + +## 1.x to 2.0.0 — the override model + +### What changed + +Before 2.0.0, deploying was a one-way copy. Anything you edited afterwards was +yours, but the next deploy would either overwrite it or refuse to touch it, and +nothing could tell the difference between a file you had deliberately customised +and one you had never touched. In practice a deployment drifted from the library +and could never be safely refreshed. + +From 2.0.0 the deployed tree is split in two: + +| Layer | Path | Who owns it | +| --- | --- | --- | +| Base | `.context/standards/`, `.context/playbooks/`, `.context/conventions/`, `.context/index.md` | The library. Replaced wholesale on every update. | +| Overrides | `.context/overrides/` | You. Never touched by an update. | + +Because the base is disposable, updating is a delete-and-recopy with no merge +and no conflicts. Because overrides sit outside it, your customisations survive +untouched. Divergence becomes structurally impossible rather than something you +have to detect and reconcile. + +Your `AGENTS.md` gains a managed block delimited by +`` and ``. Only that +block is rewritten on update; everything above and below it is yours. + +### Migrating + +Run the migration from the root of the repository that has the deployment: + +```bash +# Dry run first. Nothing is written. +/path/to/agentic-context/scripts/migrate.sh . + +# When the report looks right: +/path/to/agentic-context/scripts/migrate.sh . --apply +``` + +```powershell +# Windows PowerShell 5.1 or PowerShell 7+ +& C:\path\to\agentic-context\scripts\migrate.ps1 . -Apply +``` + +The migration: + +1. Compares every deployed file against the published baseline for the version + you are on, so it can tell a file you edited from one you never touched. +2. Promotes each edited file into `.context/overrides/`, preserving your content + and marking it `mode: replace`. +3. Restores the base to pristine library content. +4. Moves files you added yourself into the override layer, where updates cannot + remove them. +5. Writes `.context/manifest.json` and installs `.context/bin/` tooling. +6. Prepends the managed block to `AGENTS.md`, leaving your existing content + below it untouched. + +It refuses to run on a dirty git tree, so every change it makes is reviewable +with `git diff` before you commit. + +### After migrating + +Two follow-ups are worth doing by hand — the migration cannot make these +judgements for you: + +- **Trim `AGENTS.md`.** Content that the managed block now supplies may still be + duplicated below it. Delete the duplicates. +- **Convert `mode: replace` to `mode: extend` where you can.** The migration is + conservative and marks every promoted file `replace`, which pins the whole file + and means you stop receiving library improvements to it. If your change was an + addition rather than a contradiction, `extend` keeps the library version and + appends yours. See `.context/overrides/README.md`. + +If you deployed on a version with no published baseline, pass `--from` to name +the closest released version, or `--baseline` to point at a baseline file +directly. Without a baseline the migration cannot distinguish your edits from +library content and will not run. + +### If you never edited anything + +Migration is still worth running: it installs the manifest and update tooling +that let the deployment stay current. It will report no promotions and simply +convert the layout. diff --git a/README.md b/README.md index 955d48a..82c388f 100644 --- a/README.md +++ b/README.md @@ -79,6 +79,8 @@ core/ Tier 1 — always in context (→ target code.md Naming, patterns, imports, core principles workflow.md Workflow orchestration, task management communication.md Writing standards, communication style + overrides/ Consumer-owned layer — never overwritten by an update + README.md How to write an override (extend vs replace) .windsurfrules Windsurf redirect → AGENTS.md .cursor/rules/standards.mdc Cursor redirect → AGENTS.md + index .devin/devin.json Devin config + index pointer @@ -145,6 +147,7 @@ scripts/ Distribution tooling (not deployed to ta update.sh / update.ps1 Staleness check and in-place update migrate.sh / migrate.ps1 One-off upgrade for pre-2.0 deployments lib/ Shared helpers (SemVer, manifest, hashing) + ci/ Release automation helpers (version, changelog, baseline) baselines/.sha256 Per-release hashes of deployable files deploy.Tests.ps1 Pester unit tests tests/ End-to-end test suites (bash and PowerShell) @@ -189,6 +192,96 @@ The `keywords` field feeds the context index. The `description` field is used by - SOLID principles always use **full names** (never SRP, OCP, etc.) - All instructions prescriptive: "must", "never", "always" +## Customising a Deployment + +A deployed repository is split into two layers, and the split is what makes +updates safe: + +| Layer | Path | Owner | +| ----- | ---- | ----- | +| Base | `.context/standards/`, `.context/playbooks/`, `.context/conventions/`, `.context/index.md` | This library. Replaced wholesale on every update. | +| Overrides | `.context/overrides/` | You. Never touched by an update. | +| Managed block | The delimited region of your `AGENTS.md` | This library. Everything outside it is yours. | + +**Never edit the base.** Anything you change there is restored on the next +update. To customise, put a file at the mirrored path under +`.context/overrides/`: + +``` +.context/standards/testing.md <- base, do not edit +.context/overrides/standards/testing.md <- your version, wins +``` + +Each override declares how it combines with the base: + +- **`mode: extend`** — the library file is loaded, then yours is appended. Use + this when you are adding a rule. You keep receiving library improvements. +- **`mode: replace`** — yours is loaded instead. Use this only when you need to + contradict the library, because you stop receiving improvements to that file. + +Prefer `extend`. Full detail is in `.context/overrides/README.md` in your +deployment. + +Because overrides live outside the base, an update never has to merge anything: +the base is deleted and recopied, and your layer is left alone. There are no +conflicts to resolve. + +## Versioning + +This library is versioned with [Semantic Versioning](https://semver.org). The +current version is in `VERSION` at the repository root, and every release is +tagged `vX.Y.Z` with notes in `CHANGELOG.md`. + +Versions are cut automatically by CI, never by hand: + +- A release happens only when **deployable content** changes — `core/`, + `standards/`, `playbooks/`, and the deploy, update, migrate and shared library + scripts. Changes to the README, workflows or tests do not produce a release, + because they never reach a consumer. +- The **size** of the bump comes from the pull request title, with a patch + floor: `feat` gives a minor, a `!` marker or `BREAKING CHANGE` gives a major, + anything else gives a patch. +- `VERSION`, `CHANGELOG.md` and `scripts/baselines/` are written by the release + workflow. Pull requests that edit them by hand are rejected by the version + gate. + +For maintainers, the version a pull request will cut is reported in its checks +before merge. + +## Staying Current + +A deployment goes stale the moment this library moves on. Each deployment +carries `.context/manifest.json` recording the version it is on, and +`.context/bin/` with the tooling to check and apply updates. + +```bash +.context/bin/update.sh --status # local state only, no network +.context/bin/update.sh --check # is there a newer version? +.context/bin/update.sh --apply # refresh the base to the latest +``` + +```powershell +.context/bin/update.ps1 -Status +.context/bin/update.ps1 -Check +.context/bin/update.ps1 -Apply +``` + +The check costs a single fetch of a six-byte `VERSION` file from a CDN-cached +URL — no authentication, no rate limit, and nothing meaningful added to an agent +session. It **fails open**: if the network is unavailable the check reports so +and exits zero, so it can never block your work. + +`--apply` deletes and recopies the base, rewrites only the managed block of +`AGENTS.md`, and leaves `.context/overrides/` untouched. It reports any base +file you had edited and tells you where to move the change. + +The deployed `AGENTS.md` carries an update checkpoint so an agent prompts you on +a schedule you choose — weekly by default. Change the frequency there, or pin to +a major line in the manifest to refuse automatic majors. + +Existing deployments made before the override model must be migrated once. See +[MIGRATIONS.md](MIGRATIONS.md). + ## Updating Standards Standards are maintained in **one place only**: diff --git a/scripts/tests/test-deploy.ps1 b/scripts/tests/test-deploy.ps1 index 58c258a..0fba0af 100644 --- a/scripts/tests/test-deploy.ps1 +++ b/scripts/tests/test-deploy.ps1 @@ -247,6 +247,68 @@ Assert-FileNotExists "AGENTS.md NOT deployed relative to corrupted CurrentDirect Remove-Item -Recurse -Force $tc5Base +# ═══════════════════════════════════════════════════════════════════════ +# TC6: manifest and override layer are deployed +# ═══════════════════════════════════════════════════════════════════════ +Write-Host "" +Write-Host "=== TC6: manifest and override layer ===" +$tc6Dir = Join-Path ([System.IO.Path]::GetTempPath()) ("ac-tc6-" + [System.Guid]::NewGuid().ToString("N")) +New-Item -ItemType Directory -Path $tc6Dir -Force | Out-Null +& "$ScriptsDir/deploy.ps1" -Agents all -Overwrite -Target $tc6Dir *>$null + +Assert-FileExists "manifest.json" "$tc6Dir/.context/manifest.json" + +$tc6Version = (Get-Content (Join-Path $RepoDir 'VERSION') -Raw).Trim() +Assert-Contains "manifest records the current version" "$tc6Dir/.context/manifest.json" "`"version`": `"$tc6Version`"" + +Assert-FileExists "override layer README" "$tc6Dir/.context/overrides/README.md" + +foreach ($tc6Tool in @('update.sh', 'update.ps1', 'lib/common.sh', 'lib/common.ps1')) { + Assert-FileExists "bin/$tc6Tool" "$tc6Dir/.context/bin/$tc6Tool" +} + +# The managed block is what lets an update rewrite framework content without +# touching the consumer's own AGENTS.md prose. +Assert-Contains "AGENTS.md managed block start" "$tc6Dir/AGENTS.md" "agentic-context:begin" +Assert-Contains "AGENTS.md managed block end" "$tc6Dir/AGENTS.md" "agentic-context:end" + +# -Status must work without network access and must not fail on a clean tree. +$tc6Status = & pwsh -NoProfile -File "$tc6Dir/.context/bin/update.ps1" -Status 2>&1 | Out-String +if ($tc6Status -match [regex]::Escape("agentic-context $tc6Version")) { + Pass "update.ps1 -Status reports the deployed version" +} else { + Fail "update.ps1 -Status did not report the deployed version" +} + +Remove-Item -Recurse -Force $tc6Dir + +# ═══════════════════════════════════════════════════════════════════════ +# TC7: consumer edits outside the managed block survive a redeploy +# ═══════════════════════════════════════════════════════════════════════ +Write-Host "" +Write-Host "=== TC7: consumer AGENTS.md content survives redeploy ===" +$tc7Dir = Join-Path ([System.IO.Path]::GetTempPath()) ("ac-tc7-" + [System.Guid]::NewGuid().ToString("N")) +New-Item -ItemType Directory -Path $tc7Dir -Force | Out-Null +& "$ScriptsDir/deploy.ps1" -Agents all -Overwrite -Target $tc7Dir *>$null + +$tc7Agents = Join-Path $tc7Dir 'AGENTS.md' +$tc7Body = Get-Content $tc7Agents -Raw +Set-Content -Path $tc7Agents -Value ("Sentinel-above-block`n`n" + $tc7Body + "`n## Our own section`nSentinel-below-block`n") -NoNewline + +& "$ScriptsDir/deploy.ps1" -Agents all -Overwrite -Target $tc7Dir *>$null + +Assert-Contains "content above the managed block survived redeploy" $tc7Agents "Sentinel-above-block" +Assert-Contains "content below the managed block survived redeploy" $tc7Agents "Sentinel-below-block" + +# An override the consumer wrote must never be overwritten by a redeploy. +$tc7Override = Join-Path $tc7Dir '.context/overrides/standards' +New-Item -ItemType Directory -Path $tc7Override -Force | Out-Null +Set-Content -Path (Join-Path $tc7Override 'testing.md') -Value "Sentinel-override" +& "$ScriptsDir/deploy.ps1" -Agents all -Overwrite -Target $tc7Dir *>$null +Assert-Contains "consumer override survived redeploy" (Join-Path $tc7Override 'testing.md') "Sentinel-override" + +Remove-Item -Recurse -Force $tc7Dir + # ═══════════════════════════════════════════════════════════════════════ # Summary # ═══════════════════════════════════════════════════════════════════════ diff --git a/scripts/tests/test-deploy.sh b/scripts/tests/test-deploy.sh index d487b2a..1562b46 100644 --- a/scripts/tests/test-deploy.sh +++ b/scripts/tests/test-deploy.sh @@ -27,9 +27,12 @@ fi # 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; } + # manifest.json is excluded: it records the deployment timestamp, so it is + # expected to differ between runs. TC5 asserts its stability separately, + # comparing every field except deployedAt. + checksum_tree() { find "$1" -type f ! -name 'manifest.json' -exec sha256sum {} + | sort; } elif command -v shasum >/dev/null 2>&1; then - checksum_tree() { find "$1" -type f -exec shasum -a 256 {} + | sort; } + checksum_tree() { find "$1" -type f ! -name 'manifest.json' -exec shasum -a 256 {} + | sort; } else echo "ERROR: neither sha256sum nor shasum is available" >&2 exit 1 @@ -297,7 +300,22 @@ else diff "$TC5_PERMS1" "$TC5_PERMS2" || true fi -rm -rf "$TC5_DIR" "$TC5_CHECKSUMS1" "$TC5_CHECKSUMS2" "$TC5_PERMS1" "$TC5_PERMS2" +# The manifest is excluded from the byte comparison above because it carries a +# timestamp. Every other field must still be identical across runs, or a +# redeploy is silently changing what the update tooling believes is installed. +TC5_MAN1=$(mktemp) +TC5_MAN2=$(mktemp) +grep -v '"deployedAt"' "$TC5_DIR/.context/manifest.json" > "$TC5_MAN2" +"$SCRIPTS_DIR/deploy.sh" --agents all --overwrite "$TC5_DIR" >/dev/null 2>&1 +grep -v '"deployedAt"' "$TC5_DIR/.context/manifest.json" > "$TC5_MAN1" +if diff -q "$TC5_MAN1" "$TC5_MAN2" >/dev/null 2>&1; then + pass "Manifest identical across runs apart from deployedAt" +else + fail "Manifest differs across runs beyond deployedAt" + diff "$TC5_MAN2" "$TC5_MAN1" || true +fi + +rm -rf "$TC5_DIR" "$TC5_CHECKSUMS1" "$TC5_CHECKSUMS2" "$TC5_PERMS1" "$TC5_PERMS2" "$TC5_MAN1" "$TC5_MAN2" # ═══════════════════════════════════════════════════════════════════════ # TC6: validate-config passes — deployed copy @@ -321,6 +339,128 @@ fi rm -rf "$TC6_DIR" +# ═══════════════════════════════════════════════════════════════════════ +# TC7: manifest and override layer are deployed +# ═══════════════════════════════════════════════════════════════════════ +echo "" +echo "=== TC7: manifest and override layer ===" +TC7_DIR=$(mktemp -d) +"$SCRIPTS_DIR/deploy.sh" --agents all --overwrite "$TC7_DIR" >/dev/null 2>&1 + +if [ -f "$TC7_DIR/.context/manifest.json" ]; then + pass "manifest.json written" +else + fail "manifest.json missing" +fi + +TC7_VER=$(tr -d ' \t\r\n' < "$REPO_DIR/VERSION") +if grep -q "\"version\": \"$TC7_VER\"" "$TC7_DIR/.context/manifest.json"; then + pass "manifest records the current version ($TC7_VER)" +else + fail "manifest version does not match VERSION" +fi + +if [ -d "$TC7_DIR/.context/overrides" ] && [ -f "$TC7_DIR/.context/overrides/README.md" ]; then + pass "override layer scaffolded" +else + fail "override layer missing" +fi + +for TC7_TOOL in update.sh update.ps1 lib/common.sh lib/common.ps1; do + if [ -f "$TC7_DIR/.context/bin/$TC7_TOOL" ]; then + pass "bin/$TC7_TOOL deployed" + else + fail "bin/$TC7_TOOL missing" + fi +done + +# The managed block is what lets an update rewrite framework content without +# touching the consumer's own AGENTS.md prose. +if grep -q 'agentic-context:begin' "$TC7_DIR/AGENTS.md" && grep -q 'agentic-context:end' "$TC7_DIR/AGENTS.md"; then + pass "AGENTS.md carries the managed block markers" +else + fail "AGENTS.md is missing the managed block markers" +fi + +# --status must work without network access and must not fail on a clean tree. +if (cd "$TC7_DIR" && bash .context/bin/update.sh --status 2>&1 | grep -q "agentic-context $TC7_VER"); then + pass "update.sh --status reports the deployed version" +else + fail "update.sh --status did not report the deployed version" +fi + +rm -rf "$TC7_DIR" + +# ═══════════════════════════════════════════════════════════════════════ +# TC8: consumer edits outside the managed block survive a redeploy +# ═══════════════════════════════════════════════════════════════════════ +echo "" +echo "=== TC8: consumer AGENTS.md content survives redeploy ===" +TC8_DIR=$(mktemp -d) +"$SCRIPTS_DIR/deploy.sh" --agents all --overwrite "$TC8_DIR" >/dev/null 2>&1 + +printf '\n## Our own section\nSentinel-below-block\n' >> "$TC8_DIR/AGENTS.md" +TC8_TMP=$(mktemp) +{ printf 'Sentinel-above-block\n\n'; cat "$TC8_DIR/AGENTS.md"; } > "$TC8_TMP" +mv "$TC8_TMP" "$TC8_DIR/AGENTS.md" + +"$SCRIPTS_DIR/deploy.sh" --agents all --overwrite "$TC8_DIR" >/dev/null 2>&1 + +if grep -q 'Sentinel-above-block' "$TC8_DIR/AGENTS.md"; then + pass "content above the managed block survived redeploy" +else + fail "content above the managed block was lost on redeploy" +fi + +if grep -q 'Sentinel-below-block' "$TC8_DIR/AGENTS.md"; then + pass "content below the managed block survived redeploy" +else + fail "content below the managed block was lost on redeploy" +fi + +# An override the consumer wrote must never be overwritten by a redeploy. +mkdir -p "$TC8_DIR/.context/overrides/standards" +printf 'Sentinel-override\n' > "$TC8_DIR/.context/overrides/standards/testing.md" +"$SCRIPTS_DIR/deploy.sh" --agents all --overwrite "$TC8_DIR" >/dev/null 2>&1 +if grep -q 'Sentinel-override' "$TC8_DIR/.context/overrides/standards/testing.md"; then + pass "consumer override survived redeploy" +else + fail "consumer override was overwritten by redeploy" +fi + +rm -rf "$TC8_DIR" + +# ═══════════════════════════════════════════════════════════════════════ +# TC9: release version computation +# ═══════════════════════════════════════════════════════════════════════ +echo "" +echo "=== TC9: next-version computation ===" +TC9_LIST=$(mktemp) + +check_next() { + # check_next + local got + printf '%s\n' "$1" > "$TC9_LIST" + got=$("$SCRIPTS_DIR/ci/next-version.sh" --changed-files "$TC9_LIST" --subject "$2" --current 1.2.3) + if [ "$got" = "$3" ]; then + pass "next-version: $1 + '$2' -> $3" + else + fail "next-version: $1 + '$2' gave '$got', expected '$3'" + fi +} + +check_next "README.md" "feat: x" "none" +check_next ".github/workflows/x.yml" "feat: x" "none" +check_next "scripts/tests/test-deploy.sh" "fix: x" "none" +check_next "standards/testing.md" "docs: x" "1.2.4" +check_next "core/AGENTS.md" "feat: x" "1.3.0" +check_next "playbooks/assess/a.md" "feat!: x" "2.0.0" +check_next "scripts/lib/common.ps1" "fix(deploy)!: x" "2.0.0" +# An unrecognised type must still release, at the patch floor. +check_next "standards/testing.md" "wibble: x" "1.2.4" + +rm -f "$TC9_LIST" + # ═══════════════════════════════════════════════════════════════════════ # Summary # ═══════════════════════════════════════════════════════════════════════ From 641fb441e506f748ee0742e64ee824c10c46c3fc Mon Sep 17 00:00:00 2001 From: ldastey-dev Date: Sun, 6 Sep 2026 16:51:20 +0100 Subject: [PATCH 06/20] fix(ci): allow bootstrap of CI-owned files and restore executable bits The version gate rejected the very commit that introduces VERSION and the first baseline. It now rejects only changes to files that already exist on the base, so creation is permitted once and every later edit is not. Restores the executable bit lost when the scripts moved into scripts/, and checks it in CI so the failure is not an opaque exit 126. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/deploy-sh-tests.yml | 22 ++++++++++++++++++++++ .github/workflows/version-gate.yml | 25 ++++++++++++++++++++----- scripts/ci/next-version.sh | 0 scripts/ci/write-baseline.sh | 0 scripts/ci/write-changelog.sh | 0 scripts/migrate.sh | 0 scripts/tests/test-deploy.sh | 0 scripts/update.sh | 0 8 files changed, 42 insertions(+), 5 deletions(-) mode change 100644 => 100755 scripts/ci/next-version.sh mode change 100644 => 100755 scripts/ci/write-baseline.sh mode change 100644 => 100755 scripts/ci/write-changelog.sh mode change 100644 => 100755 scripts/migrate.sh mode change 100644 => 100755 scripts/tests/test-deploy.sh mode change 100644 => 100755 scripts/update.sh diff --git a/.github/workflows/deploy-sh-tests.yml b/.github/workflows/deploy-sh-tests.yml index da2eb8c..0d7860b 100644 --- a/.github/workflows/deploy-sh-tests.yml +++ b/.github/workflows/deploy-sh-tests.yml @@ -57,6 +57,28 @@ jobs: done < <(git ls-files '*.sh') exit $status + - name: Executable bit on entry-point scripts + run: | + # A lost executable bit surfaces as an opaque exit 126 in the test job, + # so check it here where the cause is obvious. Only scripts that are + # invoked directly need it; lib/common.sh is sourced, not run. + status=0 + for script in \ + scripts/deploy.sh \ + scripts/update.sh \ + scripts/migrate.sh \ + scripts/tests/test-deploy.sh \ + scripts/ci/next-version.sh \ + scripts/ci/write-baseline.sh \ + scripts/ci/write-changelog.sh; do + mode="$(git ls-files -s "$script" | cut -d' ' -f1)" + if [ "$mode" != "100755" ]; then + echo "::error file=$script::$script is mode $mode; it must be 100755. Run: git update-index --chmod=+x $script" + status=1 + fi + done + exit $status + test: name: ${{ matrix.name }} strategy: diff --git a/.github/workflows/version-gate.yml b/.github/workflows/version-gate.yml index d9d4239..098b4b5 100644 --- a/.github/workflows/version-gate.yml +++ b/.github/workflows/version-gate.yml @@ -45,24 +45,39 @@ jobs: set -euo pipefail fail=0 - if grep -qx 'VERSION' /tmp/changed-files.txt; then + # These files are CI-owned once they exist. Creating them is a + # legitimate one-time bootstrap - the pull request that introduces + # versioning has to add them, and no release workflow exists yet to + # do it. Only a change to a file that already exists on the base is a + # hand-edit of generated content. + exists_on_base() { + git cat-file -e "$BASE_SHA:$1" 2>/dev/null + } + + if grep -qx 'VERSION' /tmp/changed-files.txt && exists_on_base VERSION; then echo "::error file=VERSION::VERSION is written by the release workflow on merge to main. Revert your change to it - the version for this pull request is derived from its title." fail=1 fi - if grep -q '^CHANGELOG\.md$' /tmp/changed-files.txt; then + if grep -qx 'CHANGELOG.md' /tmp/changed-files.txt && exists_on_base CHANGELOG.md; then echo "::error file=CHANGELOG.md::CHANGELOG.md is generated by the release workflow. Revert your change to it - it is written from commit messages on merge." fail=1 fi # Baselines are published per release so migrate can tell a pristine # file from an edited one. A hand-written baseline would silently - # corrupt every future migration, so only the release job may add one. + # corrupt every future migration, so once any baseline exists only + # the release job may add or change one. if grep -q '^scripts/baselines/' /tmp/changed-files.txt; then - echo "::error::scripts/baselines/ is published by the release workflow. Do not add or edit baseline files by hand." - fail=1 + if git ls-tree -r --name-only "$BASE_SHA" -- scripts/baselines | grep -q .; then + echo "::error::scripts/baselines/ is published by the release workflow. Do not add or edit baseline files by hand." + fail=1 + fi fi + if [ "$fail" -eq 0 ]; then + echo "OK: no hand-edits to CI-owned files." + fi exit "$fail" - name: Compute the version this merge will cut diff --git a/scripts/ci/next-version.sh b/scripts/ci/next-version.sh old mode 100644 new mode 100755 diff --git a/scripts/ci/write-baseline.sh b/scripts/ci/write-baseline.sh old mode 100644 new mode 100755 diff --git a/scripts/ci/write-changelog.sh b/scripts/ci/write-changelog.sh old mode 100644 new mode 100755 diff --git a/scripts/migrate.sh b/scripts/migrate.sh old mode 100644 new mode 100755 diff --git a/scripts/tests/test-deploy.sh b/scripts/tests/test-deploy.sh old mode 100644 new mode 100755 diff --git a/scripts/update.sh b/scripts/update.sh old mode 100644 new mode 100755 From ec6e6a09560d714dac5bd859a11efa4cfaf4e3d6 Mon Sep 17 00:00:00 2001 From: ldastey-dev Date: Sun, 6 Sep 2026 17:53:12 +0100 Subject: [PATCH 07/20] fix(release): publish 1.0.0 as the initial drop instead of bumping past it With no release tag there is nothing to bump from, so the version already in VERSION is published as-is and tagged. Bumping here would have skipped 1.0.0 entirely and made the first tag disagree with the repository's own VERSION. Both the gate and the release job now pass the latest tag explicitly, the release job tolerates having nothing to commit on an initial release, and both refuse to move a tag that already exists. Reframes migration as unversioned to 1.0.0 rather than a 2.0.0 upgrade. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/release.yml | 69 ++++++++++++++++++++---------- .github/workflows/version-gate.yml | 25 ++++++++++- MIGRATIONS.md | 26 +++++++---- README.md | 2 +- scripts/ci/next-version.sh | 23 +++++++++- scripts/migrate.ps1 | 8 ++-- scripts/migrate.sh | 10 ++--- scripts/tests/test-deploy.sh | 23 +++++++++- 8 files changed, 141 insertions(+), 45 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b926362..86d1598 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -58,27 +58,44 @@ jobs: set -euo pipefail subject="$(git log -1 --pretty=format:'%s')" current="$(tr -d ' \t\r\n' < VERSION)" - next="$(scripts/ci/next-version.sh --changed-files /tmp/changed-files.txt --subject "$subject")" + prev_tag="$(git tag --list 'v*' --sort=-v:refname | head -n 1 || true)" + next="$(scripts/ci/next-version.sh --changed-files /tmp/changed-files.txt --subject "$subject" --latest-tag "$prev_tag")" echo "current=$current" >> "$GITHUB_OUTPUT" echo "next=$next" >> "$GITHUB_OUTPUT" + echo "prev_tag=$prev_tag" >> "$GITHUB_OUTPUT" if [ "$next" = "none" ]; then echo "No deployable content changed. Skipping release." >> "$GITHUB_STEP_SUMMARY" + elif [ -z "$prev_tag" ]; then + echo "Initial release: publishing $next as-is." >> "$GITHUB_STEP_SUMMARY" else echo "Releasing $current -> $next" >> "$GITHUB_STEP_SUMMARY" fi + - name: Fail if the release tag already exists + if: steps.next.outputs.next != 'none' + env: + NEXT: ${{ steps.next.outputs.next }} + run: | + set -euo pipefail + # Re-releasing an existing version would move a tag consumers have + # already pinned to, so stop rather than overwrite. + if git rev-parse -q --verify "refs/tags/v$NEXT" >/dev/null; then + echo "::error::Tag v$NEXT already exists. Refusing to re-release it." + exit 1 + fi + - name: Write VERSION, CHANGELOG and baseline if: steps.next.outputs.next != 'none' env: NEXT: ${{ steps.next.outputs.next }} + PREV_TAG: ${{ steps.next.outputs.prev_tag }} run: | set -euo pipefail - prev_tag="$(git tag --list 'v*' --sort=-v:refname | head -n 1 || true)" printf '%s\n' "$NEXT" > VERSION # The baseline must hash the released content, so write it after # VERSION but from the same working tree that is about to be tagged. scripts/ci/write-baseline.sh "$NEXT" - scripts/ci/write-changelog.sh "$NEXT" "$prev_tag" + scripts/ci/write-changelog.sh "$NEXT" "$PREV_TAG" - name: Commit, then tag that commit if: steps.next.outputs.next != 'none' @@ -89,26 +106,34 @@ jobs: git config user.name 'github-actions[bot]' git config user.email '41898282+github-actions[bot]@users.noreply.github.com' git add VERSION CHANGELOG.md "scripts/baselines/$NEXT.sha256" - git commit -m "chore(release): $NEXT [skip ci]" - # Push the commit before tagging it. Tagging first would produce a tag - # whose tree still holds the previous VERSION, so a consumer resolving - # the tag would download content that disagrees with the version they - # were told to expect. - # - # Another release may have landed while this job queued, so rebase and - # retry rather than failing the release outright. - attempt=1 - until git push origin HEAD:main; do - if [ "$attempt" -ge 3 ]; then - echo "::error::Could not push the release commit after $attempt attempts." - exit 1 - fi - echo "Push rejected; rebasing onto the latest main (attempt $attempt)." - git fetch origin main - git rebase origin/main - attempt=$((attempt + 1)) - done + # On an initial release VERSION and the baseline are already correct + # in the tree, so there may be nothing to commit. That is not a + # failure - the tag still has to be created against this commit. + if git diff --cached --quiet; then + echo "Nothing to commit; tagging the current commit." + else + git commit -m "chore(release): $NEXT [skip ci]" + + # Push the commit before tagging it. Tagging first would produce a + # tag whose tree still holds the previous VERSION, so a consumer + # resolving the tag would download content that disagrees with the + # version they were told to expect. + # + # Another release may have landed while this job queued, so rebase + # and retry rather than failing the release outright. + attempt=1 + until git push origin HEAD:main; do + if [ "$attempt" -ge 3 ]; then + echo "::error::Could not push the release commit after $attempt attempts." + exit 1 + fi + echo "Push rejected; rebasing onto the latest main (attempt $attempt)." + git fetch origin main + git rebase origin/main + attempt=$((attempt + 1)) + done + fi git tag -a "v$NEXT" -m "v$NEXT" HEAD git push origin "v$NEXT" diff --git a/.github/workflows/version-gate.yml b/.github/workflows/version-gate.yml index 098b4b5..7009b58 100644 --- a/.github/workflows/version-gate.yml +++ b/.github/workflows/version-gate.yml @@ -87,7 +87,8 @@ jobs: run: | set -euo pipefail current="$(tr -d ' \t\r\n' < VERSION)" - next="$(scripts/ci/next-version.sh --changed-files /tmp/changed-files.txt --subject "$PR_TITLE")" + prev_tag="$(git tag --list 'v*' --sort=-v:refname | head -n 1 || true)" + next="$(scripts/ci/next-version.sh --changed-files /tmp/changed-files.txt --subject "$PR_TITLE" --latest-tag "$prev_tag")" { echo "### Version gate" echo "" @@ -95,6 +96,10 @@ jobs: echo "No deployable content changed. **No release will be cut** and \`VERSION\` stays at \`$current\`." echo "" echo "Deployable paths are \`core/\`, \`standards/\`, \`playbooks/\`, and the deploy, update, migrate and shared library scripts. Documentation, workflows and tests do not reach a consumer repository, so they do not earn a version." + elif [ -z "$prev_tag" ]; then + echo "This is the **initial release**. Merging will publish \`$next\` as-is and tag it \`v$next\`." + echo "" + echo "There is no earlier tag to bump from, so the version in \`VERSION\` is published unchanged rather than skipping straight past it." else echo "Merging this pull request will release **\`$next\`** (currently \`$current\`)." echo "" @@ -102,15 +107,31 @@ jobs: fi } >> "$GITHUB_STEP_SUMMARY" echo "next=$next" >> "$GITHUB_OUTPUT" + echo "prev_tag=$prev_tag" >> "$GITHUB_OUTPUT" - - name: Verify the computed version is a real increment + - name: Verify the computed version is releasable if: steps.next.outputs.next != 'none' env: NEXT: ${{ steps.next.outputs.next }} + PREV_TAG: ${{ steps.next.outputs.prev_tag }} run: | set -euo pipefail . scripts/lib/common.sh current="$(tr -d ' \t\r\n' < VERSION)" + + # Whatever version is about to be cut, its tag must not already exist: + # re-releasing would move a tag consumers have pinned to. + if git rev-parse -q --verify "refs/tags/v$NEXT" >/dev/null; then + echo "::error::Tag v$NEXT already exists. This merge would try to re-release it." + exit 1 + fi + + if [ -z "$PREV_TAG" ]; then + # Initial release: nothing to be greater than. + echo "OK: initial release of $NEXT" + exit 0 + fi + if ! ac_semver_gt "$NEXT" "$current"; then echo "::error::Computed version '$NEXT' is not greater than the current '$current'. This is a bug in scripts/ci/next-version.sh." exit 1 diff --git a/MIGRATIONS.md b/MIGRATIONS.md index 5e7c8c1..da3b39a 100644 --- a/MIGRATIONS.md +++ b/MIGRATIONS.md @@ -6,17 +6,22 @@ step. --- -## 1.x to 2.0.0 — the override model +## Unversioned to 1.0.0 — the override model + +If you deployed this library before it carried a `VERSION`, your deployment has +no `.context/manifest.json` and no `.context/overrides/`. This is a one-off +migration; every release after 1.0.0 is picked up by `update.sh` with no manual +step. ### What changed -Before 2.0.0, deploying was a one-way copy. Anything you edited afterwards was +Before 1.0.0, deploying was a one-way copy. Anything you edited afterwards was yours, but the next deploy would either overwrite it or refuse to touch it, and nothing could tell the difference between a file you had deliberately customised and one you had never touched. In practice a deployment drifted from the library and could never be safely refreshed. -From 2.0.0 the deployed tree is split in two: +From 1.0.0 the deployed tree is split in two: | Layer | Path | Who owns it | | --- | --- | --- | @@ -51,8 +56,8 @@ Run the migration from the root of the repository that has the deployment: The migration: -1. Compares every deployed file against the published baseline for the version - you are on, so it can tell a file you edited from one you never touched. +1. Compares every deployed file against the published `1.0.0` baseline, so it + can tell a file you edited from one you never touched. 2. Promotes each edited file into `.context/overrides/`, preserving your content and marking it `mode: replace`. 3. Restores the base to pristine library content. @@ -78,10 +83,13 @@ judgements for you: addition rather than a contradiction, `extend` keeps the library version and appends yours. See `.context/overrides/README.md`. -If you deployed on a version with no published baseline, pass `--from` to name -the closest released version, or `--baseline` to point at a baseline file -directly. Without a baseline the migration cannot distinguish your edits from -library content and will not run. +The migration compares against the `1.0.0` baseline by default, which hashes the +content this library shipped at the point versioning was introduced. If you +deployed from an older commit than that, some files will be reported as edited +when you never touched them — promote only the ones you recognise, or pass +`--baseline` to point at a baseline file you generated yourself from the commit +you actually deployed. Without a baseline the migration cannot distinguish your +edits from library content and will not run. ### If you never edited anything diff --git a/README.md b/README.md index 82c388f..5869379 100644 --- a/README.md +++ b/README.md @@ -145,7 +145,7 @@ playbooks/ Tier 2 — on demand (→ target .contex scripts/ Distribution tooling (not deployed to targets) deploy.sh / deploy.ps1 First-time install into a target repository update.sh / update.ps1 Staleness check and in-place update - migrate.sh / migrate.ps1 One-off upgrade for pre-2.0 deployments + migrate.sh / migrate.ps1 One-off upgrade for unversioned deployments lib/ Shared helpers (SemVer, manifest, hashing) ci/ Release automation helpers (version, changelog, baseline) baselines/.sha256 Per-release hashes of deployable files diff --git a/scripts/ci/next-version.sh b/scripts/ci/next-version.sh index 9f5bbca..b194782 100755 --- a/scripts/ci/next-version.sh +++ b/scripts/ci/next-version.sh @@ -5,10 +5,13 @@ # call this, so the version shown on the PR is the version that is actually cut. # # Usage: -# next-version.sh --changed-files --subject [--current ] +# next-version.sh --changed-files --subject +# [--current ] [--latest-tag ] # # Reads the current version from ./VERSION unless --current is given. # --changed-files points at a newline-delimited list of paths changed by the merge. +# --latest-tag names the most recent release tag; pass an empty string to state +# that none exists. When omitted it is derived from git. # # Prints one of: # when the change touches deployable content @@ -26,12 +29,15 @@ REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" CHANGED_FILES="" SUBJECT="" CURRENT="" +LATEST_TAG="" +LATEST_TAG_SET=0 while [ $# -gt 0 ]; do case "$1" in --changed-files) CHANGED_FILES="${2:-}"; shift 2 ;; --subject) SUBJECT="${2:-}"; shift 2 ;; --current) CURRENT="${2:-}"; shift 2 ;; + --latest-tag) LATEST_TAG="${2:-}"; LATEST_TAG_SET=1; shift 2 ;; *) echo "ERROR: unknown argument '$1'" >&2; exit 2 ;; esac done @@ -71,6 +77,21 @@ if [ "$DEPLOYABLE" -eq 0 ]; then exit 0 fi +# --- the initial drop ------------------------------------------------------ +# +# With no release tag there is nothing to bump from: the version already in +# VERSION is the first release, published as-is. Bumping here would silently +# skip 1.0.0 and make the first tag disagree with everything the repository +# says its version is. +if [ "$LATEST_TAG_SET" -eq 0 ]; then + LATEST_TAG="$(git -C "$REPO_ROOT" tag --list 'v*' --sort=-v:refname 2>/dev/null | head -n 1 || true)" +fi + +if [ -z "$LATEST_TAG" ]; then + printf '%s\n' "$CURRENT" + exit 0 +fi + # --- how big a bump? ------------------------------------------------------- # # The Conventional Commit type sets the size, never whether a bump happens: a diff --git a/scripts/migrate.ps1 b/scripts/migrate.ps1 index ae67715..6596c36 100644 --- a/scripts/migrate.ps1 +++ b/scripts/migrate.ps1 @@ -1,6 +1,6 @@ -# migrate.ps1 - upgrade a pre-2.0 agentic-context deployment to the override model. +# migrate.ps1 - upgrade an unversioned agentic-context deployment to the override model. # -# Pre-2.0 deployments have no .context/manifest.json and no .context/overrides/. +# Unversioned deployments have no .context/manifest.json and no .context/overrides/. # Consumers may have edited standards and playbooks directly. This script finds # those edits by comparing against a published baseline for the version they are # on, and promotes each edited file into .context/overrides/ so their intent is @@ -56,7 +56,7 @@ if (Test-Path -LiteralPath $manifestPath) { $ver = 'unknown' if ($existing -and $existing.version) { $ver = $existing.version } Write-Host "This deployment already has a manifest (version $ver)." - Write-Host "Migration is only for pre-2.0 deployments. Use update.ps1 -Apply instead." + Write-Host "Migration is only for unversioned deployments. Use update.ps1 -Apply instead." exit 0 } @@ -178,7 +178,7 @@ function Move-AcToOverride { 'mode: replace', '---', '', - '', diff --git a/scripts/migrate.sh b/scripts/migrate.sh index dd85667..2cca835 100755 --- a/scripts/migrate.sh +++ b/scripts/migrate.sh @@ -1,7 +1,7 @@ #!/bin/bash -# migrate.sh — upgrade a pre-2.0 agentic-context deployment to the override model. +# migrate.sh — upgrade an unversioned agentic-context deployment to the override model. # -# Pre-2.0 deployments have no .context/manifest.json and no .context/overrides/. +# Unversioned deployments have no .context/manifest.json and no .context/overrides/. # Consumers may have edited standards and playbooks directly. This script finds # those edits by comparing against a published baseline for the version they are # on, and promotes each edited file into .context/overrides/ so their intent is @@ -33,7 +33,7 @@ usage() { cat <] [--baseline ] [target-repo] -Upgrade a pre-2.0 deployment to the override model. +Upgrade an unversioned deployment to the override model. --apply Write changes. Without it, reports what would happen and exits. --from Version the target was deployed from. Default: 1.0.0 @@ -80,7 +80,7 @@ fi if [ -f "$CONTEXT_DIR/manifest.json" ]; then existing="$(ac_manifest_get "$CONTEXT_DIR/manifest.json" version)" echo "This deployment already has a manifest (version ${existing:-unknown})." - echo "Migration is only for pre-2.0 deployments. Use update.sh --apply instead." + echo "Migration is only for unversioned deployments. Use update.sh --apply instead." exit 0 fi @@ -184,7 +184,7 @@ promote() { printf 'overrides: %s\n' "$rel" printf 'mode: replace\n' printf -- '---\n\n' - printf '\n\n' diff --git a/scripts/tests/test-deploy.sh b/scripts/tests/test-deploy.sh index 1562b46..a524e2c 100755 --- a/scripts/tests/test-deploy.sh +++ b/scripts/tests/test-deploy.sh @@ -439,9 +439,11 @@ TC9_LIST=$(mktemp) check_next() { # check_next + # --latest-tag is passed explicitly so the result does not depend on which + # tags happen to exist in the checkout running the tests. local got printf '%s\n' "$1" > "$TC9_LIST" - got=$("$SCRIPTS_DIR/ci/next-version.sh" --changed-files "$TC9_LIST" --subject "$2" --current 1.2.3) + got=$("$SCRIPTS_DIR/ci/next-version.sh" --changed-files "$TC9_LIST" --subject "$2" --current 1.2.3 --latest-tag v1.2.3) if [ "$got" = "$3" ]; then pass "next-version: $1 + '$2' -> $3" else @@ -459,6 +461,25 @@ check_next "scripts/lib/common.ps1" "fix(deploy)!: x" "2.0.0" # An unrecognised type must still release, at the patch floor. check_next "standards/testing.md" "wibble: x" "1.2.4" +# The initial drop: with no release tag there is nothing to bump from, so the +# version already in VERSION is published as-is rather than skipping 1.0.0. +printf '%s\n' "core/AGENTS.md" > "$TC9_LIST" +TC9_INITIAL=$("$SCRIPTS_DIR/ci/next-version.sh" --changed-files "$TC9_LIST" --subject "feat!: x" --current 1.0.0 --latest-tag "") +if [ "$TC9_INITIAL" = "1.0.0" ]; then + pass "next-version: no tag yet -> publishes 1.0.0 unchanged" +else + fail "next-version: no tag yet gave '$TC9_INITIAL', expected '1.0.0'" +fi + +# Non-deployable changes must still cut nothing, even with no tag. +printf '%s\n' "README.md" > "$TC9_LIST" +TC9_INITIAL_NONE=$("$SCRIPTS_DIR/ci/next-version.sh" --changed-files "$TC9_LIST" --subject "feat: x" --current 1.0.0 --latest-tag "") +if [ "$TC9_INITIAL_NONE" = "none" ]; then + pass "next-version: no tag yet + non-deployable change -> none" +else + fail "next-version: no tag yet + non-deployable gave '$TC9_INITIAL_NONE', expected 'none'" +fi + rm -f "$TC9_LIST" # ═══════════════════════════════════════════════════════════════════════ From 7a09baf32253c5721b678eeea03c9028ffa59995 Mon Sep 17 00:00:00 2001 From: ldastey-dev Date: Sun, 6 Sep 2026 18:03:15 +0100 Subject: [PATCH 08/20] docs(readme): explain what each version bump means for a deployment Documents MAJOR, MINOR and PATCH in terms of their effect on a consumer's deployment rather than the scale of the change, since that is the decision a reader actually faces: whether an update can be applied unattended or needs reading first. Adds the pinning defaults and how a version is derived. Removes the bump derivation from the maintainer guide, which now links to the README rather than restating it. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- AGENTS.md | 8 ++++-- README.md | 81 ++++++++++++++++++++++++++++++++++++++++++------------- 2 files changed, 69 insertions(+), 20 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index edbaf16..6f293a7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -164,11 +164,15 @@ A release is cut only when **deployable content** changes. Deployable means cont This list lives in exactly one place — `is_deployable` in `scripts/ci/next-version.sh` — and both the gate and the release job call that script, so the version reported on a pull request is always the version its merge cuts. If you add a new file that ships to consumers, add it there or it will never trigger a release and every deployment will silently miss it. -The pull request title sets the **size** of the bump, never whether one happens: `feat` gives a minor, `!` or `BREAKING CHANGE` gives a major, anything else falls to the patch floor. An unrecognised type must never block a release — a deployable change is a release regardless of how its author labelled it. +The pull request title sets the **size** of the bump, never whether one happens. The derivation and what each number means to a consumer are in the [Versioning section of the README](README.md#versioning) — do not restate them here. The maintainer-facing rule is the one the table cannot express: an unrecognised type must never block a release. A deployable change is a release regardless of how its author labelled it, so mislabelling may understate a version but must never lose one. + +Choose the bump by its effect on a consumer's deployment, not by how much prose changed. Renaming or removing a deployed path is a major even if it is a one-line change, because an override pointing at the old path is orphaned by it. Rewriting a standard in full is a patch if every path survives. + +The first release is a special case: with no tag to bump from, the version already in `VERSION` is published as-is rather than bumped past. `next-version.sh` handles this when `--latest-tag` is empty, and both the gate and the release job pass it explicitly so they cannot disagree. Ordering inside the release job is load-bearing. The commit must be pushed **before** the tag is created, because tagging first produces a tag whose tree still holds the previous `VERSION` — a consumer resolving that tag would download content that contradicts the version it was told to expect. The job also runs under `concurrency: cancel-in-progress: false` so releases queue rather than cancel, and guards against re-triggering itself with both an actor check and `[skip ci]`. -Any change that requires a consumer to act needs a section in `MIGRATIONS.md` and a matching baseline, or adopters on the previous version cannot upgrade. +Any change that requires a consumer to act needs a section in `MIGRATIONS.md` and a matching baseline, or adopters on the previous version cannot upgrade. Because consumers are pinned to their major line by default, a major is never applied silently — which is what makes it safe to cut one when a deployment genuinely needs attention, rather than contorting a change to avoid it. --- diff --git a/README.md b/README.md index 5869379..9d65044 100644 --- a/README.md +++ b/README.md @@ -229,24 +229,69 @@ conflicts to resolve. ## Versioning This library is versioned with [Semantic Versioning](https://semver.org). The -current version is in `VERSION` at the repository root, and every release is -tagged `vX.Y.Z` with notes in `CHANGELOG.md`. - -Versions are cut automatically by CI, never by hand: - -- A release happens only when **deployable content** changes — `core/`, - `standards/`, `playbooks/`, and the deploy, update, migrate and shared library - scripts. Changes to the README, workflows or tests do not produce a release, - because they never reach a consumer. -- The **size** of the bump comes from the pull request title, with a patch - floor: `feat` gives a minor, a `!` marker or `BREAKING CHANGE` gives a major, - anything else gives a patch. -- `VERSION`, `CHANGELOG.md` and `scripts/baselines/` are written by the release - workflow. Pull requests that edit them by hand are rejected by the version - gate. - -For maintainers, the version a pull request will cut is reported in its checks -before merge. +current version is in `VERSION` at the repository root, every release is tagged +`vX.Y.Z` with notes in `CHANGELOG.md`, and each deployment records the version +it is on in `.context/manifest.json`. + +The version describes **the library as a whole**, not individual files. A single +number is what makes the staleness check cheap: comparing your manifest against +one six-byte file is enough to know whether you are behind. + +### What each number means + +Versions here describe the effect on **your deployment**, not the scale of the +writing. A rewritten standard is still a patch if your overrides keep working; a +single renamed file is a major if they do not. + +| Bump | Example: `1.4.2` → | What changed | What you must do | +| ---- | ------------------ | ------------ | ---------------- | +| **PATCH** | `1.4.3` | Existing content corrected or clarified. No file added, renamed or removed. | Nothing. Apply it whenever. | +| **MINOR** | `1.5.0` | New standards, playbooks or conventions added, and new routes in `index.md`. Existing paths unchanged. | Nothing. Your overrides still resolve; you simply gain content. | +| **MAJOR** | `2.0.0` | A deployed path was renamed or removed, the manifest schema changed, the managed block markers changed, or override resolution changed. | Read [MIGRATIONS.md](MIGRATIONS.md) before applying. An override may now point at a file that no longer exists. | + +The distinction that matters is **major versus everything else**. Patches and +minors are safe to apply unattended: the base is replaced, your override layer +is untouched, and every override still resolves to a real file. A major is the +only case where an update can leave an override orphaned, which is why it is the +only case that requires you to read anything. + +`update.sh --status` lists any override whose target no longer exists, so you +can confirm a major landed cleanly. + +### Pinning + +New deployments are pinned to their major line — `"pin": "1.x"` in the manifest. +Patches and minors apply automatically; a major is reported but never applied +until you act. Widen it to `"*"` to accept anything, narrow it to an exact +version to freeze entirely, or pass `--force` to override the pin once. + +This default means a major can never surprise you, which is what allows majors +to be used honestly rather than avoided. + +### How a version is decided + +Versions are cut automatically by CI, never by hand. + +**Whether** a release happens depends only on what changed. Deployable content — +`core/`, `standards/`, `playbooks/`, and the deploy, update, migrate and shared +library scripts — cuts a release. The README, workflows and tests do not, because +they never reach a consumer and so cannot make a deployment stale. + +**How large** the bump is comes from the pull request title, with a patch floor: + +| Pull request title | Bump | +| ------------------ | ---- | +| `feat!: ...`, or any title containing `BREAKING CHANGE` | major | +| `feat: ...` | minor | +| `fix: ...`, `docs: ...`, or anything else | patch | + +The title sets the size of the bump, never whether one happens: a deployable +change is released regardless of how it was labelled, so mislabelling can +understate a release but can never lose it. + +`VERSION`, `CHANGELOG.md` and `scripts/baselines/` are written by the release +workflow. Pull requests that edit them by hand are rejected by the version gate, +which also reports the exact version a merge will cut before it is merged. ## Staying Current From f41aa5a0d21be238d1659ca527e5c91da74e94e1 Mon Sep 17 00:00:00 2001 From: ldastey-dev Date: Sun, 6 Sep 2026 18:27:54 +0100 Subject: [PATCH 09/20] chore: ignore Node heap dump reports Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .gitignore | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.gitignore b/.gitignore index 39e7d61..011a5b4 100644 --- a/.gitignore +++ b/.gitignore @@ -29,3 +29,7 @@ # Pester test output (Invoke-Pester -CI) testResults.xml + +# Node/V8 diagnostic reports (heap dumps from an agent or tooling crash). +# Written to the working directory as report....json. +report.*.json From 732fa7f3785747230853e807f3498d2298b5f6d3 Mon Sep 17 00:00:00 2001 From: ldastey-dev Date: Sun, 6 Sep 2026 19:24:41 +0100 Subject: [PATCH 10/20] fix(content): repair broken index routes and separate migration baseline Three keyword routes in core/.context/index.md pointed at playbooks that do not exist, so an agent following them resolved nothing: assess/compliance.md -> split into assess/gdpr.md and assess/pci-dss.md assess/test-coverage.md -> renamed to assess/testing.md review/test-coverage.md -> renamed to review/testing.md Four assess playbooks (ci-cd, cost-optimisation, operational-excellence and resilience) had no route at all and were undiscoverable. All 29 standards and 45 playbooks are now routed, with no route pointing at a missing file. Separate the two distinct meanings of a baseline. scripts/baselines/ .sha256 records what a tagged release shipped and is written by CI. The migration baseline records the pre-versioning content that unversioned adopters actually deployed, and is now scripts/baselines/unversioned.sha256. Previously both were the same file, so the release job would have regenerated 1.0.0.sha256 from the release tree. That is harmless today because migrate compares only standards, playbooks and conventions, none of which differ on this branch. It stops being harmless as soon as a release also edits a standard or playbook: every such file would be reported as a consumer edit and promoted to a mode: replace override, freezing consumers on stale content. write-baseline.sh accepts only valid SemVer, so CI can never overwrite the unversioned baseline. Correct the README Repository Structure block, which had drifted: three missing standards, and stale counts and file lists for assess, review and setup. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- AGENTS.md | 6 ++--- MIGRATIONS.md | 9 ++++--- README.md | 24 +++++++++++-------- core/.context/index.md | 11 ++++++--- .../{1.0.0.sha256 => unversioned.sha256} | 0 scripts/migrate.ps1 | 2 +- scripts/migrate.sh | 5 ++-- 7 files changed, 35 insertions(+), 22 deletions(-) rename scripts/baselines/{1.0.0.sha256 => unversioned.sha256} (100%) diff --git a/AGENTS.md b/AGENTS.md index 6f293a7..d5c71d1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -150,7 +150,7 @@ Generated into the target, with no source file here: `/.context/manifest.j ### Versioning and releases -**Never edit `VERSION`, `CHANGELOG.md`, or `scripts/baselines/` by hand.** All three are written by `.github/workflows/release.yml` when a change lands on `main`, and `.github/workflows/version-gate.yml` rejects any pull request that touches them. A hand-written baseline is the most damaging of the three: `migrate.sh` uses it to tell a pristine file from a consumer's edit, so a wrong baseline silently corrupts every future migration. +**Never edit `VERSION`, `CHANGELOG.md`, or `scripts/baselines/.sha256` by hand.** All three are written by `.github/workflows/release.yml` when a change lands on `main`, and `.github/workflows/version-gate.yml` rejects any pull request that touches them. A hand-written baseline is the most damaging of the three: `migrate.sh` uses it to tell a pristine file from a consumer's edit, so a wrong baseline silently corrupts every future migration. A release is cut only when **deployable content** changes. Deployable means content that reaches a consumer repository: @@ -229,7 +229,7 @@ Additional rules that apply specifically to maintainers of this template repo: - `scripts/deploy.ps1` must run on Windows PowerShell 5.1 as well as PowerShell 7+. No PowerShell 7-only syntax. - The same portability and behaviour-equivalence rules apply in full to `scripts/update.*`, `scripts/migrate.*` and `scripts/lib/common.*`. They ship to consumers and run on their machines, not ours. - The deploy-script workflows must run on every pull request from any branch, with no path filters. Never narrow their triggers. -- Never edit `VERSION`, `CHANGELOG.md` or `scripts/baselines/` by hand. CI owns all three. +- Never edit `VERSION`, `CHANGELOG.md` or `scripts/baselines/.sha256` by hand. CI owns all three. `scripts/baselines/unversioned.sha256` is the one exception: it records the pre-versioning content for `migrate`, is committed by hand, and CI can never overwrite it because `write-baseline.sh` accepts only valid SemVer. - Never add a file to a consumer's base layer that a consumer would want to edit. It belongs in the override layer, or it will be destroyed on the next update. - Never let an unrecognised commit type block a release. The type sets the size of a bump, never whether one happens. @@ -245,7 +245,7 @@ Before opening a PR, confirm: - [ ] If an update or migrate script change: the bash and PowerShell versions were run against the same fixture and their output trees diffed for parity. - [ ] If a new file now ships to consumers: `is_deployable` in `scripts/ci/next-version.sh` was updated, or it will never trigger a release. - [ ] If consumers must act to upgrade: `MIGRATIONS.md` has a section for it. -- [ ] No hand-edits to `VERSION`, `CHANGELOG.md` or `scripts/baselines/`. +- [ ] No hand-edits to `VERSION`, `CHANGELOG.md` or `scripts/baselines/.sha256`. - [ ] 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/MIGRATIONS.md b/MIGRATIONS.md index da3b39a..67f474e 100644 --- a/MIGRATIONS.md +++ b/MIGRATIONS.md @@ -56,7 +56,7 @@ Run the migration from the root of the repository that has the deployment: The migration: -1. Compares every deployed file against the published `1.0.0` baseline, so it +1. Compares every deployed file against the `unversioned` baseline, so it can tell a file you edited from one you never touched. 2. Promotes each edited file into `.context/overrides/`, preserving your content and marking it `mode: replace`. @@ -83,8 +83,11 @@ judgements for you: addition rather than a contradiction, `extend` keeps the library version and appends yours. See `.context/overrides/README.md`. -The migration compares against the `1.0.0` baseline by default, which hashes the -content this library shipped at the point versioning was introduced. If you +The migration compares against the `unversioned` baseline by default, which +hashes the content this library shipped immediately before versioning was +introduced - that is, what you deployed. It is deliberately distinct from the +per-release `.sha256` baselines, which record what each tagged release +shipped. If you deployed from an older commit than that, some files will be reported as edited when you never touched them — promote only the ones you recognise, or pass `--baseline` to point at a baseline file you generated yourself from the commit diff --git a/README.md b/README.md index 9d65044..68514d3 100644 --- a/README.md +++ b/README.md @@ -116,17 +116,21 @@ standards/ Tier 3 — reference (→ target .contex terraform.md HCL file layout, modules, tflint, Terratest ado-pipelines.md YAML triggers, templates, environments, approvals docker.md Multi-stage builds, layer optimisation, scanning + opentelemetry.md OpenTelemetry semantic conventions and SDK usage + opentelemetry-dotnet.md OpenTelemetry instrumentation for .NET + playwright.md End-to-end browser testing with Playwright playbooks/ Tier 2 — on demand (→ target .context/playbooks/) - assess/ Structured codebase-level assessments (14) + assess/ Structured codebase-level assessments (21) accessibility.md, api-design.md, architecture.md, aws-well-architected.md, - azure-well-architected.md, code-quality.md, compliance.md, full.md, - iac.md, observability.md, performance.md, security.md, tech-debt.md, - test-coverage.md - review/ PR-level and change-level reviews (10) + azure-well-architected.md, ci-cd.md, code-quality.md, cost-optimisation.md, + domain-security.md, full.md, gdpr.md, iac.md, observability.md, + operational-excellence.md, pci-dss.md, pen-test.md, performance.md, + resilience.md, security.md, tech-debt.md, testing.md + review/ PR-level and change-level reviews (11) accessibility.md, api-design.md, architecture.md, code-quality.md, - compliance.md, iac.md, observability.md, performance.md, - security.md, test-coverage.md + compliance.md, cost-optimisation.md, iac.md, observability.md, + performance.md, security.md, testing.md plan/ Design and decision documents (5) adr.md, design-doc.md, research.md, risk-assessment.md, spike.md refactor/ Structured code change procedures (3) @@ -135,12 +139,11 @@ playbooks/ Tier 2 — on demand (→ target .contex scientific-debugging.md docs/ Developer-facing documentation generation (1) gitbook.md - setup/ Setup and tooling playbooks (4) + setup/ Setup and tooling playbooks (3) create-local-otel-stack.md create-local-otel-stack/ (companion scripts and configs — deployed alongside the playbook) discover-local-otel-stack.md use-local-otel-stack.md - instrument-dotnet-otel.md scripts/ Distribution tooling (not deployed to targets) deploy.sh / deploy.ps1 First-time install into a target repository @@ -148,7 +151,8 @@ scripts/ Distribution tooling (not deployed to ta migrate.sh / migrate.ps1 One-off upgrade for unversioned deployments lib/ Shared helpers (SemVer, manifest, hashing) ci/ Release automation helpers (version, changelog, baseline) - baselines/.sha256 Per-release hashes of deployable files + baselines/.sha256 Per-release hashes of deployable files (written by CI) + baselines/unversioned.sha256 Hashes of the pre-versioning content, for migrate deploy.Tests.ps1 Pester unit tests tests/ End-to-end test suites (bash and PowerShell) diff --git a/core/.context/index.md b/core/.context/index.md index 684262a..619ad1d 100644 --- a/core/.context/index.md +++ b/core/.context/index.md @@ -63,17 +63,22 @@ managed by the framework and are replaced wholesale on update. Never edit them; | assess architecture, architecture review, well-architected | `.context/playbooks/assess/architecture.md` | Well-Architected Framework assessment | | assess AWS, AWS well-architected, AWS cloud review | `.context/playbooks/assess/aws-well-architected.md` | AWS Well-Architected Framework assessment (6 pillars) | | assess Azure, Azure well-architected, Azure cloud review | `.context/playbooks/assess/azure-well-architected.md` | Azure Well-Architected Framework assessment (5 pillars) | +| assess ci-cd, pipeline audit, build assessment, deployment review | `.context/playbooks/assess/ci-cd.md` | CI/CD pipeline and delivery assessment | | assess code quality, SOLID assessment, clean code audit | `.context/playbooks/assess/code-quality.md` | SOLID and Clean Code assessment | -| assess compliance, GDPR audit, PCI audit, regulatory | `.context/playbooks/assess/compliance.md` | GDPR and PCI-DSS compliance assessment | +| assess compliance, GDPR audit, regulatory | `.context/playbooks/assess/gdpr.md` | GDPR data protection assessment | +| assess PCI audit, card data, PCI-DSS | `.context/playbooks/assess/pci-dss.md` | PCI-DSS payment card data assessment | | full assessment, comprehensive assessment, health check | `.context/playbooks/assess/full.md` | Single-pass assessment across all domains | +| assess cost, finops audit, cost review, resource optimisation | `.context/playbooks/assess/cost-optimisation.md` | Cloud cost and resource efficiency assessment | | assess IaC, infrastructure assessment, terraform audit | `.context/playbooks/assess/iac.md` | Infrastructure as Code maturity assessment | | assess observability, monitoring audit, logging assessment | `.context/playbooks/assess/observability.md` | Observability maturity assessment | +| assess operations, operational audit, ops review, platform assessment | `.context/playbooks/assess/operational-excellence.md` | Operational excellence and platform maturity assessment | | assess performance, performance audit, scalability review | `.context/playbooks/assess/performance.md` | Performance and resilience assessment | +| assess resilience, fault tolerance audit, reliability review, chaos readiness | `.context/playbooks/assess/resilience.md` | Fault tolerance, reliability and chaos readiness assessment | | assess security, security audit, threat model, owasp top 10 | `.context/playbooks/assess/security.md` | OWASP Top 10 security assessment (lighter-touch triage; for a full CREST engagement see `pen-test.md` below) | | assess domain security, DNS audit, subdomain takeover, security headers, HSTS, CSP, WAF, Cloudflare | `.context/playbooks/assess/domain-security.md` | External domain and attack-surface security assessment | | crest pen test, penetration test, red team, ethical hacking, bug bounty, exploit chain | `.context/playbooks/assess/pen-test.md` | CREST-aligned penetration test across web, API, infra, cloud, identity, mobile, wireless, social-policy with authorisation gating and non-destructive PoC | | assess tech debt, debt inventory, code health | `.context/playbooks/assess/tech-debt.md` | Technical debt identification and prioritisation | -| assess test coverage, test audit, testing assessment | `.context/playbooks/assess/test-coverage.md` | Testing strategy and coverage assessment | +| assess test coverage, test audit, testing assessment | `.context/playbooks/assess/testing.md` | Testing strategy and coverage assessment | --- @@ -91,7 +96,7 @@ managed by the framework and are replaced wholesale on update. Never edit them; | review cost, FinOps audit, cost review, resource optimisation | `.context/playbooks/review/cost-optimisation.md` | API economy, dependencies, observability spend | | review performance, performance review | `.context/playbooks/review/performance.md` | Query efficiency, caching, resource disposal | | review security, security review | `.context/playbooks/review/security.md` | OWASP, secrets, injection vectors | -| review test coverage, test review | `.context/playbooks/review/test-coverage.md` | Test quality, coverage gaps, coupling | +| review test coverage, test review | `.context/playbooks/review/testing.md` | Test quality, coverage gaps, coupling | --- diff --git a/scripts/baselines/1.0.0.sha256 b/scripts/baselines/unversioned.sha256 similarity index 100% rename from scripts/baselines/1.0.0.sha256 rename to scripts/baselines/unversioned.sha256 diff --git a/scripts/migrate.ps1 b/scripts/migrate.ps1 index 6596c36..7a9006e 100644 --- a/scripts/migrate.ps1 +++ b/scripts/migrate.ps1 @@ -16,7 +16,7 @@ param( [Parameter(Position = 0)] [string]$Target = '', [switch]$Apply, - [string]$From = '1.0.0', + [string]$From = 'unversioned', [string]$Baseline = '' ) diff --git a/scripts/migrate.sh b/scripts/migrate.sh index 2cca835..067cc22 100755 --- a/scripts/migrate.sh +++ b/scripts/migrate.sh @@ -26,7 +26,7 @@ fi APPLY=0 TARGET="" -FROM_VERSION="1.0.0" +FROM_VERSION="unversioned" BASELINE="" usage() { @@ -36,7 +36,8 @@ Usage: migrate.sh [--apply] [--from ] [--baseline ] [target-repo] Upgrade an unversioned deployment to the override model. --apply Write changes. Without it, reports what would happen and exits. - --from Version the target was deployed from. Default: 1.0.0 + --from Content the target was deployed from. Default: unversioned + (the pre-versioning content). Otherwise a published version. --baseline Baseline hash file. Default: scripts/baselines/.sha256 target-repo Repository to migrate. Default: current directory. From 8317ba4448068b9198df18fbb2a17fc5f5285f15 Mon Sep 17 00:00:00 2001 From: ldastey-dev Date: Sun, 6 Sep 2026 19:36:48 +0100 Subject: [PATCH 11/20] docs(readme): correct baseline ownership claim The README stated that all of scripts/baselines/ is written by the release workflow. That stopped being true when the migration baseline was separated from the per-release baselines: scripts/baselines/unversioned.sha256 records the pre-versioning content for migrate.sh and is committed by hand, and CI cannot write it because write-baseline.sh accepts only valid SemVer. AGENTS.md was corrected at the time; the README was missed. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- README.md | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 68514d3..f5d412b 100644 --- a/README.md +++ b/README.md @@ -293,9 +293,12 @@ The title sets the size of the bump, never whether one happens: a deployable change is released regardless of how it was labelled, so mislabelling can understate a release but can never lose it. -`VERSION`, `CHANGELOG.md` and `scripts/baselines/` are written by the release -workflow. Pull requests that edit them by hand are rejected by the version gate, -which also reports the exact version a merge will cut before it is merged. +`VERSION`, `CHANGELOG.md` and `scripts/baselines/.sha256` are written by +the release workflow. Pull requests that edit them by hand are rejected by the +version gate, which also reports the exact version a merge will cut before it is +merged. The one baseline CI does not own is +`scripts/baselines/unversioned.sha256`, which records the pre-versioning content +for `migrate.sh` and is committed by hand. ## Staying Current From 4124f1161ee7d19f60bcd89f882ab71cd1a4927a Mon Sep 17 00:00:00 2001 From: ldastey-dev Date: Sun, 6 Sep 2026 20:48:59 +0100 Subject: [PATCH 12/20] fix(update,ci): close data-loss and version-derivation defects from review Actions the review comments on pull request #31. Data loss and correctness in consumer repositories: - update.sh guarded the managed block on the begin marker alone, so a file with a begin marker and no end marker had everything from that marker to EOF replaced - destroying the consumer configuration the block exists to protect. deploy.sh and update.ps1 both required the pair, so this was also a parity break. The markers and the well-formed test now live in scripts/lib/common.sh and are shared, so the two cannot drift again. - migrate.sh compared byte hashes against an LF baseline, so a Windows checkout with core.autocrlf=true saw every file as edited and promoted all of them to "mode: replace" overrides, silently converting a pristine deployment into a permanent fork. Hashing is now LF-normalised, and an all-files-differ result is refused outright as a systemic mismatch. - migrate.sh restored each area with rm -rf but only classified *.md, so a consumer's non-markdown file was destroyed without ever being offered as an override. Such files are now preserved; framework companions are restored. The migration also states its destructive behaviour before it runs. - update.sh refreshed common.sh but not common.ps1, so any deployment last updated from bash shipped a stale PowerShell library to its Windows users forever. - Both updaters reset "pin" to the new version's major line on every apply, discarding a deliberate consumer choice. The pin is now preserved. - update.sh deleted each area before writing its replacement without checking any result, so a failed extract left a half-deleted .context/ and exited zero while PowerShell stopped cleanly. The payload is now validated before anything is removed, and every step is checked. - migrate.sh runs under set -e; it has no fail-open requirement. Version derivation: - The breaking-change match was unanchored, so "fix: cleanup foo!: bar" cut a major and orphaned every pinned consumer. "feat*" likewise matched "feature flags:". Both are now anchored to the leading type token. - BREAKING CHANGE was only ever read from the subject, but Conventional Commits puts it in the footer, so a breaking change shipped as a patch. The release job now passes the full message, and the gate rejects a description declaring a breaking change when the title omits "!". - The release job warns when the merge subject is not a Conventional Commit rather than silently falling back to a patch, since the bump depends on squash-merge being enabled. CI: - The version gate uses a three-dot diff, so commits landing on main while a pull request is open are no longer attributed to it. - The gate allows scripts/baselines/unversioned.sha256, which AGENTS.md documents as hand-committed and which migrate correctness depends on. - A new check asserts that everything the deploy scripts ship is matched by is_deployable, and that non-deployable paths are not. - write-changelog.sh no longer lists breaking commits twice, and groups build/ci/chore/test/revert so a deployable script change cannot produce an empty release section. - The third-party action is pinned to a commit SHA, with Dependabot added to keep it current. Tests: - 11 new assertions covering the managed-block matrix, migrate promotion and restore, non-markdown preservation, CRLF handling and the backstop. Each was confirmed to fail when its defect is reintroduced. 92 pass, up from 81. Not actioned: the report that find -maxdepth is GNU-only. FreeBSD and macOS both document -maxdepth, so update.sh is already portable there. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/dependabot.yml | 15 +++ .github/workflows/deploy-sh-tests.yml | 40 +++++++ .github/workflows/pr-title.yml | 13 ++- .github/workflows/release.yml | 25 ++++- .github/workflows/version-gate.yml | 43 ++++++-- MIGRATIONS.md | 9 +- scripts/ci/next-version.sh | 32 ++++-- scripts/ci/write-changelog.sh | 18 +++- scripts/deploy.sh | 15 +-- scripts/lib/common.ps1 | 9 +- scripts/lib/common.sh | 39 +++++++ scripts/migrate.sh | 70 +++++++++++- scripts/tests/test-deploy.sh | 148 ++++++++++++++++++++++++++ scripts/update.ps1 | 8 +- scripts/update.sh | 59 ++++++++-- 15 files changed, 489 insertions(+), 54 deletions(-) create mode 100644 .github/dependabot.yml diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..b1232c9 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,15 @@ +version: 2 + +# Third-party actions are pinned to full commit SHAs so a retargeted tag cannot +# execute in our workflows. Dependabot is what keeps those pins current, so the +# hardening does not decay into running abandoned versions. +updates: + - package-ecosystem: github-actions + directory: / + schedule: + interval: monthly + commit-message: + # Workflow files are not deployable, so this never cuts a release. + prefix: ci + labels: + - dependencies diff --git a/.github/workflows/deploy-sh-tests.yml b/.github/workflows/deploy-sh-tests.yml index 0d7860b..c1aabde 100644 --- a/.github/workflows/deploy-sh-tests.yml +++ b/.github/workflows/deploy-sh-tests.yml @@ -79,6 +79,46 @@ jobs: done exit $status + - name: Deployable paths cover everything that ships + run: | + # is_deployable in next-version.sh decides whether a change cuts a + # release. If a file reaches consumers but is not listed there, the + # change silently never releases and the omission is only discovered + # when a consumer notices missing content. A rule this load-bearing + # must not depend on remembering a checklist item. + set -uo pipefail + status=0 + # Everything the deploy scripts copy into a target repository. + shipped="core standards playbooks + scripts/deploy.sh scripts/deploy.ps1 + scripts/update.sh scripts/update.ps1 + scripts/migrate.sh scripts/migrate.ps1 + scripts/lib/common.sh scripts/lib/common.ps1" + for path in $shipped; do + if [ -d "$path" ]; then + probe="$path/__probe__.md" + else + probe="$path" + fi + printf '%s\n' "$probe" > /tmp/probe.txt + result="$(scripts/ci/next-version.sh --changed-files /tmp/probe.txt --subject 'fix: probe' --latest-tag v1.0.0)" + if [ "$result" = "none" ]; then + echo "::error file=scripts/ci/next-version.sh::'$probe' is deployed to consumers but is_deployable does not match it, so changing it would never cut a release." + status=1 + fi + done + # And the inverse: non-deployable paths must not trigger releases. + for path in README.md AGENTS.md .github/workflows/release.yml scripts/ci/next-version.sh scripts/tests/test-deploy.sh; do + printf '%s\n' "$path" > /tmp/probe.txt + result="$(scripts/ci/next-version.sh --changed-files /tmp/probe.txt --subject 'fix: probe' --latest-tag v1.0.0)" + if [ "$result" != "none" ]; then + echo "::error file=scripts/ci/next-version.sh::'$path' never reaches a consumer but is_deployable matches it, so documentation and CI changes would cut releases." + status=1 + fi + done + [ "$status" -eq 0 ] && echo "OK: deployable paths and is_deployable agree." + exit $status + test: name: ${{ matrix.name }} strategy: diff --git a/.github/workflows/pr-title.yml b/.github/workflows/pr-title.yml index ce5db03..360d430 100644 --- a/.github/workflows/pr-title.yml +++ b/.github/workflows/pr-title.yml @@ -20,10 +20,21 @@ jobs: name: Conventional Commit title runs-on: ubuntu-latest steps: - - uses: amannn/action-semantic-pull-request@v5 + # Pinned to a full commit SHA, not a mutable tag: a compromised or + # retargeted "v5" tag would otherwise execute here. Dependabot raises the + # upgrade, so pinning costs nothing in currency. + - uses: amannn/action-semantic-pull-request@0723387faaf9b38adef4775cd42cfd5155ed6017 # v5.5.3 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: + # This list is PR hygiene, not the bump decision. It keeps changelog + # groups meaningful by rejecting invented types before merge. + # + # next-version.sh deliberately does NOT share this list: it applies a + # patch floor to any unrecognised type, so a deployable change that + # reaches main by some route this lint never saw - a direct push, or + # a non-squash merge - still cuts a release instead of being lost. + # The lint prevents unknown types; the floor makes them harmless. types: | feat fix diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 86d1598..bb1622a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -57,9 +57,32 @@ jobs: run: | set -euo pipefail subject="$(git log -1 --pretty=format:'%s')" + # Pass the whole message, not just the subject: Conventional Commits + # puts BREAKING CHANGE in the footer, so a subject-only read ships a + # breaking change as a patch to consumers pinned to a major line. + git log -1 --pretty=format:'%B' > /tmp/commit-body.txt current="$(tr -d ' \t\r\n' < VERSION)" prev_tag="$(git tag --list 'v*' --sort=-v:refname | head -n 1 || true)" - next="$(scripts/ci/next-version.sh --changed-files /tmp/changed-files.txt --subject "$subject" --latest-tag "$prev_tag")" + + # The bump size is read from the merge subject, which equals the PR + # title only under squash-merge. Under a merge commit the subject is + # "Merge pull request #..." and under rebase-merge it is the last + # commit's subject, so pr-title.yml would be validating a string that + # never reaches this job. Warn loudly rather than silently defaulting + # to a patch, which would understate a feat or a breaking change. + if ! printf '%s' "$subject" | grep -Eq '^[a-zA-Z]+(\([^)]*\))?!?:'; then + echo "::warning::Merge subject '$subject' is not a Conventional Commit. \ + The repository must use squash-merge so the PR title becomes the merge subject; \ + falling back to the patch floor." + { + echo "> **Merge subject is not a Conventional Commit.**" + echo "> \`$subject\`" + echo ">" + echo "> Bump size fell back to the patch floor. Enable squash-merge only." + } >> "$GITHUB_STEP_SUMMARY" + fi + + next="$(scripts/ci/next-version.sh --changed-files /tmp/changed-files.txt --subject "$subject" --body-file /tmp/commit-body.txt --latest-tag "$prev_tag")" echo "current=$current" >> "$GITHUB_OUTPUT" echo "next=$next" >> "$GITHUB_OUTPUT" echo "prev_tag=$prev_tag" >> "$GITHUB_OUTPUT" diff --git a/.github/workflows/version-gate.yml b/.github/workflows/version-gate.yml index 7009b58..2d4ebb4 100644 --- a/.github/workflows/version-gate.yml +++ b/.github/workflows/version-gate.yml @@ -34,7 +34,12 @@ jobs: HEAD_SHA: ${{ github.event.pull_request.head.sha }} run: | set -euo pipefail - git diff --name-only "$BASE_SHA" "$HEAD_SHA" > /tmp/changed-files.txt + # Three-dot: compare against the merge base, not the tip of main. + # Two-dot would attribute commits that landed on main while this pull + # request was open - a release commit bumping VERSION, say - to this + # pull request, producing a false "hand-edit to a CI-owned file" + # rejection and a wrong changed-file set for the version computation. + git diff --name-only "$BASE_SHA...$HEAD_SHA" > /tmp/changed-files.txt echo "Changed files:" sed 's/^/ /' /tmp/changed-files.txt @@ -65,12 +70,18 @@ jobs: fi # Baselines are published per release so migrate can tell a pristine - # file from an edited one. A hand-written baseline would silently - # corrupt every future migration, so once any baseline exists only - # the release job may add or change one. - if grep -q '^scripts/baselines/' /tmp/changed-files.txt; then + # file from an edited one. A hand-written per-release baseline would + # silently corrupt every future migration, so once any baseline + # exists only the release job may add or change one. + # + # scripts/baselines/unversioned.sha256 is the documented exception: + # it records the pre-versioning content that adopters actually + # deployed, CI can never write it (write-baseline.sh takes only valid + # SemVer), and migrate correctness depends on it being correctable. + if grep '^scripts/baselines/' /tmp/changed-files.txt \ + | grep -qv '^scripts/baselines/unversioned\.sha256$'; then if git ls-tree -r --name-only "$BASE_SHA" -- scripts/baselines | grep -q .; then - echo "::error::scripts/baselines/ is published by the release workflow. Do not add or edit baseline files by hand." + echo "::error::scripts/baselines/.sha256 is published by the release workflow. Do not add or edit per-release baseline files by hand." fail=1 fi fi @@ -80,6 +91,26 @@ jobs: fi exit "$fail" + - name: Require the breaking-change marker in the title + env: + PR_TITLE: ${{ github.event.pull_request.title }} + PR_BODY: ${{ github.event.pull_request.body }} + run: | + set -euo pipefail + # The release job reads BREAKING CHANGE from the full commit message, + # but this gate only ever sees the pull request title. A body that + # declares a breaking change while the title omits "!" would compute + # one version here and a different one on merge, and would ship a + # breaking change to consumers who pinned a major line precisely so + # that cannot happen. Require the marker in the title instead. + if printf '%s' "$PR_BODY" | grep -Eq '(^|[^[:alnum:]])BREAKING[ -]CHANGE'; then + if ! printf '%s' "$PR_TITLE" | grep -Eq '^[a-zA-Z]+(\([^)]*\))?!:'; then + echo "::error::The description declares a BREAKING CHANGE but the title has no '!' marker. Retitle as 'type!: ...' so the release cuts a major." + exit 1 + fi + fi + echo "OK: breaking-change marking is consistent." + - name: Compute the version this merge will cut id: next env: diff --git a/MIGRATIONS.md b/MIGRATIONS.md index 67f474e..1d728cd 100644 --- a/MIGRATIONS.md +++ b/MIGRATIONS.md @@ -33,9 +33,12 @@ and no conflicts. Because overrides sit outside it, your customisations survive untouched. Divergence becomes structurally impossible rather than something you have to detect and reconcile. -Your `AGENTS.md` gains a managed block delimited by -`` and ``. Only that -block is rewritten on update; everything above and below it is yours. +Your `AGENTS.md` gains a managed block. The start marker carries the version it +was written from, so it looks like ``, and +the block ends with ``. Both markers are required: +tooling that finds a begin marker without a matching end marker treats the file +as unmanaged and leaves it alone rather than guessing where the block stops. +Only that block is rewritten on update; everything above and below it is yours. ### Migrating diff --git a/scripts/ci/next-version.sh b/scripts/ci/next-version.sh index b194782..05aea09 100755 --- a/scripts/ci/next-version.sh +++ b/scripts/ci/next-version.sh @@ -28,6 +28,8 @@ REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" CHANGED_FILES="" SUBJECT="" +BODY="" +BODY_FILE="" CURRENT="" LATEST_TAG="" LATEST_TAG_SET=0 @@ -36,12 +38,19 @@ while [ $# -gt 0 ]; do case "$1" in --changed-files) CHANGED_FILES="${2:-}"; shift 2 ;; --subject) SUBJECT="${2:-}"; shift 2 ;; + --body) BODY="${2:-}"; shift 2 ;; + --body-file) BODY_FILE="${2:-}"; shift 2 ;; --current) CURRENT="${2:-}"; shift 2 ;; --latest-tag) LATEST_TAG="${2:-}"; LATEST_TAG_SET=1; shift 2 ;; *) echo "ERROR: unknown argument '$1'" >&2; exit 2 ;; esac done +if [ -n "$BODY_FILE" ]; then + [ -f "$BODY_FILE" ] || { echo "ERROR: no such file: $BODY_FILE" >&2; exit 2; } + BODY="$(cat "$BODY_FILE")" +fi + [ -n "$CHANGED_FILES" ] || { echo "ERROR: --changed-files is required." >&2; exit 2; } [ -f "$CHANGED_FILES" ] || { echo "ERROR: no such file: $CHANGED_FILES" >&2; exit 2; } @@ -98,15 +107,22 @@ fi # deployable change is a release regardless of how its author labelled it. An # unrecognised type therefore falls through to the patch floor rather than # blocking the release. +# Every pattern below is anchored to the leading type token. An unanchored +# match reads incidental prose as intent: "fix: cleanup foo!: bar" is a patch, +# not a major, and "feature flags: add toggle" is not a feat. BUMP="patch" -case "$SUBJECT" in - feat*) BUMP="minor" ;; -esac -case "$SUBJECT" in - # "type!: subject" and "type(scope)!: subject" both mark a breaking change. - *"!:"*) BUMP="major" ;; -esac -if printf '%s' "$SUBJECT" | grep -q 'BREAKING[ -]CHANGE'; then +if printf '%s' "$SUBJECT" | grep -Eq '^feat(\([^)]*\))?!?:'; then + BUMP="minor" +fi +# "type!: subject" and "type(scope)!: subject" both mark a breaking change. +if printf '%s' "$SUBJECT" | grep -Eq '^[a-zA-Z]+(\([^)]*\))?!:'; then + BUMP="major" +fi +# A BREAKING CHANGE footer is authoritative wherever it appears in the message. +# The release job passes the full commit body precisely so this can be seen; +# the gate only ever has the title, which is why it separately rejects a body +# that declares a breaking change without the "!" marker in the title. +if printf '%s' "$BODY" | grep -Eq '(^|[^[:alnum:]])BREAKING[ -]CHANGE'; then BUMP="major" fi diff --git a/scripts/ci/write-changelog.sh b/scripts/ci/write-changelog.sh index 79c710d..efe84d5 100755 --- a/scripts/ci/write-changelog.sh +++ b/scripts/ci/write-changelog.sh @@ -62,11 +62,21 @@ if [ -n "$BREAKING" ]; then echo "" >> "$SECTION" fi -emit_group '^feat(\([^)]*\))?!?: ' 'Features' -emit_group '^fix(\([^)]*\))?!?: ' 'Fixes' -emit_group '^(docs|refactor|perf|style)(\([^)]*\))?!?: ' 'Other changes' +# These patterns deliberately exclude "!": a breaking commit is already listed +# under "Breaking changes" above, and matching it again here would print it +# twice in the same release section. +emit_group '^feat(\([^)]*\))?: ' 'Features' +emit_group '^fix(\([^)]*\))?: ' 'Fixes' +emit_group '^(docs|refactor|perf|style)(\([^)]*\))?: ' 'Other changes' +# build/ci/chore/test/revert are grouped rather than dropped. Editing a deploy +# script under "chore:" is deployable and cuts a release, so without this the +# release notes for a real change to shipped tooling would be empty. +emit_group '^(build|ci|chore|test|revert)(\([^)]*\))?: ' 'Maintenance' -if [ ! -s "$RAW" ]; then +# Fall back when nothing was written, not merely when there were no commits at +# all: a release whose commits all fell outside every group above would +# otherwise publish a heading with no content under it. +if [ "$(wc -l < "$SECTION" | tr -d ' ')" -le 2 ]; then echo "- No user-facing changes recorded." >> "$SECTION" echo "" >> "$SECTION" fi diff --git a/scripts/deploy.sh b/scripts/deploy.sh index ff97c3e..c0d5852 100755 --- a/scripts/deploy.sh +++ b/scripts/deploy.sh @@ -171,17 +171,8 @@ copy_dir_contents() { # framework owns only the region between the begin/end markers. Rewriting just # that region is the only safe way to refresh a file we do not own. -AC_BEGIN_MARKER='' - -# Return 0 when the file contains a well-formed managed block. -has_managed_block() { - local file="$1" - [ -f "$file" ] || return 1 - grep -q "^$AC_BEGIN_MARKER" "$file" 2>/dev/null || return 1 - grep -qF "$AC_END_MARKER" "$file" 2>/dev/null || return 1 - return 0 -} +# The markers and the well-formed-block test live in scripts/lib/common.sh so +# deploy and update cannot disagree about what counts as a managed block. # Print the managed block (markers included) from the source template. extract_managed_block() { @@ -233,7 +224,7 @@ deploy_agents_md() { return 0 fi - if has_managed_block "$dst"; then + if ac_has_managed_block "$dst"; then replace_managed_block "$src" "$dst" "$version" echo " AGENTS.md: refreshed managed block (your content preserved)" return 0 diff --git a/scripts/lib/common.ps1 b/scripts/lib/common.ps1 index 406d5c2..66e7200 100644 --- a/scripts/lib/common.ps1 +++ b/scripts/lib/common.ps1 @@ -212,7 +212,12 @@ function Write-AcManifest { [Parameter(Mandatory)][string]$Version, [string[]]$Agents = @(), [string]$SourceRepo = $script:AcSourceRepo, - [string]$CheckFrequency = 'weekly' + [string]$CheckFrequency = 'weekly', + # The pin is consumer configuration. Callers updating an existing + # deployment must pass the pin already recorded there, or a deliberate + # choice ("*" to accept majors, or an exact version to freeze) is + # silently reset to the new version's major line on every apply. + [string]$Pin = '' ) if (-not (Test-Path -LiteralPath $ContextDir)) { @@ -227,7 +232,7 @@ function Write-AcManifest { schema = 1 version = $Version source = $SourceRepo - pin = "$(Get-AcSemVerPart $Version 'Major').x" + pin = $(if ([string]::IsNullOrWhiteSpace($Pin)) { "$(Get-AcSemVerPart $Version 'Major').x" } else { $Pin }) checkFrequency = $CheckFrequency deployedAt = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ') agents = @($Agents) diff --git a/scripts/lib/common.sh b/scripts/lib/common.sh index 7fa9691..a67eae1 100644 --- a/scripts/lib/common.sh +++ b/scripts/lib/common.sh @@ -216,9 +216,48 @@ ac_hash_source_tree() { } | LC_ALL=C sort } +# Hash a text file with line endings normalised to LF. +# +# Baselines are generated on LF checkouts but compared against a consumer's +# working tree. A Windows checkout with core.autocrlf=true has CRLF in every +# file, so a byte hash would report every framework file as modified when the +# consumer has changed nothing. Only used for cross-machine comparison; the +# manifest keeps byte-exact hashes, which are always written and read on the +# same machine. +ac_sha256_lf() { + local file="$1" + if command -v shasum >/dev/null 2>&1; then + tr -d '\r' < "$file" | shasum -a 256 | awk '{print $1}' + elif command -v sha256sum >/dev/null 2>&1; then + tr -d '\r' < "$file" | sha256sum | awk '{print $1}' + else + echo "ERROR: neither shasum nor sha256sum is available" >&2 + return 1 + fi +} + # Look up one path's expected hash in a baseline file. ac_baseline_lookup() { local baseline="$1" path="$2" [ -f "$baseline" ] || return 1 awk -v p="$path" '$1 == p { print $2; found = 1; exit } END { exit !found }' "$baseline" } + +# --- managed block --------------------------------------------------------- +# +# The consumer owns AGENTS.md; the framework owns only the region between these +# markers. Both markers are required. A begin marker with no end marker is +# malformed, and treating it as a block would swallow every line to EOF - which +# in AGENTS.md is the consumer's own configuration. +AC_BEGIN_MARKER='' + +# Return 0 only when the file contains a well-formed (begin AND end) block. +# Mirrors Test-AcManagedBlock in common.ps1; the two must stay equivalent. +ac_has_managed_block() { + local file="$1" + [ -f "$file" ] || return 1 + grep -q "^$AC_BEGIN_MARKER" "$file" 2>/dev/null || return 1 + grep -qF "$AC_END_MARKER" "$file" 2>/dev/null || return 1 + return 0 +} diff --git a/scripts/migrate.sh b/scripts/migrate.sh index 067cc22..32449f4 100755 --- a/scripts/migrate.sh +++ b/scripts/migrate.sh @@ -12,7 +12,11 @@ # # Portability: macOS bash 3.2 with BSD userland, and Linux with GNU userland. -set -uo pipefail +# Exit on first error. This script rewrites files in a repository it did not +# create, so a failed copy must stop the run rather than let it complete with a +# partially migrated tree. Unlike update.sh there is no network call here, so +# there is nothing that needs to fail open. +set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" @@ -119,14 +123,18 @@ echo "" DIVERGED_LIST="$(mktemp)" MISSING_LIST="$(mktemp)" -cleanup() { rm -f "$DIVERGED_LIST" "$MISSING_LIST"; } +TOTAL_LIST="$(mktemp)" +NONMD_LIST="$(mktemp)" +cleanup() { rm -f "$DIVERGED_LIST" "$MISSING_LIST" "$TOTAL_LIST" "$NONMD_LIST"; } trap cleanup EXIT INT TERM for area in standards playbooks conventions; do [ -d "$CONTEXT_DIR/$area" ] || continue find "$CONTEXT_DIR/$area" -type f -name '*.md' 2>/dev/null | LC_ALL=C sort | while IFS= read -r f; do rel="$area/${f#"$CONTEXT_DIR"/"$area"/}" - actual="$(ac_sha256 "$f")" + printf '%s\n' "$rel" >> "$TOTAL_LIST" + # LF-normalised: a CRLF checkout must not make every file look edited. + actual="$(ac_sha256_lf "$f")" if expected="$(ac_baseline_lookup "$BASELINE" "$rel")"; then if [ "$expected" != "$actual" ]; then printf '%s\n' "$rel" >> "$DIVERGED_LIST" @@ -138,10 +146,40 @@ for area in standards playbooks conventions; do done done +# Non-markdown files. The baseline only covers .md, but the restore below +# replaces each area wholesale, so anything else here is destroyed unless it is +# recognised. A file that also exists in the source tree is a framework +# companion (playbooks/setup ships shell scripts) and is restored intact; one +# that does not is the consumer's own and must be preserved as an override. +for pair in "standards:$SOURCE_ROOT/standards" "playbooks:$SOURCE_ROOT/playbooks" "conventions:$SOURCE_ROOT/core/.context/conventions"; do + area="${pair%%:*}" + from="${pair#*:}" + [ -d "$CONTEXT_DIR/$area" ] || continue + find "$CONTEXT_DIR/$area" -type f ! -name '*.md' 2>/dev/null | LC_ALL=C sort | while IFS= read -r f; do + rel="${f#"$CONTEXT_DIR"/"$area"/}" + [ -f "$from/$rel" ] || printf '%s/%s\n' "$area" "$rel" >> "$NONMD_LIST" + done +done + diverged_count=$(wc -l < "$DIVERGED_LIST" | tr -d ' ') added_count=$(wc -l < "$MISSING_LIST" | tr -d ' ') +nonmd_count=$(wc -l < "$NONMD_LIST" | tr -d ' ') +total_count=$(wc -l < "$TOTAL_LIST" | tr -d ' ') + +# Backstop. Every single file differing is not a real editing pattern - it is +# the signature of a systemic mismatch (wrong baseline, or an encoding or +# line-ending transform). Promoting them all would turn a pristine deployment +# into a total fork, pinning every file with "mode: replace" so no upstream +# improvement ever reaches it again. Refuse rather than do that silently. +if [ "$total_count" -gt 1 ] && [ "$diverged_count" -eq "$total_count" ]; then + echo "ERROR: every one of the $total_count base files differs from the baseline." >&2 + echo " That is a systemic mismatch, not consumer edits - check the baseline" >&2 + echo " version (--from) and that the checkout has not rewritten line endings." >&2 + echo " Refusing to promote every file into overrides." >&2 + exit 1 +fi -if [ "$diverged_count" -eq 0 ] && [ "$added_count" -eq 0 ]; then +if [ "$diverged_count" -eq 0 ] && [ "$added_count" -eq 0 ] && [ "$nonmd_count" -eq 0 ]; then echo "No local modifications detected — this deployment is pristine." else if [ "$diverged_count" -gt 0 ]; then @@ -160,8 +198,22 @@ else done < "$MISSING_LIST" echo "" fi + if [ "$nonmd_count" -gt 0 ]; then + echo "Non-markdown files you added ($nonmd_count) — will move to overrides:" + while IFS= read -r rel; do + [ -n "$rel" ] || continue + echo " $rel -> .context/overrides/$rel" + done < "$NONMD_LIST" + echo "" + fi fi +# State the destructive behaviour before it happens, not after. +echo "This migration replaces .context/{standards,playbooks,conventions} wholesale." +echo "Anything listed above is preserved under .context/overrides/. A base file you" +echo "deleted is restored, because the base is owned by the library, not by you." +echo "" + if [ "$APPLY" -eq 0 ]; then echo "Nothing written. Re-run with --apply to perform the migration." exit 0 @@ -207,6 +259,16 @@ while IFS= read -r rel; do rm -f "$CONTEXT_DIR/$rel" done < "$MISSING_LIST" +# Consumer-owned non-markdown files: copied verbatim, with no frontmatter - +# they are not markdown, so a YAML header would corrupt them. +while IFS= read -r rel; do + [ -n "$rel" ] || continue + dst="$CONTEXT_DIR/overrides/$rel" + mkdir -p "$(dirname "$dst")" + cat "$CONTEXT_DIR/$rel" > "$dst" + rm -f "$CONTEXT_DIR/$rel" +done < "$NONMD_LIST" + echo "Restoring base content from $SOURCE_ROOT ..." for pair in "standards:$SOURCE_ROOT/standards" "playbooks:$SOURCE_ROOT/playbooks" "conventions:$SOURCE_ROOT/core/.context/conventions"; do name="${pair%%:*}" diff --git a/scripts/tests/test-deploy.sh b/scripts/tests/test-deploy.sh index a524e2c..33cc83a 100755 --- a/scripts/tests/test-deploy.sh +++ b/scripts/tests/test-deploy.sh @@ -482,6 +482,154 @@ fi rm -f "$TC9_LIST" +# ═══════════════════════════════════════════════════════════════════════ +# TC10: managed block safety +# +# These cover the destructive rewrite of a file the framework does not own. +# A begin marker with no end marker must NOT be treated as a block: the +# rewrite would otherwise swallow every line to EOF, destroying the consumer +# configuration the block exists to protect. +# ═══════════════════════════════════════════════════════════════════════ +echo "" +echo "=== TC10: managed block detection ===" + +# shellcheck source=scripts/lib/common.sh +. "$SCRIPTS_DIR/lib/common.sh" + +TC10_DIR=$(mktemp -d) + +printf '\nF\n\n\n## MINE\nkeep\n' > "$TC10_DIR/well-formed.md" +printf '\nF\n\n## MINE\nkeep\n' > "$TC10_DIR/no-end.md" +printf '## MINE\nkeep\n' > "$TC10_DIR/no-block.md" +printf '\r\nF\r\n\r\n' > "$TC10_DIR/crlf.md" + +if ac_has_managed_block "$TC10_DIR/well-formed.md"; then + pass "managed block: well-formed file is recognised" +else + fail "managed block: well-formed file was not recognised" +fi + +if ac_has_managed_block "$TC10_DIR/no-end.md"; then + fail "managed block: begin-without-end was treated as a block (would truncate consumer content)" +else + pass "managed block: begin-without-end is rejected" +fi + +if ac_has_managed_block "$TC10_DIR/no-block.md"; then + fail "managed block: a file with no markers was treated as a block" +else + pass "managed block: file with no markers is rejected" +fi + +if ac_has_managed_block "$TC10_DIR/missing-entirely.md"; then + fail "managed block: a missing file was treated as a block" +else + pass "managed block: missing file is rejected" +fi + +rm -rf "$TC10_DIR" + +# ═══════════════════════════════════════════════════════════════════════ +# TC11: migrate promotes divergence and restores the base +# +# migrate rewrites a repository it did not create, so its behaviour is +# asserted rather than assumed: edits become overrides, consumer additions +# survive, and base content is restored pristine. +# ═══════════════════════════════════════════════════════════════════════ +echo "" +echo "=== TC11: migrate ===" + +TC11_DIR=$(mktemp -d) +( + cd "$TC11_DIR" && git init -q . && git config user.email t@t && git config user.name t +) +mkdir -p "$TC11_DIR/.context/standards" "$TC11_DIR/.context/conventions" +cp "$REPO_DIR/standards/security.md" "$TC11_DIR/.context/standards/" +cp "$REPO_DIR/core/.context/conventions/code.md" "$TC11_DIR/.context/conventions/" +echo "MY LOCAL EDIT" >> "$TC11_DIR/.context/standards/security.md" +echo "mine" > "$TC11_DIR/.context/standards/my-own.md" +printf '{"k":1}\n' > "$TC11_DIR/.context/standards/fixture.json" +(cd "$TC11_DIR" && git add -A >/dev/null 2>&1 && git commit -qm init >/dev/null 2>&1) + +TC11_OUT="$("$SCRIPTS_DIR/migrate.sh" --apply "$TC11_DIR" 2>&1)" || true + +if [ -f "$TC11_DIR/.context/overrides/standards/security.md" ] \ + && grep -q 'MY LOCAL EDIT' "$TC11_DIR/.context/overrides/standards/security.md"; then + pass "migrate: edited base file promoted to overrides with content intact" +else + fail "migrate: edited base file was not promoted (output: $TC11_OUT)" +fi + +if grep -q 'mode: replace' "$TC11_DIR/.context/overrides/standards/security.md" 2>/dev/null; then + pass "migrate: promoted override carries mode: replace frontmatter" +else + fail "migrate: promoted override is missing frontmatter" +fi + +if [ -f "$TC11_DIR/.context/overrides/standards/my-own.md" ]; then + pass "migrate: consumer-added markdown preserved" +else + fail "migrate: consumer-added markdown was lost" +fi + +# Regression: the restore wipes each area wholesale, so a non-markdown file the +# consumer added must be moved out first or it is destroyed silently. +if [ -f "$TC11_DIR/.context/overrides/standards/fixture.json" ]; then + pass "migrate: consumer-added non-markdown preserved" +else + fail "migrate: consumer-added non-markdown was destroyed by the restore" +fi + +if [ -f "$TC11_DIR/.context/standards/security.md" ] \ + && ! grep -q 'MY LOCAL EDIT' "$TC11_DIR/.context/standards/security.md"; then + pass "migrate: base file restored pristine" +else + fail "migrate: base file was not restored pristine" +fi + +rm -rf "$TC11_DIR" + +# ═══════════════════════════════════════════════════════════════════════ +# TC12: migrate refuses a systemic mismatch +# +# A CRLF checkout makes every file hash differently. Classifying all of them +# as consumer edits would silently convert a pristine deployment into a total +# fork, so line endings are normalised and an all-files-differ result is +# refused outright. +# ═══════════════════════════════════════════════════════════════════════ +echo "" +echo "=== TC12: migrate line endings ===" + +TC12_DIR=$(mktemp -d) +( + cd "$TC12_DIR" && git init -q . && git config user.email t@t && git config user.name t +) +mkdir -p "$TC12_DIR/.context/standards" +for f in security.md testing.md code-quality.md; do + sed 's/$/\r/' "$REPO_DIR/standards/$f" > "$TC12_DIR/.context/standards/$f" +done +(cd "$TC12_DIR" && git add -A >/dev/null 2>&1 && git commit -qm init >/dev/null 2>&1) + +TC12_OUT="$("$SCRIPTS_DIR/migrate.sh" "$TC12_DIR" 2>&1)" || true +if printf '%s' "$TC12_OUT" | grep -q 'pristine'; then + pass "migrate: CRLF checkout is not misread as wholesale divergence" +else + fail "migrate: CRLF checkout reported as diverged (output: $TC12_OUT)" +fi + +# And the backstop itself: a genuinely wrong baseline must refuse, not promote. +TC12_BAD="$(mktemp)" +printf 'standards/security.md %s\n' "0000000000000000000000000000000000000000000000000000000000000000" > "$TC12_BAD" +printf 'standards/testing.md %s\n' "0000000000000000000000000000000000000000000000000000000000000000" >> "$TC12_BAD" +printf 'standards/code-quality.md %s\n' "0000000000000000000000000000000000000000000000000000000000000000" >> "$TC12_BAD" +if "$SCRIPTS_DIR/migrate.sh" --baseline "$TC12_BAD" "$TC12_DIR" >/dev/null 2>&1; then + fail "migrate: a baseline matching nothing was accepted (would fork every file)" +else + pass "migrate: refuses when every file differs from the baseline" +fi +rm -f "$TC12_BAD" +rm -rf "$TC12_DIR" + # ═══════════════════════════════════════════════════════════════════════ # Summary # ═══════════════════════════════════════════════════════════════════════ diff --git a/scripts/update.ps1 b/scripts/update.ps1 index eebc4f5..c487856 100644 --- a/scripts/update.ps1 +++ b/scripts/update.ps1 @@ -147,7 +147,9 @@ if ($mode -eq 'check') { if ($pinOk) { Write-Host "agentic-context update available: $LocalVersion -> $Latest (run .context/bin/update.ps1 -Apply)" } else { - Write-Host "agentic-context $Latest is available but outside your pin '$Pin'. See MIGRATIONS.md, then use -Force." + # Link, do not name the file: MIGRATIONS.md is not deployed into + # consumer repositories, so naming it points at something they lack. + Write-Host "agentic-context $Latest is available but outside your pin '$Pin'. See $script:AcWebBase/$SourceRepo/blob/v$Latest/MIGRATIONS.md, then use -Force." } if ($Quiet) { exit 0 } Show-AcLocalState @@ -157,7 +159,7 @@ if ($mode -eq 'check') { # --- apply ----------------------------------------------------------------- if (-not $pinOk) { - Write-Error "Refusing to update: $Latest is outside the pin '$Pin'. This is a major upgrade. Read MIGRATIONS.md, then re-run with -Force." + Write-Error "Refusing to update: $Latest is outside the pin '$Pin'. This is a major upgrade. Read $script:AcWebBase/$SourceRepo/blob/v$Latest/MIGRATIONS.md, then re-run with -Force." exit 1 } @@ -262,7 +264,7 @@ try { if ($manifest -and $manifest.agents) { $agents = @($manifest.agents) } Write-AcManifest -ContextDir $ContextDir -Version $Latest -Agents $agents ` - -SourceRepo $SourceRepo -CheckFrequency $Freq + -SourceRepo $SourceRepo -CheckFrequency $Freq -Pin $Pin Set-Content -LiteralPath (Join-Path $ContextDir 'VERSION') -Value $Latest -Encoding UTF8 diff --git a/scripts/update.sh b/scripts/update.sh index 0dc233f..f93214e 100755 --- a/scripts/update.sh +++ b/scripts/update.sh @@ -191,7 +191,9 @@ if [ "$MODE" = "check" ]; then if [ "$PIN_OK" -eq 1 ]; then echo "agentic-context update available: $LOCAL_VERSION -> $LATEST (run .context/bin/update.sh --apply)" else - echo "agentic-context $LATEST is available but outside your pin '$PIN'. See MIGRATIONS.md, then use --force." + # Link, do not name the file: MIGRATIONS.md is not deployed into consumer + # repositories, so "see MIGRATIONS.md" points at something they do not have. + echo "agentic-context $LATEST is available but outside your pin '$PIN'. See $AC_WEB_BASE/$SOURCE_REPO/blob/v$LATEST/MIGRATIONS.md, then use --force." fi [ "$QUIET" -eq 1 ] && exit 0 report_local_state @@ -202,7 +204,7 @@ fi if [ "$PIN_OK" -ne 1 ]; then echo "Refusing to update: $LATEST is outside the pin '$PIN'." >&2 - echo "This is a major upgrade. Read MIGRATIONS.md, then re-run with --force." >&2 + echo "This is a major upgrade. Read $AC_WEB_BASE/$SOURCE_REPO/blob/v$LATEST/MIGRATIONS.md, then re-run with --force." >&2 exit 1 fi @@ -241,16 +243,38 @@ for rel in $(ac_hash_context_tree "$CONTEXT_DIR" | sed 's/ .*//'); do done # Replace base trees wholesale. Overrides are untouched by construction. +# This is the destructive step: each area is deleted before its replacement is +# written. The script deliberately does not run under "set -e" (check and status +# must fail open), so every operation here is checked explicitly. Without this a +# failed extract would leave a half-deleted .context/ and still exit zero, while +# the same failure on PowerShell stops cleanly - a parity break at the one point +# where it does real damage. +partial_apply() { + echo "ERROR: $1" >&2 + echo " .context/ may be partially updated. Restore with:" >&2 + echo " git -C \"$TARGET_ROOT\" checkout -- .context" >&2 + exit 1 +} + +# Validate the whole payload before deleting anything. +for pair in "standards:$SRC/standards" "playbooks:$SRC/playbooks" "conventions:$SRC/core/.context/conventions"; do + from="${pair#*:}" + [ -d "$from" ] || partial_apply "downloaded archive is missing ${pair%%:*}/ - nothing was changed." +done + for pair in "standards:$SRC/standards" "playbooks:$SRC/playbooks" "conventions:$SRC/core/.context/conventions"; do name="${pair%%:*}" from="${pair#*:}" - [ -d "$from" ] || continue - rm -rf "${CONTEXT_DIR:?}/$name" - mkdir -p "$CONTEXT_DIR/$name" - (cd "$from" && tar cf - .) | (cd "$CONTEXT_DIR/$name" && tar xf -) + rm -rf "${CONTEXT_DIR:?}/$name" || partial_apply "could not remove $name/." + mkdir -p "$CONTEXT_DIR/$name" || partial_apply "could not create $name/." + if ! (cd "$from" && tar cf - .) | (cd "$CONTEXT_DIR/$name" && tar xf -); then + partial_apply "could not write $name/." + fi done -[ -f "$SRC/core/.context/index.md" ] && cp "$SRC/core/.context/index.md" "$CONTEXT_DIR/index.md" +if [ -f "$SRC/core/.context/index.md" ]; then + cp "$SRC/core/.context/index.md" "$CONTEXT_DIR/index.md" || partial_apply "could not write index.md." +fi # Refresh the update tooling itself, so a fixed updater reaches consumers. mkdir -p "$CONTEXT_DIR/bin/lib" @@ -258,12 +282,21 @@ for tool in update.sh update.ps1 migrate.sh migrate.ps1; do [ -f "$SRC/scripts/$tool" ] && cp "$SRC/scripts/$tool" "$CONTEXT_DIR/bin/$tool" done [ -f "$SRC/scripts/lib/common.sh" ] && cp "$SRC/scripts/lib/common.sh" "$CONTEXT_DIR/bin/lib/common.sh" +# Both libraries, not just this platform's. A deployment updated from bash - a +# Linux CI runner, or one bash user on a mixed team - would otherwise pair the +# freshly downloaded update.ps1 with a stale common.ps1 forever, so a bug fixed +# in the PowerShell library could never reach that repo's Windows users. +[ -f "$SRC/scripts/lib/common.ps1" ] && cp "$SRC/scripts/lib/common.ps1" "$CONTEXT_DIR/bin/lib/common.ps1" chmod +x "$CONTEXT_DIR/bin"/*.sh 2>/dev/null || true # Refresh only the managed block in AGENTS.md. AGENTS_FILE="$TARGET_ROOT/AGENTS.md" if [ -f "$AGENTS_FILE" ] && [ -f "$SRC/core/AGENTS.md" ]; then - if grep -q '^' ' index($0, b) == 1 { inblock = 1 } @@ -285,7 +318,7 @@ if [ -f "$AGENTS_FILE" ] && [ -f "$SRC/core/AGENTS.md" ]; then cat "$WORK_DIR/agents.md" > "$AGENTS_FILE" echo " AGENTS.md: managed block refreshed; your content preserved." else - echo " AGENTS.md: no managed block found — left untouched. Merge manually if required." + echo " AGENTS.md: no well-formed managed block (needs both the begin and end marker) — left untouched. Merge manually if required." fi fi @@ -296,12 +329,18 @@ agents_json="$(sed -n 's/.*"agents"[[:space:]]*:[[:space:]]*\(\[[^]]*\]\).*/\1/p freq="$(ac_manifest_get "$MANIFEST" checkFrequency)" [ -n "$freq" ] || freq="weekly" +pin="$(ac_manifest_get "$MANIFEST" pin)" +# The pin is consumer configuration, not derived state. Recomputing it from the +# new version would silently undo a deliberate choice - "*" to accept majors, or +# an exact version to freeze - and change how every future check behaves. +[ -n "$pin" ] || pin="$(ac_semver_major "$LATEST").x" + { printf '{\n' printf ' "schema": 1,\n' printf ' "version": "%s",\n' "$LATEST" printf ' "source": "%s",\n' "$SOURCE_REPO" - printf ' "pin": "%s",\n' "$(ac_semver_major "$LATEST").x" + printf ' "pin": "%s",\n' "$pin" printf ' "checkFrequency": "%s",\n' "$freq" printf ' "deployedAt": "%s",\n' "$now" printf ' "agents": %s,\n' "$agents_json" From 189368bfd1d7d56458e7aed1a4a69f768ce2c46f Mon Sep 17 00:00:00 2001 From: ldastey-dev Date: Sun, 6 Sep 2026 21:14:16 +0100 Subject: [PATCH 13/20] fix(powershell): restore parity with the bash migrate and update fixes The previous commit fixed three defects in the bash tooling but left the PowerShell twins untouched, so Windows consumers kept every one of them. Behaviour equivalence between the pairs is a non-negotiable in AGENTS.md. - migrate.ps1 hashed bytes against an LF baseline, so a CRLF checkout saw every file as edited and promoted the whole deployment into "mode: replace" overrides - a silent permanent fork that never receives another upstream improvement. Hashing is now LF-normalised via a new Get-AcFileHashLf, which mirrors ac_sha256_lf and is verified to produce a byte-identical digest. - migrate.ps1 restored each area with a recursive delete but classified only *.md, so a consumer's non-markdown file under standards, playbooks or conventions was destroyed without ever being offered as an override. Such files are now preserved; files that also exist in the source tree are framework companions and are restored intact. - migrate.ps1 gained the all-files-differ backstop and the pre-run statement of destructive behaviour. - update.ps1 skipped a missing payload area and carried on, which could leave old base content in place while the manifest and VERSION still advanced, so the deployment reported a version it did not contain. The payload is now validated before anything is deleted. Both migrate implementations were run against the same fixtures and agree: an edited base file is promoted with frontmatter, consumer markdown and non-markdown survive, the base is restored pristine, a CRLF tree is correctly reported as pristine rather than diverged, and a genuinely all-edited tree is refused by the backstop in both. Adds 8 Pester assertions covering the hash equivalence and the migrate preservation matrix, since the absence of PowerShell coverage for these paths is why the defects were not caught. 28 pass, up from 20. The non-markdown assertion was confirmed to fail when the defect is reintroduced. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- scripts/deploy.Tests.ps1 | 92 ++++++++++++++++++++++++++++++++++++++++ scripts/lib/common.ps1 | 21 +++++++++ scripts/migrate.ps1 | 78 ++++++++++++++++++++++++++++++---- scripts/update.ps1 | 13 +++++- 4 files changed, 195 insertions(+), 9 deletions(-) diff --git a/scripts/deploy.Tests.ps1 b/scripts/deploy.Tests.ps1 index fda55d3..b4239a0 100644 --- a/scripts/deploy.Tests.ps1 +++ b/scripts/deploy.Tests.ps1 @@ -199,3 +199,95 @@ Describe 'Copy-SingleFile' { $firstBytes[0] | Should -Not -Be 0xEF } } + +Describe 'Get-AcFileHashLf' { + BeforeAll { + . (Join-Path (Split-Path -Parent $PSScriptRoot) 'scripts/lib/common.ps1') + $script:LfTmp = Join-Path ([System.IO.Path]::GetTempPath()) ("ac-lf-" + [guid]::NewGuid()) + New-Item -ItemType Directory -Path $script:LfTmp -Force | Out-Null + } + AfterAll { + if (Test-Path -LiteralPath $script:LfTmp) { Remove-Item -LiteralPath $script:LfTmp -Recurse -Force } + } + + # Baselines are generated on LF checkouts. Hashing a CRLF working tree + # byte-for-byte reports every file as edited, which made migrate promote a + # pristine deployment wholesale into "mode: replace" overrides - a silent + # permanent fork. The LF and CRLF forms of the same content must agree. + It 'returns the same hash for LF and CRLF forms of identical content' { + $lf = Join-Path $script:LfTmp 'lf.md' + $crlf = Join-Path $script:LfTmp 'crlf.md' + [System.IO.File]::WriteAllText($lf, "line one`nline two`n") + [System.IO.File]::WriteAllText($crlf, "line one`r`nline two`r`n") + + Get-AcFileHashLf -Path $crlf | Should -Be (Get-AcFileHashLf -Path $lf) + } + + # The byte-exact hash must still distinguish them, because manifest hashes + # rely on it for local change detection. + It 'differs from the byte-exact hash for CRLF content' { + $crlf = Join-Path $script:LfTmp 'crlf2.md' + [System.IO.File]::WriteAllText($crlf, "line one`r`nline two`r`n") + + Get-AcFileHashLf -Path $crlf | Should -Not -Be (Get-AcFileHash -Path $crlf) + } + + It 'still detects genuinely different content' { + $a = Join-Path $script:LfTmp 'a.md' + $b = Join-Path $script:LfTmp 'b.md' + [System.IO.File]::WriteAllText($a, "alpha`n") + [System.IO.File]::WriteAllText($b, "beta`n") + + Get-AcFileHashLf -Path $a | Should -Not -Be (Get-AcFileHashLf -Path $b) + } +} + +Describe 'migrate.ps1 (consumer content preservation)' { + BeforeAll { + $script:RepoRoot = Split-Path -Parent $PSScriptRoot + $script:MigTmp = Join-Path ([System.IO.Path]::GetTempPath()) ("ac-mig-" + [guid]::NewGuid()) + New-Item -ItemType Directory -Path (Join-Path $script:MigTmp '.context/standards') -Force | Out-Null + + Copy-Item -LiteralPath (Join-Path $script:RepoRoot 'standards/security.md') ` + -Destination (Join-Path $script:MigTmp '.context/standards/security.md') + Add-Content -LiteralPath (Join-Path $script:MigTmp '.context/standards/security.md') -Value 'MY LOCAL EDIT' + Set-Content -LiteralPath (Join-Path $script:MigTmp '.context/standards/my-own.md') -Value 'mine' + Set-Content -LiteralPath (Join-Path $script:MigTmp '.context/standards/fixture.json') -Value '{"k":1}' + + & pwsh -NoProfile -File (Join-Path $script:RepoRoot 'scripts/migrate.ps1') ` + -Target $script:MigTmp -Apply 2>&1 | Out-Null + } + AfterAll { + if (Test-Path -LiteralPath $script:MigTmp) { Remove-Item -LiteralPath $script:MigTmp -Recurse -Force } + } + + It 'promotes an edited base file into overrides with its content intact' { + $o = Join-Path $script:MigTmp '.context/overrides/standards/security.md' + Test-Path -LiteralPath $o | Should -BeTrue + (Get-Content -LiteralPath $o -Raw) | Should -Match 'MY LOCAL EDIT' + } + + It 'marks a promoted override with mode: replace' { + (Get-Content -LiteralPath (Join-Path $script:MigTmp '.context/overrides/standards/security.md') -Raw) | + Should -Match 'mode: replace' + } + + It 'preserves a consumer-added markdown file' { + Test-Path -LiteralPath (Join-Path $script:MigTmp '.context/overrides/standards/my-own.md') | + Should -BeTrue + } + + # The restore deletes each area wholesale, so a non-markdown file the + # consumer added is destroyed unless it is classified and moved out first. + # The baseline only covers *.md, so this needed handling separately. + It 'preserves a consumer-added non-markdown file' { + Test-Path -LiteralPath (Join-Path $script:MigTmp '.context/overrides/standards/fixture.json') | + Should -BeTrue + } + + It 'restores the base file pristine' { + $b = Join-Path $script:MigTmp '.context/standards/security.md' + Test-Path -LiteralPath $b | Should -BeTrue + (Get-Content -LiteralPath $b -Raw) | Should -Not -Match 'MY LOCAL EDIT' + } +} diff --git a/scripts/lib/common.ps1 b/scripts/lib/common.ps1 index 66e7200..2594bcd 100644 --- a/scripts/lib/common.ps1 +++ b/scripts/lib/common.ps1 @@ -18,6 +18,27 @@ function Get-AcFileHash { return $hash.Hash.ToLowerInvariant() } +# Hash with CR stripped, matching ac_sha256_lf in common.sh. Baselines are +# generated on LF checkouts, so comparing a CRLF working tree byte-for-byte +# reports every file as edited. Only cross-machine baseline comparisons use +# this; manifest hashes stay byte-exact because they are written and read on +# the same machine. +function Get-AcFileHashLf { + param([Parameter(Mandatory)][string]$Path) + $bytes = [System.IO.File]::ReadAllBytes($Path) + $out = New-Object System.Collections.Generic.List[byte] + foreach ($b in $bytes) { + if ($b -ne 13) { $out.Add($b) } + } + $sha = [System.Security.Cryptography.SHA256]::Create() + try { + $hash = $sha.ComputeHash($out.ToArray()) + } finally { + $sha.Dispose() + } + return ([System.BitConverter]::ToString($hash) -replace '-', '').ToLowerInvariant() +} + # --- semver ---------------------------------------------------------------- function ConvertTo-AcSemVer { diff --git a/scripts/migrate.ps1 b/scripts/migrate.ps1 index 7a9006e..a1e982e 100644 --- a/scripts/migrate.ps1 +++ b/scripts/migrate.ps1 @@ -110,6 +110,8 @@ foreach ($line in (Get-Content -LiteralPath $Baseline)) { $diverged = New-Object System.Collections.Generic.List[string] $added = New-Object System.Collections.Generic.List[string] +$nonMd = New-Object System.Collections.Generic.List[string] +$totalCount = 0 foreach ($area in @('standards', 'playbooks', 'conventions')) { $areaDir = Join-Path $ContextDir $area @@ -122,7 +124,9 @@ foreach ($area in @('standards', 'playbooks', 'conventions')) { foreach ($f in $files) { $rel = $area + '/' + $f.FullName.Substring($full.Length).TrimStart($sep).Replace('\', '/') - $actual = Get-AcFileHash -Path $f.FullName + $totalCount++ + # LF-normalised: a CRLF checkout must not make every file look edited. + $actual = Get-AcFileHashLf -Path $f.FullName if ($baselineMap.ContainsKey($rel)) { if ($baselineMap[$rel] -ne $actual) { $diverged.Add($rel) } } else { @@ -132,7 +136,47 @@ foreach ($area in @('standards', 'playbooks', 'conventions')) { } } -if ($diverged.Count -eq 0 -and $added.Count -eq 0) { +# Non-markdown files. The baseline only covers .md, but the restore below +# replaces each area wholesale, so anything else here is destroyed unless it is +# recognised. A file that also exists in the source tree is a framework +# companion (playbooks/setup ships shell scripts) and is restored intact; one +# that does not is the consumer's own and must be preserved as an override. +$sourceAreas = @( + @{ Name = 'standards'; From = (Join-Path $SourceRoot 'standards') }, + @{ Name = 'playbooks'; From = (Join-Path $SourceRoot 'playbooks') }, + @{ Name = 'conventions'; From = (Join-Path $SourceRoot 'core/.context/conventions') } +) +foreach ($area in $sourceAreas) { + $areaDir = Join-Path $ContextDir $area.Name + if (-not (Test-Path -LiteralPath $areaDir)) { continue } + + $full = (Resolve-Path -LiteralPath $areaDir).Path + $sep = [System.IO.Path]::DirectorySeparatorChar + $files = Get-ChildItem -LiteralPath $full -Recurse -File -ErrorAction SilentlyContinue | + Where-Object { $_.Extension -ne '.md' } | Sort-Object FullName + + foreach ($f in $files) { + $rel = $f.FullName.Substring($full.Length).TrimStart($sep).Replace('\', '/') + if (-not (Test-Path -LiteralPath (Join-Path $area.From $rel))) { + $nonMd.Add($area.Name + '/' + $rel) + } + } +} + +# Backstop. Every single file differing is not a real editing pattern - it is +# the signature of a systemic mismatch (wrong baseline, or an encoding or +# line-ending transform). Promoting them all would turn a pristine deployment +# into a total fork, pinning every file with "mode: replace" so no upstream +# improvement ever reaches it again. Refuse rather than do that silently. +if ($totalCount -gt 1 -and $diverged.Count -eq $totalCount) { + Write-Error ("Every one of the $totalCount base files differs from the baseline. " + + "That is a systemic mismatch, not consumer edits - check the baseline version " + + "(-From) and that the checkout has not rewritten line endings. " + + "Refusing to promote every file into overrides.") + exit 1 +} + +if ($diverged.Count -eq 0 -and $added.Count -eq 0 -and $nonMd.Count -eq 0) { Write-Host "No local modifications detected - this deployment is pristine." } else { if ($diverged.Count -gt 0) { @@ -145,8 +189,19 @@ if ($diverged.Count -eq 0 -and $added.Count -eq 0) { foreach ($rel in $added) { Write-Host " $rel -> .context/overrides/$rel" } Write-Host "" } + if ($nonMd.Count -gt 0) { + Write-Host "Non-markdown files you added ($($nonMd.Count)) - will move to overrides:" + foreach ($rel in $nonMd) { Write-Host " $rel -> .context/overrides/$rel" } + Write-Host "" + } } +# State the destructive behaviour before it happens, not after. +Write-Host "This migration replaces .context/{standards,playbooks,conventions} wholesale." +Write-Host "Anything listed above is preserved under .context/overrides/. A base file you" +Write-Host "deleted is restored, because the base is owned by the library, not by you." +Write-Host "" + if (-not $Apply) { Write-Host "Nothing written. Re-run with -Apply to perform the migration." exit 0 @@ -197,13 +252,20 @@ foreach ($rel in $added) { Remove-Item -LiteralPath (Join-Path $ContextDir $rel) -Force -ErrorAction SilentlyContinue } +# Consumer-owned non-markdown files: copied verbatim, with no frontmatter - +# they are not markdown, so a YAML header would corrupt them. +foreach ($rel in $nonMd) { + $dst = Join-Path $overrideRoot $rel + $parent = Split-Path -Parent $dst + if (-not (Test-Path -LiteralPath $parent)) { + New-Item -ItemType Directory -Path $parent -Force | Out-Null + } + Copy-Item -LiteralPath (Join-Path $ContextDir $rel) -Destination $dst -Force + Remove-Item -LiteralPath (Join-Path $ContextDir $rel) -Force -ErrorAction SilentlyContinue +} + Write-Host "Restoring base content from $SourceRoot ..." -$areas = @( - @{ Name = 'standards'; From = (Join-Path $SourceRoot 'standards') }, - @{ Name = 'playbooks'; From = (Join-Path $SourceRoot 'playbooks') }, - @{ Name = 'conventions'; From = (Join-Path $SourceRoot 'core/.context/conventions') } -) -foreach ($area in $areas) { +foreach ($area in $sourceAreas) { if (-not (Test-Path -LiteralPath $area.From)) { continue } $dest = Join-Path $ContextDir $area.Name if (Test-Path -LiteralPath $dest) { Remove-Item -LiteralPath $dest -Recurse -Force } diff --git a/scripts/update.ps1 b/scripts/update.ps1 index c487856..d67261b 100644 --- a/scripts/update.ps1 +++ b/scripts/update.ps1 @@ -216,8 +216,19 @@ try { @{ Name = 'playbooks'; From = (Join-Path $srcPath 'playbooks') }, @{ Name = 'conventions'; From = (Join-Path $srcPath 'core/.context/conventions') } ) + + # Validate the whole payload before deleting anything. Skipping a missing + # area would leave the previous content in place while the manifest and + # VERSION still advance, so the deployment would report a version it does + # not actually contain. + foreach ($area in $areas) { + if (-not (Test-Path -LiteralPath $area.From)) { + Write-Error ("Downloaded archive is missing $($area.Name)/ - nothing was changed.") + exit 1 + } + } + foreach ($area in $areas) { - if (-not (Test-Path -LiteralPath $area.From)) { continue } $dest = Join-Path $ContextDir $area.Name if (Test-Path -LiteralPath $dest) { Remove-Item -LiteralPath $dest -Recurse -Force } New-Item -ItemType Directory -Path $dest -Force | Out-Null From cad8b6eb47ae341511c705ea7cf9f42b9259a001 Mon Sep 17 00:00:00 2001 From: ldastey-dev Date: Sun, 6 Sep 2026 21:39:24 +0100 Subject: [PATCH 14/20] fix(deploy,update): protect the override layer and fix cross-platform manifest parsing Actions the suppressed findings from the latest automated review. Each was reproduced against a scratch deployment before being fixed. The override layer was not actually consumer-owned: - deploy.sh and deploy.ps1 copied the override scaffolding with the normal overwrite rules, so redeploying with --overwrite destroyed a consumer's own .context/overrides/README.md - the file where they document why their overrides exist. The comment above the call claimed the tree was "never touched again", and it was not. Scaffolding is now seeded only where absent, ignoring --overwrite entirely, and the override subtree is excluded from the bulk core/.context copy that also reached it. Base content is still refreshed, so the exemption is scoped to overrides/ alone. This mattered more than the small blast radius suggests: the base is disposable only because the framework never writes to overrides. An update path that can overwrite consumer files there breaks the guarantee the whole design rests on. - deploy.ps1 used Get-ChildItem without -Force, which skips dotfiles, so the .gitkeep files under the override scaffolding were silently never deployed and the subdirectories never created. deploy.sh created all three. Windows consumers therefore had no scaffolding at all. Cross-platform manifest parsing in update.sh: - The agents array was extracted with a single-line pattern, but ConvertTo-Json in deploy.ps1 writes arrays across multiple lines. Running update.sh --apply against a repository deployed with deploy.ps1 matched nothing and silently reset the recorded agent list to empty, which then changes what a later deploy writes. Reproduced with a real PowerShell-written manifest; the file is now collapsed before matching so both styles parse identically. - list_diverged interpolated the file path into a sed pattern unescaped. Every ".md" contains a regex metacharacter, so the pattern could match more than the literal key and report a false divergence. The key is now escaped. Not actioned: the report that Invoke-WebRequest -UseBasicParsing is unavailable on PowerShell 7. It is still present and functional - verified on 7.6.3, where the parameter exists and a live request succeeds. It is a deprecated no-op retained for backwards compatibility, which is exactly what a script supporting both 5.1 and 7+ needs. Adds TC13 to the bash suite and four Pester assertions covering the override ownership contract in both implementations, including the dotfile scaffolding and the requirement that base content is still refreshed. 96 bash assertions, up from 92; 32 Pester, up from 28. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- scripts/deploy.Tests.ps1 | 43 +++++++++++++++++++++++++++ scripts/deploy.ps1 | 56 ++++++++++++++++++++++++++++++++++-- scripts/deploy.sh | 53 ++++++++++++++++++++++++++++++++-- scripts/tests/test-deploy.sh | 50 ++++++++++++++++++++++++++++++++ scripts/update.sh | 14 +++++++-- 5 files changed, 208 insertions(+), 8 deletions(-) diff --git a/scripts/deploy.Tests.ps1 b/scripts/deploy.Tests.ps1 index b4239a0..b19adfa 100644 --- a/scripts/deploy.Tests.ps1 +++ b/scripts/deploy.Tests.ps1 @@ -291,3 +291,46 @@ Describe 'migrate.ps1 (consumer content preservation)' { (Get-Content -LiteralPath $b -Raw) | Should -Not -Match 'MY LOCAL EDIT' } } + +Describe 'deploy.ps1 (override layer ownership)' { + BeforeAll { + $script:OvRepo = Split-Path -Parent $PSScriptRoot + $script:OvTmp = Join-Path ([System.IO.Path]::GetTempPath()) ("ac-ov-" + [guid]::NewGuid()) + $deploy = Join-Path $script:OvRepo 'scripts/deploy.ps1' + New-Item -ItemType Directory -Path $script:OvTmp -Force | Out-Null + + & pwsh -NoProfile -File $deploy -Target $script:OvTmp -Agents claude -Overwrite 2>&1 | Out-Null + Set-Content -LiteralPath (Join-Path $script:OvTmp '.context/overrides/README.md') -Value 'MY OWN OVERRIDE NOTES' + Set-Content -LiteralPath (Join-Path $script:OvTmp '.context/overrides/standards/security.md') -Value 'my custom rule' + Set-Content -LiteralPath (Join-Path $script:OvTmp '.context/standards/security.md') -Value 'tampered' + & pwsh -NoProfile -File $deploy -Target $script:OvTmp -Agents claude -Overwrite 2>&1 | Out-Null + } + AfterAll { + if (Test-Path -LiteralPath $script:OvTmp) { Remove-Item -LiteralPath $script:OvTmp -Recurse -Force } + } + + # The override tree is consumer-owned; the base is disposable only because + # the framework never writes there. Scaffolding was previously copied with + # the normal overwrite rules, which destroyed a consumer's own README. + It 'preserves a consumer-edited overrides README under -Overwrite' { + (Get-Content -LiteralPath (Join-Path $script:OvTmp '.context/overrides/README.md') -Raw) | + Should -Match 'MY OWN OVERRIDE NOTES' + } + + It 'preserves a consumer override file under -Overwrite' { + (Get-Content -LiteralPath (Join-Path $script:OvTmp '.context/overrides/standards/security.md') -Raw) | + Should -Match 'my custom rule' + } + + # Get-ChildItem skips dotfiles without -Force, so the .gitkeep scaffolding + # was silently never deployed, diverging from deploy.sh. + It 'seeds the override scaffolding including dotfiles' { + Test-Path -LiteralPath (Join-Path $script:OvTmp '.context/overrides/playbooks/.gitkeep') | + Should -BeTrue + } + + It 'still refreshes base content under -Overwrite' { + (Get-Content -LiteralPath (Join-Path $script:OvTmp '.context/standards/security.md') -Raw) | + Should -Not -Match '^tampered' + } +} diff --git a/scripts/deploy.ps1 b/scripts/deploy.ps1 index b709f8a..7f2d0ba 100644 --- a/scripts/deploy.ps1 +++ b/scripts/deploy.ps1 @@ -204,7 +204,10 @@ function Copy-DirectoryContents { if (-not (Test-Path $Destination)) { New-Item -ItemType Directory -Path $Destination -Force | Out-Null } - $sourceFiles = Get-ChildItem -Path $Source -Recurse -File + # -Force is required or dotfiles are skipped: the override scaffolding + # ships .gitkeep files, and without this the subdirectories are never + # created, diverging from deploy.sh. + $sourceFiles = Get-ChildItem -Path $Source -Recurse -File -Force foreach ($file in $sourceFiles) { $relativePath = $file.FullName.Substring($Source.TrimEnd('/\').Length + 1) $destPath = Join-Path $Destination $relativePath @@ -212,6 +215,53 @@ function Copy-DirectoryContents { } } +# Seed files only where absent, ignoring -Overwrite entirely. +# +# The override tree is consumer-owned: the whole architecture depends on the +# framework never writing there, because that is what makes the base +# disposable. Copy-SingleFile honours -Overwrite, so using it for the override +# scaffolding would let a redeploy destroy a consumer's own README - the very +# file where they document why their overrides exist. +function Copy-AcSeedContents { + param([string]$Source, [string]$Destination) + if (-not (Test-Path $Destination)) { + New-Item -ItemType Directory -Path $Destination -Force | Out-Null + } + # -Force is required or dotfiles are skipped: the override scaffolding + # ships .gitkeep files, and without this the subdirectories are never + # created, diverging from deploy.sh. + $sourceFiles = Get-ChildItem -Path $Source -Recurse -File -Force + foreach ($file in $sourceFiles) { + $relativePath = $file.FullName.Substring($Source.TrimEnd('/\').Length + 1) + $destPath = Join-Path $Destination $relativePath + if (Test-Path -LiteralPath $destPath) { continue } + $parent = Split-Path -Parent $destPath + if (-not (Test-Path -LiteralPath $parent)) { + New-Item -ItemType Directory -Path $parent -Force | Out-Null + } + Copy-Item -LiteralPath $file.FullName -Destination $destPath -Force + } +} + +# Copy core/.context but skip the override subtree, which is seeded separately +# and must never be overwritten. +function Copy-AcContextExcludingOverrides { + param([string]$Source, [string]$Destination) + if (-not (Test-Path $Destination)) { + New-Item -ItemType Directory -Path $Destination -Force | Out-Null + } + # -Force is required or dotfiles are skipped: the override scaffolding + # ships .gitkeep files, and without this the subdirectories are never + # created, diverging from deploy.sh. + $sourceFiles = Get-ChildItem -Path $Source -Recurse -File -Force + foreach ($file in $sourceFiles) { + $relativePath = $file.FullName.Substring($Source.TrimEnd('/\').Length + 1) + if ($relativePath.Replace('\', '/') -like 'overrides/*') { continue } + $destPath = Join-Path $Destination $relativePath + Copy-SingleFile -Source $file.FullName -Destination $destPath + } +} + function Enable-VirtualTerminal { # Returns $true if ANSI escape sequences are usable on stdout. On non-Windows # hosts this is always true; on Windows it requires ENABLE_VIRTUAL_TERMINAL_PROCESSING, @@ -589,7 +639,7 @@ if (-not (Test-Path -LiteralPath $agentsDst)) { Copy-SingleFile -Source $agentsSrc -Destination $agentsDst } -Copy-DirectoryContents -Source (Join-Path $SourceRoot 'core/.context') -Destination (Join-Path $script:Target '.context') +Copy-AcContextExcludingOverrides -Source (Join-Path $SourceRoot 'core/.context') -Destination (Join-Path $script:Target '.context') if (Test-AgentEnabled 'claude') { Write-Host " Copying Claude Code files..." @@ -671,7 +721,7 @@ $overrideDst = Join-Path $script:Target '.context/overrides' if (-not (Test-Path -LiteralPath $overrideDst)) { New-Item -ItemType Directory -Path $overrideDst -Force | Out-Null } -Copy-DirectoryContents -Source (Join-Path $SourceRoot 'core/.context/overrides') -Destination $overrideDst +Copy-AcSeedContents -Source (Join-Path $SourceRoot 'core/.context/overrides') -Destination $overrideDst # Update tooling, shipped into the target so it can maintain itself. Write-Host " Installing update tooling -> $(Join-Path $script:Target '.context/bin')" diff --git a/scripts/deploy.sh b/scripts/deploy.sh index c0d5852..c930851 100755 --- a/scripts/deploy.sh +++ b/scripts/deploy.sh @@ -165,6 +165,52 @@ copy_dir_contents() { done < <(find "$src" -type f -print0 | sort -z) } +# Copy core/.context but skip the override subtree, which is seeded separately +# and must never be overwritten. +copy_dir_contents_excluding_overrides() { + local src="$1" + local dst="$2" + local rel_path + + while IFS= read -r -d '' file; do + rel_path="${file#"$src"/}" + case "$rel_path" in + overrides/*) continue ;; + esac + copy_file "$file" "$dst/$rel_path" + done < <(find "$src" -type f -print0 | sort -z) +} + +# Seed a file only when it is absent, ignoring --overwrite entirely. +# +# The override tree is consumer-owned: the whole architecture depends on the +# framework never writing there, because that is what makes the base +# disposable. copy_file honours --overwrite, so using it for the override +# scaffolding would let a redeploy destroy a consumer's own README - the very +# file where they document why their overrides exist. +seed_file_if_absent() { + local src="$1" + local dst="$2" + + if [[ -e "$dst" ]]; then + return 0 + fi + + mkdir -p "$(dirname "$dst")" + cp "$src" "$dst" +} + +seed_dir_if_absent() { + local src="$1" + local dst="$2" + local rel_path + + while IFS= read -r -d '' file; do + rel_path="${file#"$src"/}" + seed_file_if_absent "$file" "$dst/$rel_path" + done < <(find "$src" -type f -print0 | sort -z) +} + # --- managed block --------------------------------------------------------- # # The consumer owns AGENTS.md: it carries their [CONFIGURE] sections. The @@ -658,7 +704,10 @@ echo " Selected agents: $(join_by ', ' "${ENABLED_AGENTS[@]}")" echo " Copying shared context files..." deploy_agents_md "$SOURCE_ROOT/core/AGENTS.md" "$TARGET/AGENTS.md" "$DEPLOY_VERSION" -copy_dir_contents "$SOURCE_ROOT/core/.context" "$TARGET/.context" +# The override subtree is deliberately excluded here and seeded later with +# seed_dir_if_absent, so that --overwrite can never rewrite consumer-owned +# files under .context/overrides/. +copy_dir_contents_excluding_overrides "$SOURCE_ROOT/core/.context" "$TARGET/.context" if agent_enabled claude; then echo " Copying Claude Code files..." @@ -756,7 +805,7 @@ fi # Consumer-owned override tree. Created empty; never touched again by update. echo " Creating override layer → $TARGET/.context/overrides/" mkdir -p "$TARGET/.context/overrides" -copy_dir_contents "$SOURCE_ROOT/core/.context/overrides" "$TARGET/.context/overrides" +seed_dir_if_absent "$SOURCE_ROOT/core/.context/overrides" "$TARGET/.context/overrides" # Update tooling, shipped into the target so it can maintain itself. echo " Installing update tooling → $TARGET/.context/bin/" diff --git a/scripts/tests/test-deploy.sh b/scripts/tests/test-deploy.sh index 33cc83a..842f79d 100755 --- a/scripts/tests/test-deploy.sh +++ b/scripts/tests/test-deploy.sh @@ -633,6 +633,56 @@ rm -rf "$TC12_DIR" # ═══════════════════════════════════════════════════════════════════════ # Summary # ═══════════════════════════════════════════════════════════════════════ +# ═══════════════════════════════════════════════════════════════════════ +# TC13: the override layer is consumer-owned and survives --overwrite +# +# The whole architecture depends on the framework never writing under +# .context/overrides/ - that is what makes the base disposable. Scaffolding +# was previously copied with the normal overwrite rules, so a redeploy with +# --overwrite destroyed a consumer's own overrides README. +# ═══════════════════════════════════════════════════════════════════════ +echo "" +echo "=== TC13: override layer ownership ===" + +TC13_DIR=$(mktemp -d) +"$SCRIPTS_DIR/deploy.sh" --agents claude --overwrite "$TC13_DIR" >/dev/null 2>&1 + +printf 'MY OWN OVERRIDE NOTES\n' > "$TC13_DIR/.context/overrides/README.md" +printf 'my custom rule\n' > "$TC13_DIR/.context/overrides/standards/security.md" + +"$SCRIPTS_DIR/deploy.sh" --agents claude --overwrite "$TC13_DIR" >/dev/null 2>&1 + +if grep -q 'MY OWN OVERRIDE NOTES' "$TC13_DIR/.context/overrides/README.md" 2>/dev/null; then + pass "deploy: --overwrite preserves a consumer-edited overrides README" +else + fail "deploy: --overwrite destroyed the consumer-owned overrides README" +fi + +if grep -q 'my custom rule' "$TC13_DIR/.context/overrides/standards/security.md" 2>/dev/null; then + pass "deploy: --overwrite preserves a consumer override file" +else + fail "deploy: --overwrite destroyed a consumer override file" +fi + +# The scaffolding must still be seeded on a fresh deployment. +if [ -f "$TC13_DIR/.context/overrides/playbooks/.gitkeep" ]; then + pass "deploy: override scaffolding seeded" +else + fail "deploy: override scaffolding missing" +fi + +# Base content must still be refreshed by --overwrite; the exemption is +# scoped to overrides/ only. +printf 'tampered\n' > "$TC13_DIR/.context/standards/security.md" +"$SCRIPTS_DIR/deploy.sh" --agents claude --overwrite "$TC13_DIR" >/dev/null 2>&1 +if ! grep -qx 'tampered' "$TC13_DIR/.context/standards/security.md" 2>/dev/null; then + pass "deploy: --overwrite still refreshes base content" +else + fail "deploy: --overwrite no longer refreshes base content" +fi + +rm -rf "$TC13_DIR" + echo "" echo "=== Results ===" echo " Passed: $PASSED" diff --git a/scripts/update.sh b/scripts/update.sh index f93214e..d4f8ad1 100755 --- a/scripts/update.sh +++ b/scripts/update.sh @@ -95,11 +95,15 @@ PIN="$(ac_manifest_get "$MANIFEST" pin)" # List base files whose current hash differs from the manifest record. list_diverged() { - local rel recorded actual + local rel recorded actual rel_escaped ac_hash_context_tree "$CONTEXT_DIR" | while IFS= read -r line; do rel="${line%% *}" actual="${line##* }" - recorded="$(sed -n 's|.*"'"$rel"'"[[:space:]]*:[[:space:]]*"\([a-f0-9]*\)".*|\1|p' "$MANIFEST" | head -1)" + # The key is interpolated into a sed pattern, so regex metacharacters in + # the path (every ".md" contains one) must be escaped or they match more + # than the literal name and can report a false divergence. + rel_escaped="$(printf '%s' "$rel" | sed 's/[][\\.*^$/]/\\&/g')" + recorded="$(sed -n 's|.*"'"$rel_escaped"'"[[:space:]]*:[[:space:]]*"\([a-f0-9]*\)".*|\1|p' "$MANIFEST" | head -1)" if [ -n "$recorded" ] && [ "$recorded" != "$actual" ]; then printf '%s\n' "$rel" fi @@ -324,7 +328,11 @@ fi # Rewrite the manifest with the new version and fresh hashes. now="$(date -u '+%Y-%m-%dT%H:%M:%SZ')" -agents_json="$(sed -n 's/.*"agents"[[:space:]]*:[[:space:]]*\(\[[^]]*\]\).*/\1/p' "$MANIFEST" | head -1)" +# ConvertTo-Json in deploy.ps1 writes arrays across multiple lines, so a +# single-line pattern silently finds nothing and resets the agent list to +# empty - which then changes what a later deploy writes. Collapse the file to +# one line first so both manifest styles parse identically. +agents_json="$(tr -d '\n' < "$MANIFEST" | sed -n 's/.*"agents"[[:space:]]*:[[:space:]]*\(\[[^]]*\]\).*/\1/p' | tr -s ' ')" [ -n "$agents_json" ] || agents_json='[]' freq="$(ac_manifest_get "$MANIFEST" checkFrequency)" [ -n "$freq" ] || freq="weekly" From 60dff5d26d48cac73dbb0b32f05c89dff6f0e111 Mon Sep 17 00:00:00 2001 From: ldastey-dev Date: Sun, 6 Sep 2026 21:56:32 +0100 Subject: [PATCH 15/20] fix(deploy): apply the overwrite guard to the shipped update tooling Actions the two suppressed findings from the latest automated review. deploy.sh installed .context/bin/* with a raw cp, and deploy.ps1 with Copy-Item -Force, so both bypassed the overwrite guard that every other base file goes through. --no-overwrite and -NoOverwrite were silently ignored for the tooling, which made the flag misleading: the deployment reported files as skipped while rewriting them. Both are now routed through copy_file and Copy-SingleFile. Refreshing a stale updater unconditionally remains correct, but that belongs to update.sh and update.ps1, where replacing the base is the declared intent - not to a deploy the consumer explicitly asked not to overwrite anything. Verified in both implementations: a fresh deployment still installs the tooling, --no-overwrite now preserves an edited bin file, --overwrite still refreshes it, and the executable bit survives. Adds TC14 covering all four of those properties. 100 bash assertions, up from 96. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- scripts/deploy.ps1 | 9 ++++++-- scripts/deploy.sh | 10 ++++++--- scripts/tests/test-deploy.sh | 42 ++++++++++++++++++++++++++++++++++++ 3 files changed, 56 insertions(+), 5 deletions(-) diff --git a/scripts/deploy.ps1 b/scripts/deploy.ps1 index 7f2d0ba..2cda11b 100644 --- a/scripts/deploy.ps1 +++ b/scripts/deploy.ps1 @@ -732,16 +732,21 @@ foreach ($dir in @($binDst, $binLibDst)) { New-Item -ItemType Directory -Path $dir -Force | Out-Null } } +# Routed through Copy-SingleFile so -NoOverwrite means what it says. Every +# other base file honours the guard, and silently rewriting the tooling +# regardless would make the flag misleading. Refreshing a stale updater +# unconditionally is update.ps1's job, where replacing the base is the +# declared intent. foreach ($tool in @('update.sh', 'update.ps1', 'migrate.sh', 'migrate.ps1')) { $toolSrc = Join-Path $SourceRoot "scripts/$tool" if (Test-Path -LiteralPath $toolSrc) { - Copy-Item -LiteralPath $toolSrc -Destination (Join-Path $binDst $tool) -Force + Copy-SingleFile -Source $toolSrc -Destination (Join-Path $binDst $tool) } } foreach ($libFile in @('common.sh', 'common.ps1')) { $libSrc = Join-Path $SourceRoot "scripts/lib/$libFile" if (Test-Path -LiteralPath $libSrc) { - Copy-Item -LiteralPath $libSrc -Destination (Join-Path $binLibDst $libFile) -Force + Copy-SingleFile -Source $libSrc -Destination (Join-Path $binLibDst $libFile) } } diff --git a/scripts/deploy.sh b/scripts/deploy.sh index c930851..44f1180 100755 --- a/scripts/deploy.sh +++ b/scripts/deploy.sh @@ -810,14 +810,18 @@ seed_dir_if_absent "$SOURCE_ROOT/core/.context/overrides" "$TARGET/.context/over # Update tooling, shipped into the target so it can maintain itself. echo " Installing update tooling → $TARGET/.context/bin/" mkdir -p "$TARGET/.context/bin" +# Routed through copy_file so --no-overwrite means what it says. Every other +# base file honours the guard, and silently rewriting the tooling regardless +# would make the flag misleading. Refreshing a stale updater unconditionally +# is update.sh's job, where replacing the base is the declared intent. for tool in update.sh update.ps1 migrate.sh migrate.ps1; do if [[ -f "$SOURCE_ROOT/scripts/$tool" ]]; then - cp "$SOURCE_ROOT/scripts/$tool" "$TARGET/.context/bin/$tool" + copy_file "$SOURCE_ROOT/scripts/$tool" "$TARGET/.context/bin/$tool" fi done mkdir -p "$TARGET/.context/bin/lib" -cp "$SOURCE_ROOT/scripts/lib/common.sh" "$TARGET/.context/bin/lib/common.sh" -[[ -f "$SOURCE_ROOT/scripts/lib/common.ps1" ]] && cp "$SOURCE_ROOT/scripts/lib/common.ps1" "$TARGET/.context/bin/lib/common.ps1" +copy_file "$SOURCE_ROOT/scripts/lib/common.sh" "$TARGET/.context/bin/lib/common.sh" +[[ -f "$SOURCE_ROOT/scripts/lib/common.ps1" ]] && copy_file "$SOURCE_ROOT/scripts/lib/common.ps1" "$TARGET/.context/bin/lib/common.ps1" chmod +x "$TARGET/.context/bin"/*.sh 2>/dev/null || true printf '%s\n' "$DEPLOY_VERSION" > "$TARGET/.context/VERSION" diff --git a/scripts/tests/test-deploy.sh b/scripts/tests/test-deploy.sh index 842f79d..07fcc86 100755 --- a/scripts/tests/test-deploy.sh +++ b/scripts/tests/test-deploy.sh @@ -683,6 +683,48 @@ fi rm -rf "$TC13_DIR" +# ═══════════════════════════════════════════════════════════════════════ +# TC14: --no-overwrite applies to the shipped tooling too +# +# .context/bin/* was installed with a raw cp that bypassed the overwrite +# guard, so --no-overwrite silently rewrote it anyway and the flag lied. +# Refreshing a stale updater unconditionally is update.sh's job. +# ═══════════════════════════════════════════════════════════════════════ +echo "" +echo "=== TC14: overwrite guard covers .context/bin ===" + +TC14_DIR=$(mktemp -d) +"$SCRIPTS_DIR/deploy.sh" --agents claude --overwrite "$TC14_DIR" >/dev/null 2>&1 + +if [ -f "$TC14_DIR/.context/bin/update.sh" ] && [ -f "$TC14_DIR/.context/bin/lib/common.sh" ]; then + pass "deploy: update tooling installed on a fresh deployment" +else + fail "deploy: update tooling missing from a fresh deployment" +fi + +printf 'LOCAL EDIT\n' > "$TC14_DIR/.context/bin/update.sh" +"$SCRIPTS_DIR/deploy.sh" --agents claude --no-overwrite "$TC14_DIR" >/dev/null 2>&1 +if grep -q 'LOCAL EDIT' "$TC14_DIR/.context/bin/update.sh"; then + pass "deploy: --no-overwrite is honoured for .context/bin" +else + fail "deploy: --no-overwrite was ignored for .context/bin" +fi + +"$SCRIPTS_DIR/deploy.sh" --agents claude --overwrite "$TC14_DIR" >/dev/null 2>&1 +if ! grep -q 'LOCAL EDIT' "$TC14_DIR/.context/bin/update.sh"; then + pass "deploy: --overwrite still refreshes .context/bin" +else + fail "deploy: --overwrite no longer refreshes .context/bin" +fi + +if [ -x "$TC14_DIR/.context/bin/update.sh" ]; then + pass "deploy: shipped tooling remains executable" +else + fail "deploy: shipped tooling lost its executable bit" +fi + +rm -rf "$TC14_DIR" + echo "" echo "=== Results ===" echo " Passed: $PASSED" From 8a75dabbca5ed3d54f8c2787ecfd3a7b57d663ee Mon Sep 17 00:00:00 2001 From: ldastey-dev Date: Sun, 6 Sep 2026 22:16:37 +0100 Subject: [PATCH 16/20] fix(update): track non-markdown base files and check the tooling copies Actions the two suppressed findings from the latest automated review. update.sh left the bin tool and library copies unchecked in the apply path, which directly contradicted the comment above them stating that every operation there is checked. Because the script deliberately does not run under set -e, a failed copy left .context/bin partially updated while the run continued, rewrote manifest.json and VERSION, and reported success. All of them now route through partial_apply like the rest of the apply path. The manifest hashed only *.md while describing itself as covering every deployable file. deploy ships non-markdown companions under playbooks/ - compose files, collector configs, env files and shell scripts - and update replaces each area wholesale, so a consumer edit to one was destroyed with no divergence report. That is exactly the outcome the override layer exists to prevent, and it was invisible precisely because those files were untracked. Hashing now covers all base content in both languages, excluding overrides/ and bin/ as before, and additionally manifest.json, VERSION and .last-update-check, which are generated state rather than content - manifest.json cannot hash itself. Nine previously untracked files are now covered, and the bash and PowerShell implementations were confirmed to produce identical output over all 87 entries. Editing a shipped docker-compose.yaml is now reported by update --status with the override path to move it to, where previously it was silently overwritten. ac_hash_source_tree is left markdown-only and its comment corrected to say so. It backs the published baselines, migrate classifies non-markdown companions by source presence rather than by hash, and the frozen unversioned baseline is markdown-only, so widening it would make the baselines disagree. Adds TC15 covering the tracking, the exclusions and the divergence report. 105 bash assertions, up from 100. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- scripts/lib/common.ps1 | 12 +++++++-- scripts/lib/common.sh | 23 +++++++++++++--- scripts/tests/test-deploy.sh | 52 ++++++++++++++++++++++++++++++++++++ scripts/update.sh | 14 +++++++--- 4 files changed, 92 insertions(+), 9 deletions(-) diff --git a/scripts/lib/common.ps1 b/scripts/lib/common.ps1 index 2594bcd..ac1361a 100644 --- a/scripts/lib/common.ps1 +++ b/scripts/lib/common.ps1 @@ -211,11 +211,19 @@ function Get-AcContextHashes { $full = (Resolve-Path -LiteralPath $ContextDir).Path $sep = [System.IO.Path]::DirectorySeparatorChar - $files = Get-ChildItem -LiteralPath $full -Recurse -File -Filter '*.md' -ErrorAction SilentlyContinue | + # -Force so dotfiles are included, and no *.md filter: deploy ships + # non-markdown companions under playbooks/, and update replaces each area + # wholesale, so hashing only markdown meant a consumer edit to one of those + # was destroyed with no divergence report. manifest.json, VERSION and + # .last-update-check are generated state, not content. + $generated = @('manifest.json', 'VERSION', '.last-update-check') + $files = Get-ChildItem -LiteralPath $full -Recurse -File -Force -ErrorAction SilentlyContinue | Where-Object { $rel = $_.FullName.Substring($full.Length).TrimStart($sep) $relNorm = $rel.Replace('\', '/') - (-not $relNorm.StartsWith('overrides/')) -and (-not $relNorm.StartsWith('bin/')) + (-not $relNorm.StartsWith('overrides/')) -and + (-not $relNorm.StartsWith('bin/')) -and + ($generated -notcontains $relNorm) } | Sort-Object { $_.FullName.Substring($full.Length).TrimStart($sep).Replace('\', '/') } diff --git a/scripts/lib/common.sh b/scripts/lib/common.sh index a67eae1..0d135a0 100644 --- a/scripts/lib/common.sh +++ b/scripts/lib/common.sh @@ -177,26 +177,43 @@ ac_manifest_get() { sed -n 's/.*"'"$key"'"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "$file" | head -1 } -# Hash every deployable file in a deployed .context tree, printing +# Hash every base file in a deployed .context tree, printing # " " lines sorted by path. # # Relative paths are relative to the .context directory. Overrides and the # bin/ directory are excluded: overrides belong to the consumer, and bin/ is # refreshed like any other base file but is not part of the content baseline. +# manifest.json, VERSION and .last-update-check are generated state, not +# content - manifest.json in particular cannot hash itself. +# +# This deliberately covers more than markdown. deploy ships non-markdown +# companions under playbooks/ (compose files, env files, shell scripts), and +# update replaces each area wholesale. Hashing only *.md meant a consumer edit +# to one of those was destroyed with no divergence report, which is precisely +# the outcome the override layer exists to prevent. ac_hash_context_tree() { local ctx="$1" f rel [ -d "$ctx" ] || return 1 - find "$ctx" -type f -name '*.md' \ + find "$ctx" -type f \ ! -path "$ctx/overrides/*" \ ! -path "$ctx/bin/*" \ + ! -name 'manifest.json' \ + ! -name 'VERSION' \ + ! -name '.last-update-check' \ 2>/dev/null | LC_ALL=C sort | while IFS= read -r f; do rel="${f#"$ctx"/}" printf '%s %s\n' "$rel" "$(ac_sha256 "$f")" done } -# Hash the deployable source files in this repository, printing the same +# Hash the markdown base files in this repository, printing the same # " " shape so the two can be diffed directly. +# +# Markdown only, deliberately: this backs the published baselines, which +# migrate uses to tell a pristine context file from an edited one. Non-markdown +# companions are classified by migrate on whether they exist in the source +# tree, not by hash, and the frozen unversioned baseline is markdown-only, so +# widening this would make the baselines disagree with each other. ac_hash_source_tree() { local root="$1" f rel [ -d "$root" ] || return 1 diff --git a/scripts/tests/test-deploy.sh b/scripts/tests/test-deploy.sh index 07fcc86..b196cf6 100755 --- a/scripts/tests/test-deploy.sh +++ b/scripts/tests/test-deploy.sh @@ -725,6 +725,58 @@ fi rm -rf "$TC14_DIR" +# ═══════════════════════════════════════════════════════════════════════ +# TC15: non-markdown base files are tracked and their edits reported +# +# The manifest hashed only *.md, but deploy ships non-markdown companions +# under playbooks/ and update replaces each area wholesale. An edit to one +# was therefore destroyed with no divergence report - the exact outcome the +# override layer exists to prevent. +# ═══════════════════════════════════════════════════════════════════════ +echo "" +echo "=== TC15: non-markdown base file tracking ===" + +TC15_DIR=$(mktemp -d) +"$SCRIPTS_DIR/deploy.sh" --agents claude --overwrite "$TC15_DIR" >/dev/null 2>&1 + +TC15_NONMD="playbooks/setup/create-local-otel-stack/docker-compose.yaml" +if [ -f "$TC15_DIR/.context/$TC15_NONMD" ]; then + pass "deploy: non-markdown companion shipped" +else + fail "deploy: non-markdown companion missing - fixture is stale" +fi + +if grep -q "$TC15_NONMD" "$TC15_DIR/.context/manifest.json"; then + pass "manifest: non-markdown base file is tracked" +else + fail "manifest: non-markdown base file is not tracked" +fi + +# Generated state must never be hashed; manifest.json cannot hash itself. +if ! grep -q '"manifest.json"' "$TC15_DIR/.context/manifest.json" \ + && ! grep -q '"VERSION"' "$TC15_DIR/.context/manifest.json"; then + pass "manifest: generated state excluded from hashes" +else + fail "manifest: generated state was hashed" +fi + +if ! grep -q '"overrides/' "$TC15_DIR/.context/manifest.json" \ + && ! grep -q '"bin/' "$TC15_DIR/.context/manifest.json"; then + pass "manifest: overrides and bin excluded from hashes" +else + fail "manifest: overrides or bin were hashed" +fi + +printf 'tampered\n' >> "$TC15_DIR/.context/$TC15_NONMD" +TC15_OUT="$(cd "$TC15_DIR" && bash .context/bin/update.sh --status 2>&1)" || true +if printf '%s' "$TC15_OUT" | grep -q "$TC15_NONMD"; then + pass "update: an edited non-markdown base file is reported as diverged" +else + fail "update: an edited non-markdown base file went unreported" +fi + +rm -rf "$TC15_DIR" + echo "" echo "=== Results ===" echo " Passed: $PASSED" diff --git a/scripts/update.sh b/scripts/update.sh index d4f8ad1..79bceb1 100755 --- a/scripts/update.sh +++ b/scripts/update.sh @@ -281,16 +281,22 @@ if [ -f "$SRC/core/.context/index.md" ]; then fi # Refresh the update tooling itself, so a fixed updater reaches consumers. -mkdir -p "$CONTEXT_DIR/bin/lib" +mkdir -p "$CONTEXT_DIR/bin/lib" || partial_apply "could not create .context/bin/lib." for tool in update.sh update.ps1 migrate.sh migrate.ps1; do - [ -f "$SRC/scripts/$tool" ] && cp "$SRC/scripts/$tool" "$CONTEXT_DIR/bin/$tool" + if [ -f "$SRC/scripts/$tool" ]; then + cp "$SRC/scripts/$tool" "$CONTEXT_DIR/bin/$tool" || partial_apply "could not write bin/$tool." + fi done -[ -f "$SRC/scripts/lib/common.sh" ] && cp "$SRC/scripts/lib/common.sh" "$CONTEXT_DIR/bin/lib/common.sh" +if [ -f "$SRC/scripts/lib/common.sh" ]; then + cp "$SRC/scripts/lib/common.sh" "$CONTEXT_DIR/bin/lib/common.sh" || partial_apply "could not write bin/lib/common.sh." +fi # Both libraries, not just this platform's. A deployment updated from bash - a # Linux CI runner, or one bash user on a mixed team - would otherwise pair the # freshly downloaded update.ps1 with a stale common.ps1 forever, so a bug fixed # in the PowerShell library could never reach that repo's Windows users. -[ -f "$SRC/scripts/lib/common.ps1" ] && cp "$SRC/scripts/lib/common.ps1" "$CONTEXT_DIR/bin/lib/common.ps1" +if [ -f "$SRC/scripts/lib/common.ps1" ]; then + cp "$SRC/scripts/lib/common.ps1" "$CONTEXT_DIR/bin/lib/common.ps1" || partial_apply "could not write bin/lib/common.ps1." +fi chmod +x "$CONTEXT_DIR/bin"/*.sh 2>/dev/null || true # Refresh only the managed block in AGENTS.md. From e4430d366601e6fd613c66e1d0a75649bed70618 Mon Sep 17 00:00:00 2001 From: ldastey-dev Date: Sun, 6 Sep 2026 22:34:02 +0100 Subject: [PATCH 17/20] fix(deploy): preserve consumer manifest configuration on redeploy Actions the three suppressed findings from the latest automated review. pin and checkFrequency are consumer configuration, not derived state, but both deploy scripts rewrote them unconditionally. Redeploying over an existing deployment therefore reset a pin of "*" back to the current major line, or an exact pin used to freeze a repository, and restored a check frequency the consumer had deliberately slowed or disabled. Because the pin is what decides whether a major upgrade is offered at all, this silently changed update behaviour on a command the consumer would reasonably expect to be idempotent. This is the same defect already fixed in update.sh and update.ps1 in an earlier commit; deploy was missed, so the two disagreed about who owns those fields. Both now read an existing manifest and preserve both values, falling back to the derived defaults only when there is no manifest or it cannot be parsed. Verified in both implementations: a fresh deployment still gets "1.x" and "weekly", and a redeploy over a manifest edited to "*" and "monthly" keeps both. Also corrects the usage comment in the test suite, which still pointed at the pre-move ./tests/test-deploy.sh path, and the matching reference in deploy.sh. The suite must be invoked as bash scripts/tests/test-deploy.sh. Adds TC16 covering the defaults and both preserved fields. 108 bash assertions, up from 105. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- scripts/deploy.ps1 | 19 +++++++++++++++- scripts/deploy.sh | 18 ++++++++++++--- scripts/tests/test-deploy.sh | 43 +++++++++++++++++++++++++++++++++++- 3 files changed, 75 insertions(+), 5 deletions(-) diff --git a/scripts/deploy.ps1 b/scripts/deploy.ps1 index 2cda11b..d90f27c 100644 --- a/scripts/deploy.ps1 +++ b/scripts/deploy.ps1 @@ -753,7 +753,24 @@ foreach ($libFile in @('common.sh', 'common.ps1')) { Set-Content -LiteralPath (Join-Path $script:Target '.context/VERSION') -Value $DeployVersion -Encoding UTF8 Write-Host " Writing manifest -> $(Join-Path $script:Target '.context/manifest.json')" -Write-AcManifest -ContextDir (Join-Path $script:Target '.context') -Version $DeployVersion -Agents $script:EnabledAgents +# pin and checkFrequency are consumer configuration, not derived state. +# Redeploying over an existing deployment must not silently undo a deliberate +# choice - "*" to accept majors, an exact version to freeze, or a check +# frequency other than weekly. update.sh and update.ps1 already preserve both; +# deploy has to agree or a redeploy quietly resets them. +$manifestPath = Join-Path $script:Target '.context/manifest.json' +$existingPin = '' +$existingFreq = 'weekly' +if (Test-Path -LiteralPath $manifestPath) { + try { + $existing = Get-Content -LiteralPath $manifestPath -Raw | ConvertFrom-Json + if ($existing.pin) { $existingPin = [string]$existing.pin } + if ($existing.checkFrequency) { $existingFreq = [string]$existing.checkFrequency } + } catch { + Write-Host " manifest.json is unreadable - rewriting with defaults." + } +} +Write-AcManifest -ContextDir (Join-Path $script:Target '.context') -Version $DeployVersion -Agents $script:EnabledAgents -Pin $existingPin -CheckFrequency $existingFreq Write-Host "" Write-Host "Done. Next steps:" diff --git a/scripts/deploy.sh b/scripts/deploy.sh index 44f1180..f41919f 100755 --- a/scripts/deploy.sh +++ b/scripts/deploy.sh @@ -287,18 +287,30 @@ write_manifest() { local target="$1" version="$2" agents="$3" local manifest="$target/.context/manifest.json" local ctx="$target/.context" - local now first=1 + local now first=1 pin freq now="$(date -u '+%Y-%m-%dT%H:%M:%SZ')" + # pin and checkFrequency are consumer configuration, not derived state. + # Redeploying over an existing deployment must not silently undo a + # deliberate choice - "*" to accept majors, an exact version to freeze, or a + # check frequency other than weekly. update.sh and update.ps1 already + # preserve both; deploy has to agree or a redeploy quietly resets them. + if [ -f "$manifest" ]; then + pin="$(ac_manifest_get "$manifest" pin)" + freq="$(ac_manifest_get "$manifest" checkFrequency)" + fi + [ -n "${pin:-}" ] || pin="$(ac_semver_major "$version").x" + [ -n "${freq:-}" ] || freq="weekly" + mkdir -p "$ctx" { printf '{\n' printf ' "schema": 1,\n' printf ' "version": "%s",\n' "$version" printf ' "source": "%s",\n' "$AC_SOURCE_REPO" - printf ' "pin": "%s",\n' "$(ac_semver_major "$version").x" - printf ' "checkFrequency": "weekly",\n' + printf ' "pin": "%s",\n' "$pin" + printf ' "checkFrequency": "%s",\n' "$freq" printf ' "deployedAt": "%s",\n' "$now" printf ' "agents": [' for agent in $agents; do diff --git a/scripts/tests/test-deploy.sh b/scripts/tests/test-deploy.sh index b196cf6..4f20420 100755 --- a/scripts/tests/test-deploy.sh +++ b/scripts/tests/test-deploy.sh @@ -2,7 +2,7 @@ # Test suite for deploy.sh — verifies setup/ playbook deployment and regressions. # # Usage: -# ./tests/test-deploy.sh +# bash scripts/tests/test-deploy.sh # # Exit codes: # 0 All tests passed @@ -777,6 +777,47 @@ fi rm -rf "$TC15_DIR" +# ═══════════════════════════════════════════════════════════════════════ +# TC16: redeploy preserves consumer manifest configuration +# +# pin and checkFrequency are consumer choices, not derived state. update +# preserves both; deploy rewrote them on every run, so a redeploy silently +# reset a pin of "*" back to the current major line. +# ═══════════════════════════════════════════════════════════════════════ +echo "" +echo "=== TC16: manifest configuration is consumer-owned ===" + +TC16_DIR=$(mktemp -d) +"$SCRIPTS_DIR/deploy.sh" --agents claude --overwrite "$TC16_DIR" >/dev/null 2>&1 + +if grep -q '"pin": "1.x"' "$TC16_DIR/.context/manifest.json" \ + && grep -q '"checkFrequency": "weekly"' "$TC16_DIR/.context/manifest.json"; then + pass "deploy: fresh deployment gets default pin and check frequency" +else + fail "deploy: fresh deployment has unexpected pin or check frequency" +fi + +# Simulate a consumer widening the pin and slowing the check. +sed 's/"pin": "1.x"/"pin": "*"/; s/"checkFrequency": "weekly"/"checkFrequency": "monthly"/' \ + "$TC16_DIR/.context/manifest.json" > "$TC16_DIR/.context/manifest.json.tmp" +mv "$TC16_DIR/.context/manifest.json.tmp" "$TC16_DIR/.context/manifest.json" + +"$SCRIPTS_DIR/deploy.sh" --agents claude --overwrite "$TC16_DIR" >/dev/null 2>&1 + +if grep -q '"pin": "\*"' "$TC16_DIR/.context/manifest.json"; then + pass "deploy: redeploy preserves a consumer-set pin" +else + fail "deploy: redeploy reset the consumer-set pin" +fi + +if grep -q '"checkFrequency": "monthly"' "$TC16_DIR/.context/manifest.json"; then + pass "deploy: redeploy preserves a consumer-set check frequency" +else + fail "deploy: redeploy reset the consumer-set check frequency" +fi + +rm -rf "$TC16_DIR" + echo "" echo "=== Results ===" echo " Passed: $PASSED" From 59353d38f44d41c616a3f2926f4c979fc726351c Mon Sep 17 00:00:00 2001 From: ldastey-dev Date: Sun, 6 Sep 2026 22:49:39 +0100 Subject: [PATCH 18/20] docs(core): use --quiet for the session-start update check Actions the remaining suppressed finding from the latest automated review. The managed block told agents to run "update.sh --check" while also requiring "Report at most one line" and a cheap session-start check. Those conflict: without --quiet, the up-to-date path prints a line and then calls report_local_state, which hashes the whole context tree and prints a further line for every locally edited or orphaned file. That cost has just grown. Expanding the manifest to track non-markdown base files took the scan from 78 files to 87, so the guidance was pulling in the opposite direction to the design goal it sits under. --quiet is built for exactly this: it exits before the local-state scan, stays silent when the check is offline or already current, and still prints the one-line notice when an update is available - which is the only case the agent needs to surface. Verified the offline path is silent under --quiet and prints a line without it, and that both implementations gate at the same three points. The manual invocations in README.md and the overrides README keep plain --check, where the local-state report is the reason a person runs it. README now documents --quiet, the trade-off between the two, and which is intended for whom. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- README.md | 14 +++++++++++--- core/AGENTS.md | 4 +++- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index f5d412b..bc5b466 100644 --- a/README.md +++ b/README.md @@ -307,17 +307,25 @@ carries `.context/manifest.json` recording the version it is on, and `.context/bin/` with the tooling to check and apply updates. ```bash -.context/bin/update.sh --status # local state only, no network -.context/bin/update.sh --check # is there a newer version? -.context/bin/update.sh --apply # refresh the base to the latest +.context/bin/update.sh --status # local state only, no network +.context/bin/update.sh --check # is there a newer version? +.context/bin/update.sh --check --quiet # as above, but silent unless action is needed +.context/bin/update.sh --apply # refresh the base to the latest ``` ```powershell .context/bin/update.ps1 -Status .context/bin/update.ps1 -Check +.context/bin/update.ps1 -Check -Quiet .context/bin/update.ps1 -Apply ``` +`--check` also reports which base files have been edited locally, which means +hashing the context tree. `--quiet` skips that scan and prints only when an +update is actually available, so it costs a single HTTP request and nothing +else. That is why it is what agents are told to run at session start, and why +`--check` on its own is the better command to run yourself. + The check costs a single fetch of a six-byte `VERSION` file from a CDN-cached URL — no authentication, no rate limit, and nothing meaningful added to an agent session. It **fails open**: if the network is unavailable the check reports so diff --git a/core/AGENTS.md b/core/AGENTS.md index 6469892..e3361bc 100644 --- a/core/AGENTS.md +++ b/core/AGENTS.md @@ -127,7 +127,9 @@ Base files under `.context/standards/`, `.context/playbooks/` and `.context/conv Check frequency: **weekly** -At session start, if `.context/.last-update-check` is missing or older than the frequency above, run `.context/bin/update.sh --check` once. Report at most one line. **Never block the session.** If the check fails for any reason, continue silently. +At session start, if `.context/.last-update-check` is missing or older than the frequency above, run `.context/bin/update.sh --check --quiet` once. Report at most one line. **Never block the session.** If the check fails for any reason, continue silently. + +`--quiet` is what keeps this cheap: it prints a line only when an update is actually available, and otherwise exits without hashing the context tree. Run it without `--quiet` yourself, at any time, to also see which base files have been edited locally. --- From 5e98b5dad880e229279a7b5e12d6efc6303b3e4e Mon Sep 17 00:00:00 2001 From: ldastey-dev Date: Sun, 6 Sep 2026 23:03:24 +0100 Subject: [PATCH 19/20] fix(update): escape the substitution delimiter in manifest key lookup The character class escaped "/", which is neither a regex metacharacter nor the delimiter of the substitution it feeds, so the backslash it produced was undefined behaviour under POSIX. It also omitted "|", which is the actual delimiter, so a path containing one could terminate the pattern early. Escape "|" and drop "/". Verified that "a.b.md" still matches its manifest entry and that "indexXmd" still does not match "index.md". Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- scripts/update.sh | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/scripts/update.sh b/scripts/update.sh index 79bceb1..f530e8c 100755 --- a/scripts/update.sh +++ b/scripts/update.sh @@ -101,8 +101,11 @@ list_diverged() { actual="${line##* }" # The key is interpolated into a sed pattern, so regex metacharacters in # the path (every ".md" contains one) must be escaped or they match more - # than the literal name and can report a false divergence. - rel_escaped="$(printf '%s' "$rel" | sed 's/[][\\.*^$/]/\\&/g')" + # than the literal name and can report a false divergence. "|" is escaped + # because it is the delimiter of the substitution below; "/" is not, since + # it is neither a metacharacter nor the delimiter, and a backslash before + # an ordinary character is undefined behaviour in POSIX. + rel_escaped="$(printf '%s' "$rel" | sed 's/[][\\.*^$|]/\\&/g')" recorded="$(sed -n 's|.*"'"$rel_escaped"'"[[:space:]]*:[[:space:]]*"\([a-f0-9]*\)".*|\1|p' "$MANIFEST" | head -1)" if [ -n "$recorded" ] && [ "$recorded" != "$actual" ]; then printf '%s\n' "$rel" From a5aa682ffd2d892272403afd57d1cf8cb6f84da2 Mon Sep 17 00:00:00 2001 From: ldastey-dev Date: Sun, 6 Sep 2026 23:07:45 +0100 Subject: [PATCH 20/20] fix(update): treat index.md as mandatory payload content The apply path copied index.md only when it was present in the downloaded archive. A payload missing it left the previous index.md in place while every other area was replaced wholesale, so a deployment could carry a stale routing table indefinitely while the manifest and VERSION advanced. index.md is the routing table; without it no standard or playbook is discoverable, so it is mandatory base content. Validate it alongside the other areas before anything is deleted, and copy it unconditionally. Deleting it when absent would be worse than keeping it, so refusing the payload is the correct outcome. Applied to both implementations. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- scripts/update.ps1 | 17 +++++++++++++---- scripts/update.sh | 10 +++++++--- 2 files changed, 20 insertions(+), 7 deletions(-) diff --git a/scripts/update.ps1 b/scripts/update.ps1 index d67261b..dca9456 100644 --- a/scripts/update.ps1 +++ b/scripts/update.ps1 @@ -228,6 +228,18 @@ try { } } + # index.md is the routing table - without it no standard or playbook is + # discoverable, so it is mandatory base content, not an optional extra. It + # is validated here rather than skipped at the copy: skipping left the + # previous index.md behind while every other area was replaced, so a stale + # routing table could survive an update indefinitely. Deleting it instead + # would be worse. + $srcIndex = Join-Path $srcPath 'core/.context/index.md' + if (-not (Test-Path -LiteralPath $srcIndex)) { + Write-Error 'Downloaded archive is missing core/.context/index.md - nothing was changed.' + exit 1 + } + foreach ($area in $areas) { $dest = Join-Path $ContextDir $area.Name if (Test-Path -LiteralPath $dest) { Remove-Item -LiteralPath $dest -Recurse -Force } @@ -235,10 +247,7 @@ try { Copy-Item -Path (Join-Path $area.From '*') -Destination $dest -Recurse -Force } - $srcIndex = Join-Path $srcPath 'core/.context/index.md' - if (Test-Path -LiteralPath $srcIndex) { - Copy-Item -LiteralPath $srcIndex -Destination (Join-Path $ContextDir 'index.md') -Force - } + Copy-Item -LiteralPath $srcIndex -Destination (Join-Path $ContextDir 'index.md') -Force # Refresh the update tooling itself, so a fixed updater reaches consumers. $binDir = Join-Path $ContextDir 'bin' diff --git a/scripts/update.sh b/scripts/update.sh index f530e8c..7527095 100755 --- a/scripts/update.sh +++ b/scripts/update.sh @@ -268,6 +268,12 @@ for pair in "standards:$SRC/standards" "playbooks:$SRC/playbooks" "conventions:$ from="${pair#*:}" [ -d "$from" ] || partial_apply "downloaded archive is missing ${pair%%:*}/ - nothing was changed." done +# index.md is the routing table - without it no standard or playbook is +# discoverable, so it is mandatory base content, not an optional extra. It is +# validated here rather than skipped at the copy: skipping left the previous +# index.md behind while every other area was replaced, so a stale routing table +# could survive an update indefinitely. Deleting it instead would be worse. +[ -f "$SRC/core/.context/index.md" ] || partial_apply "downloaded archive is missing core/.context/index.md - nothing was changed." for pair in "standards:$SRC/standards" "playbooks:$SRC/playbooks" "conventions:$SRC/core/.context/conventions"; do name="${pair%%:*}" @@ -279,9 +285,7 @@ for pair in "standards:$SRC/standards" "playbooks:$SRC/playbooks" "conventions:$ fi done -if [ -f "$SRC/core/.context/index.md" ]; then - cp "$SRC/core/.context/index.md" "$CONTEXT_DIR/index.md" || partial_apply "could not write index.md." -fi +cp "$SRC/core/.context/index.md" "$CONTEXT_DIR/index.md" || partial_apply "could not write index.md." # Refresh the update tooling itself, so a fixed updater reaches consumers. mkdir -p "$CONTEXT_DIR/bin/lib" || partial_apply "could not create .context/bin/lib."