From 328c7cd1e83e4424fae55559f78caf7c759f4786 Mon Sep 17 00:00:00 2001 From: Nikolai Emil Damm Date: Sun, 4 Oct 2026 21:55:23 +0200 Subject: [PATCH 1/8] fix: bind release artifacts to unambiguous checkout evidence --- AGENTS.md | 1 + scripts/bump-plugin-version.sh | 4 + scripts/plugin-changelog-boundaries.test.sh | 5 +- ...lugin-changelog-release-boundaries.test.sh | 142 ++++++++++++++++++ scripts/plugin-changelog.sh | 28 +++- scripts/plugin-changelog.test.sh | 1 + 6 files changed, 175 insertions(+), 6 deletions(-) create mode 100644 scripts/plugin-changelog-release-boundaries.test.sh diff --git a/AGENTS.md b/AGENTS.md index fe5a72e5..4a2d1f59 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -331,6 +331,7 @@ GOENV=off GOWORK=off GO111MODULE=off GOTOOLCHAIN=local go test ./plugins/agentic ./scripts/check-plugin-version-bump.sh origin/main HEAD bash scripts/plugin-changelog.sh check origin/main HEAD bash scripts/plugin-changelog.test.sh +bash scripts/plugin-changelog-release-boundaries.test.sh # caller identity, committed provenance and safe release writes # 1c. Every content digest a desired-state resource pins must match the file it pins. # Those digests have a writer: refresh them rather than hand-editing, or the next diff --git a/scripts/bump-plugin-version.sh b/scripts/bump-plugin-version.sh index d3b41f07..74a71191 100755 --- a/scripts/bump-plugin-version.sh +++ b/scripts/bump-plugin-version.sh @@ -25,6 +25,8 @@ COPILOT_MANIFEST=".github/plugin/marketplace.json" # shellcheck source=scripts/plugin-version.lib.sh . "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/plugin-version.lib.sh" plugin_version_git_context +# shellcheck source=scripts/json-object.lib.sh +. "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/json-object.lib.sh" # shellcheck source=scripts/atomic-write.lib.sh . "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/atomic-write.lib.sh" @@ -39,6 +41,7 @@ for directory in .claude-plugin .github .github/plugin; do done for manifest in "$CLAUDE_MANIFEST" "$COPILOT_MANIFEST"; do [ -f "$manifest" ] && [ ! -L "$manifest" ] || exit 1 + json_object_unique "$manifest" || { echo '::error::Marketplace declarations must be unambiguous; no manifests were changed.' >&2; exit 1; } jq -es 'length==1 and (.[0] | type == "object" and (.plugins | type == "array"))' "$manifest" >/dev/null || exit 1 cp "$manifest" "$work/$(basename "$(dirname "$manifest")").json" done @@ -51,6 +54,7 @@ plan_one() { current=$(plugin_version_read "$dir/.claude-plugin/plugin.json" "$name") || return 1 for manifest in "$dir/plugin.json" "$dir/.claude-plugin/plugin.json"; do [ -f "$manifest" ] && [ ! -L "$manifest" ] || return 1 + json_object_unique "$manifest" || { echo '::error::Plugin declarations must be unambiguous; no manifests were changed.' >&2; return 1; } observed=$(plugin_version_read "$manifest" "$name") || return 1 [ "$observed" = "$current" ] || { echo "::error::$manifest: plugin identity or version parity is invalid; no manifests were changed." >&2 diff --git a/scripts/plugin-changelog-boundaries.test.sh b/scripts/plugin-changelog-boundaries.test.sh index 6aab26d3..737114aa 100644 --- a/scripts/plugin-changelog-boundaries.test.sh +++ b/scripts/plugin-changelog-boundaries.test.sh @@ -51,6 +51,7 @@ base=$(git -C "$dir" rev-parse HEAD) label='multiple base manifest documents'; expect_refusal write "$base" 2026-10-03 fixture printf '%s\n' '---' 'name: example' 'metadata:' ' nested:' ' github-repo: https://github.com/devantler-tech/agent-plugins' ' github-ref: refs/tags/v9.9.9' '---' > "$dir/plugins/alpha/skills/example/SKILL.md" +git -C "$dir" add -- plugins/alpha/skills/example/SKILL.md; git -C "$dir" commit -qm nested-provenance label='nested keys cannot impersonate provenance'; expect_refusal write "$base" 2026-10-03 fixture mkdir "$dir/bin" @@ -64,12 +65,12 @@ fixture mkdir "$dir/bin"; real_git=$(command -v git) cat > "$dir/bin/git" <<'SH' #!/usr/bin/env bash -if [[ $1 == cat-file && $2 == -e ]]; then exit 128; fi +if [[ $1 == cat-file && $2 == blob ]]; then exit 128; fi if [[ $1 == ls-tree && $2 == -r ]]; then exit 0; fi exec "$REAL_GIT" "$@" SH chmod +x "$dir/bin/git" -label='failed existence read cannot invent a removed skill' +label='failed committed provenance read cannot invent a removed skill' if (cd "$dir" && PATH="$dir/bin:$PATH" REAL_GIT="$real_git" bash "$script" write "$base" 2026-10-03) > "$work/out" 2>&1; then printf 'FAIL %s\n' "$label"; failed=$((failed+1)); else printf 'PASS %s\n' "$label"; fi fixture printf 'resource\n' > "$dir/plugins/alpha/skills/example/cafรฉ.txt" diff --git a/scripts/plugin-changelog-release-boundaries.test.sh b/scripts/plugin-changelog-release-boundaries.test.sh new file mode 100644 index 00000000..b0ed6b78 --- /dev/null +++ b/scripts/plugin-changelog-release-boundaries.test.sh @@ -0,0 +1,142 @@ +#!/usr/bin/env bash +# Observe release artifacts in real independent repositories without network access. +# shellcheck disable=SC2016 # Inner bash snippets expand their own positional arguments. +set -euo pipefail +here=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) +work=$(mktemp -d) +trap 'rm -rf "$work"' EXIT +passed=0 failed=0 +# Accumulate assertions so every boundary has independent RED/GREEN evidence. +check() { if "$@"; then passed=$((passed+1)); else printf 'FAIL %s\n' "$label"; failed=$((failed+1)); fi; } +# Create a complete two-plugin baseline and one committed skill update. +fresh() { + d=$(mktemp -d "$work/repo.XXXXXX") + git -C "$d" init -q -b main + git -C "$d" config user.name Fixture + git -C "$d" config user.email fixture@example.invalid + git -C "$d" config commit.gpgsign false + mkdir -p "$d/.claude-plugin" "$d/.github/plugin" + for n in alpha beta; do + mkdir -p "$d/plugins/$n/.claude-plugin" "$d/plugins/$n/skills/example" + printf '{"name":"%s","version":"1.2.3"}\n' "$n" > "$d/plugins/$n/plugin.json" + cp "$d/plugins/$n/plugin.json" "$d/plugins/$n/.claude-plugin/plugin.json" + printf '%s\n' '---' 'name: example' 'metadata:' ' github-repo: https://github.com/devantler-tech/agent-skills' ' github-ref: refs/tags/v1.0.0' '---' 'Example.' > "$d/plugins/$n/skills/example/SKILL.md" + printf '## 1.2.3 โ€” 2026-01-01\n\nOriginal history.\n' > "$d/plugins/$n/CHANGELOG.md" + done + printf '%s\n' '{"name":"fixture","plugins":[{"name":"alpha","version":"1.2.3","source":"./plugins/alpha"},{"name":"beta","version":"1.2.3","source":"./plugins/beta"}]}' > "$d/.claude-plugin/marketplace.json" + cp "$d/.claude-plugin/marketplace.json" "$d/.github/plugin/marketplace.json" + git -C "$d" add -- plugins .claude-plugin .github + git -C "$d" commit -qm base + base=$(git -C "$d" rev-parse HEAD) + sed 's/v1.0.0/v2.0.0/' "$d/plugins/alpha/skills/example/SKILL.md" > "$d/next" + mv "$d/next" "$d/plugins/alpha/skills/example/SKILL.md" + git -C "$d" add -- plugins/alpha/skills/example/SKILL.md + git -C "$d" commit -qm sync + printf '%s\n' '{"name":"alpha","version":"1.2.4"}' > "$d/plugins/alpha/plugin.json" +} +# Capture only the actual helper's status and bytes, preserving later assertions. +run() { rc=0; (cd "$d" && "$@") > "$work/out" 2> "$work/err" || rc=$?; } +# Commit only the fixture-owned plugin paths. +commit() { git -C "$d" add -- plugins; git -C "$d" commit -qm change; } +# Snapshot all publication files independently of the index. +snapshot() { find "$d/plugins" "$d/.claude-plugin" "$d/.github" -type f -exec shasum {} + | LC_ALL=C sort; } +for mode in check write; do + for selector in repository worktree index config; do + fresh + foreign=$(mktemp -d "$work/foreign.XXXXXX") + git clone -q "$d" "$foreign" + git -C "$foreign" checkout -q "$base" + if [[ $mode == check ]]; then commit; arg=HEAD; else arg=2026-10-04; fi + case $selector in + repository) run env GIT_DIR="$foreign/.git" GIT_WORK_TREE="$foreign" bash "$here/plugin-changelog.sh" "$mode" "$base" "$arg" ;; + worktree) run env GIT_WORK_TREE="$foreign" bash "$here/plugin-changelog.sh" "$mode" "$base" "$arg" ;; + index) run env GIT_INDEX_FILE="$foreign/.git/index" bash "$here/plugin-changelog.sh" "$mode" "$base" "$arg" ;; + config) run env GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=core.worktree GIT_CONFIG_VALUE_0="$foreign" bash "$here/plugin-changelog.sh" "$mode" "$base" "$arg" ;; + esac + label="$mode binds caller under $selector selector" + if [[ $mode == check ]]; then check test "$rc" -ne 0 + else check bash -c '[[ $1 == 0 ]] && grep -Fq refs/tags/v2.0.0 "$2"' _ "$rc" "$d/plugins/alpha/CHANGELOG.md"; fi + done +done +for kind in root plugin destination; do + fresh + outside=$(mktemp -d "$work/external.XXXXXX") + case $kind in + root) mv "$d/plugins" "$outside/plugins"; ln -s "$outside/plugins" "$d/plugins"; target="$outside/plugins/alpha/CHANGELOG.md" ;; + plugin) mv "$d/plugins/alpha" "$outside/alpha"; ln -s "$outside/alpha" "$d/plugins/alpha"; target="$outside/alpha/CHANGELOG.md" ;; + destination) mv "$d/plugins/alpha/CHANGELOG.md" "$outside/log"; ln -s "$outside/log" "$d/plugins/alpha/CHANGELOG.md"; target="$outside/log" ;; + esac + cp "$target" "$work/before"; cp "$d/plugins/beta/CHANGELOG.md" "$work/beta" + run bash "$here/plugin-changelog.sh" write "$base" 2026-10-04 + label="linked $kind refuses whole write"; check test "$rc" -ne 0 + label="linked $kind preserves external and other plugin bytes"; check bash -c 'cmp "$1" "$2" && cmp "$3" "$4"' _ "$work/before" "$target" "$work/beta" "$d/plugins/beta/CHANGELOG.md" +done +for kind in altered missing linked; do + fresh + metadata="$d/plugins/alpha/skills/example/SKILL.md" + case $kind in + altered) sed 's/v2.0.0/v99.0.0/' "$metadata" > "$d/next"; mv "$d/next" "$metadata" ;; + missing) rm "$metadata" ;; + linked) printf 'wrong uncommitted provenance\n' > "$d/wrong"; rm "$metadata"; ln -s "$d/wrong" "$metadata" ;; + esac + run bash "$here/plugin-changelog.sh" write "$base" 2026-10-04 + label="committed provenance survives $kind working copy"; check bash -c '[[ $1 == 0 ]] && grep -Fq refs/tags/v2.0.0 "$2" && ! grep -Fq v99.0.0 "$2"' _ "$rc" "$d/plugins/alpha/CHANGELOG.md" +done +fresh +rm "$d/plugins/alpha/skills/example/SKILL.md" +ln -s other "$d/plugins/alpha/skills/example/SKILL.md" +commit; snapshot > "$work/before" +run bash "$here/plugin-changelog.sh" write "$base" 2026-10-04 +label='nonregular committed provenance refuses'; check test "$rc" -ne 0 +snapshot > "$work/after"; label='nonregular committed provenance preserves batch'; check cmp "$work/before" "$work/after" +for target in marketplace portable; do + for kind in scalar container escaped; do + fresh + if [[ $target == marketplace ]]; then + file="$d/.claude-plugin/marketplace.json" + case $kind in + scalar) printf '%s\n' '{"name":"fixture","name":"fixture","plugins":[{"name":"alpha","version":"1.2.3","source":"./plugins/alpha"}]}' > "$file" ;; + container) printf '%s\n' '{"plugins":[{"name":"beta","version":"1.2.3"}],"plugins":[{"name":"alpha","version":"1.2.3","source":"./plugins/alpha"}]}' > "$file" ;; + escaped) printf '%s\n' '{"plugins":[],"\u0070lugins":[{"name":"alpha","version":"1.2.3","source":"./plugins/alpha"}]}' > "$file" ;; + esac + cp "$d/plugins/alpha/.claude-plugin/plugin.json" "$d/plugins/alpha/plugin.json" + snapshot > "$work/before" + run bash "$here/bump-plugin-version.sh" alpha patch + else + file="$d/plugins/alpha/plugin.json" + case $kind in + scalar) printf '%s\n' '{"version":"1.2.4","version":"1.2.3"}' > "$file" ;; + container) printf '%s\n' '{"version":"1.2.3","extra":{},"extra":{}}' > "$file" ;; + escaped) printf '%s\n' '{"version":"1.2.4","\u0076ersion":"1.2.3"}' > "$file" ;; + esac + commit; snapshot > "$work/before" + run bash "$here/plugin-changelog.sh" check "$base" HEAD + fi + label="$target repeated $kind refuses"; check test "$rc" -ne 0 + snapshot > "$work/after"; label="$target repeated $kind preserves all bytes"; check cmp "$work/before" "$work/after" + done +done +for mode in check write; do + fresh + tree=$(git -C "$d" rev-parse "$base^{tree}") + git -C "$d" add -- plugins/alpha/plugin.json + changed_tree=$(git -C "$d" write-tree) + a=$(printf 'First base\n' | git -C "$d" commit-tree "$changed_tree" -p "$base") + b=$(printf 'Other base\n' | git -C "$d" commit-tree "$tree" -p "$base") + head=$(printf 'Head\n' | git -C "$d" commit-tree "$changed_tree" -p "$a" -p "$b") + comparison=$(printf 'Comparison\n' | git -C "$d" commit-tree "$tree" -p "$b" -p "$a") + git -C "$d" checkout -q "$head" + snapshot > "$work/before" + if [[ $mode == check ]]; then arg="$head"; else arg=2026-10-04; fi + run bash "$here/plugin-changelog.sh" "$mode" "$comparison" "$arg" + label="$mode ambiguous merge bases refuse"; check test "$rc" -ne 0 + snapshot > "$work/after"; label="$mode ambiguous history preserves bytes"; check cmp "$work/before" "$work/after" + fresh + clone=$(mktemp -d "$work/shallow.XXXXXX") + git clone -q --depth=1 "file://$d" "$clone" + d=$clone + run bash "$here/plugin-changelog.sh" "$mode" HEAD "$([[ $mode == check ]] && printf HEAD || printf 2026-10-04)" + label="$mode shallow history refuses"; check test "$rc" -ne 0 +done +printf 'release artifact boundaries: %s passes, %s failures\n' "$passed" "$failed" +[[ $failed == 0 ]] diff --git a/scripts/plugin-changelog.sh b/scripts/plugin-changelog.sh index 478cd122..58377599 100644 --- a/scripts/plugin-changelog.sh +++ b/scripts/plugin-changelog.sh @@ -4,8 +4,14 @@ # bash scripts/plugin-changelog.sh check # The writer reads working-tree versions after bump-plugin-version.sh. The gate reads commits. set -euo pipefail -export GIT_NO_REPLACE_OBJECTS=1 here=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) +# shellcheck source=scripts/marketplace-git-context.lib.sh +source "$here/marketplace-git-context.lib.sh" +marketplace_git_context +# shellcheck source=scripts/plugin-version.lib.sh +source "$here/plugin-version.lib.sh" +# shellcheck source=scripts/json-object.lib.sh +source "$here/json-object.lib.sh" # shellcheck source=scripts/atomic-write.lib.sh source "$here/atomic-write.lib.sh" mode=${1:-} @@ -29,7 +35,10 @@ else (( year > 0 && day <= days )) || fail 'invalid calendar date' fi # Compare only this branch's changes, including when main advances during preparation. -base=$(git merge-base "$base" "$head") || fail 'no merge base' +plugin_version_history || fail 'incomplete comparison history' +bases=$(git merge-base --all "$base" "$head") || fail 'no merge base' +[[ "$bases" =~ ^([0-9a-f]{40}|[0-9a-f]{64})$ ]] || fail 'comparison requires one merge base' +base=$bases work=$(mktemp -d) trap 'rm -rf "$work"' EXIT plugins=$(git ls-tree -d --name-only "$head" plugins/) @@ -62,13 +71,20 @@ provenance() { # Require one complete manifest object before accepting its scalar version. manifest_version() { - jq -ser 'if length == 1 and (.[0] | type == "object") then .[0].version | strings else error("expected one manifest object") end' "$@" + cat "$@" > "$work/manifest.json" || return 1 + json_object_unique "$work/manifest.json" || return 1 + jq -er '.version | strings' "$work/manifest.json" } changed=0 while IFS= read -r dir; do [[ "$dir" =~ ^plugins/[a-z0-9-]+$ ]] || fail "unsupported plugin path: $dir" name=${dir#plugins/} + if [ "$mode" = write ]; then + for parent in plugins "$dir"; do + [ -d "$parent" ] && [ ! -L "$parent" ] || fail 'changelog parent must be a real checkout directory' + done + fi manifest="$dir/plugin.json" old='' base_entry=$(git ls-tree "$base" -- "$manifest") || fail "unreadable base manifest tree: $name" @@ -126,9 +142,13 @@ while IFS= read -r dir; do [ -z "$remaining" ] || fail "incomplete skill removal: $skill" removed=true metadata="$work/removed-skill.md" + previous=$(git ls-tree "$base" -- "$skill/SKILL.md") || fail "unreadable previous skill tree: $skill" + [[ "$previous" == '100644 blob '* || "$previous" == '100755 blob '* ]] || fail "previous skill provenance is not a regular committed file: $skill" git show "$base:$skill/SKILL.md" > "$metadata" 2>/dev/null || fail "missing previous skill: $skill" else - git cat-file -e "$head:$metadata" 2>/dev/null || fail "unreadable skill object: $skill" + [[ "$tree" == '100644 blob '* || "$tree" == '100755 blob '* ]] || fail "skill provenance is not a regular committed file: $skill" + metadata="$work/committed-skill.md" + git cat-file blob "$head:$skill/SKILL.md" > "$metadata" || fail "unreadable skill object: $skill" fi source=$(provenance "$metadata" github-repo) || fail "missing source for $skill" ref=$(provenance "$metadata" github-ref) || fail "missing upstream ref for $skill" diff --git a/scripts/plugin-changelog.test.sh b/scripts/plugin-changelog.test.sh index de48e5c1..85130dc0 100644 --- a/scripts/plugin-changelog.test.sh +++ b/scripts/plugin-changelog.test.sh @@ -96,6 +96,7 @@ for kind in missing prefix fenced nested-fence tilde-info indented duplicate; do fixture; bump sed '/github-ref:/d' "$dir/plugins/alpha/skills/example/SKILL.md" > "$dir/new" mv "$dir/new" "$dir/plugins/alpha/skills/example/SKILL.md" +commit # Provenance is observed at the committed head, never from the working copy. cp "$dir/plugins/alpha/CHANGELOG.md" "$work/before" refuse write "$base" 2026-09-24 cmp "$work/before" "$dir/plugins/alpha/CHANGELOG.md" From b97d55f88ea190cf1123c1769d4d4ad448dd3692 Mon Sep 17 00:00:00 2001 From: Nikolai Emil Damm Date: Sun, 4 Oct 2026 22:02:46 +0200 Subject: [PATCH 2/8] fix: express changelog parent refusal explicitly --- scripts/plugin-changelog.sh | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/scripts/plugin-changelog.sh b/scripts/plugin-changelog.sh index 58377599..3a06b519 100644 --- a/scripts/plugin-changelog.sh +++ b/scripts/plugin-changelog.sh @@ -82,7 +82,9 @@ while IFS= read -r dir; do name=${dir#plugins/} if [ "$mode" = write ]; then for parent in plugins "$dir"; do - [ -d "$parent" ] && [ ! -L "$parent" ] || fail 'changelog parent must be a real checkout directory' + if [ ! -d "$parent" ] || [ -L "$parent" ]; then + fail 'changelog parent must be a real checkout directory' + fi done fi manifest="$dir/plugin.json" From c60d3a1d87e129db7f4855b44842efd50ef61ab8 Mon Sep 17 00:00:00 2001 From: Nikolai Emil Damm Date: Sun, 4 Oct 2026 22:06:33 +0200 Subject: [PATCH 3/8] test: isolate release regression fixture Git context --- scripts/plugin-changelog-release-boundaries.test.sh | 2 ++ 1 file changed, 2 insertions(+) diff --git a/scripts/plugin-changelog-release-boundaries.test.sh b/scripts/plugin-changelog-release-boundaries.test.sh index b0ed6b78..f47e5c9e 100644 --- a/scripts/plugin-changelog-release-boundaries.test.sh +++ b/scripts/plugin-changelog-release-boundaries.test.sh @@ -2,6 +2,8 @@ # Observe release artifacts in real independent repositories without network access. # shellcheck disable=SC2016 # Inner bash snippets expand their own positional arguments. set -euo pipefail +unset GIT_DIR GIT_WORK_TREE GIT_COMMON_DIR GIT_INDEX_FILE GIT_OBJECT_DIRECTORY \ + GIT_ALTERNATE_OBJECT_DIRECTORIES GIT_NAMESPACE GIT_PREFIX GIT_CONFIG_COUNT GIT_CONFIG_PARAMETERS here=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) work=$(mktemp -d) trap 'rm -rf "$work"' EXIT From a0722afb321b9f4927d00fd81cb17a02fec727b2 Mon Sep 17 00:00:00 2001 From: Nikolai Emil Damm Date: Sun, 4 Oct 2026 22:12:12 +0200 Subject: [PATCH 4/8] test: preserve inherited caller Git configuration --- scripts/plugin-changelog-release-boundaries.test.sh | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/scripts/plugin-changelog-release-boundaries.test.sh b/scripts/plugin-changelog-release-boundaries.test.sh index f47e5c9e..f4b75dde 100644 --- a/scripts/plugin-changelog-release-boundaries.test.sh +++ b/scripts/plugin-changelog-release-boundaries.test.sh @@ -3,7 +3,8 @@ # shellcheck disable=SC2016 # Inner bash snippets expand their own positional arguments. set -euo pipefail unset GIT_DIR GIT_WORK_TREE GIT_COMMON_DIR GIT_INDEX_FILE GIT_OBJECT_DIRECTORY \ - GIT_ALTERNATE_OBJECT_DIRECTORIES GIT_NAMESPACE GIT_PREFIX GIT_CONFIG_COUNT GIT_CONFIG_PARAMETERS + GIT_ALTERNATE_OBJECT_DIRECTORIES GIT_NAMESPACE GIT_PREFIX GIT_CONFIG_COUNT GIT_CONFIG_PARAMETERS \ + GIT_CONFIG GIT_CONFIG_GLOBAL GIT_CONFIG_SYSTEM GIT_CONFIG_NOSYSTEM GIT_CEILING_DIRECTORIES here=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) work=$(mktemp -d) trap 'rm -rf "$work"' EXIT From f9ef542455fc6980edc08ba02acd030da6e7140d Mon Sep 17 00:00:00 2001 From: Nikolai Emil Damm Date: Sun, 4 Oct 2026 22:38:42 +0200 Subject: [PATCH 5/8] fix: anchor release writes against ancestor replacement --- AGENTS.md | 5 ++ scripts/atomic-parent-go/main.go | 89 +++++++++++++++++++ scripts/atomic-write.lib.sh | 63 +++++++++---- scripts/generated-write-safety.test.sh | 10 +-- scripts/plugin-changelog-boundaries.test.sh | 2 +- ...lugin-changelog-release-boundaries.test.sh | 21 ++++- 6 files changed, 168 insertions(+), 22 deletions(-) create mode 100644 scripts/atomic-parent-go/main.go diff --git a/AGENTS.md b/AGENTS.md index 4a2d1f59..d55f8287 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -301,6 +301,11 @@ plugin membership) is authored here. Run before opening any PR. Steps 1โ€“2 mirror the CI gates; step 3 is a best-effort local lint that CI does not currently enforce but that keeps workflow changes clean: +The generated version, digest and changelog writers also need Go 1.22 or later. +Their shared writer pins each operation to a real checkout directory, so a concurrent +ancestor replacement cannot redirect publication or recovery through a symlink. +Moved directories retain their staging and original files for operator recovery. + The JSON-field guard needs Go 1.22 or later for every scanned surface. It builds only its installed observer, joins adjacent literal shell quotes without evaluation, requires unique decoded JSON keys, reads Go comments and decoded literal strings/argument blocks, and never diff --git a/scripts/atomic-parent-go/main.go b/scripts/atomic-parent-go/main.go new file mode 100644 index 00000000..2482ab66 --- /dev/null +++ b/scripts/atomic-parent-go/main.go @@ -0,0 +1,89 @@ +// Execute one filesystem operation in a pinned, symlink-free checkout directory. +// Go 1.22's File.Chdir keeps directory identity through a later ancestor rename. +package main + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" +) + +func enter(path string) (*os.File, error) { + before, err := os.Lstat(path) + if err != nil || !before.IsDir() { + return nil, fmt.Errorf("parent is not a real directory: %s", path) + } + f, err := os.Open(path) + if err != nil { + return nil, err + } + after, err := f.Stat() + if err != nil || !os.SameFile(before, after) { + f.Close() + return nil, fmt.Errorf("parent moved while opening: %s", path) + } + if err = f.Chdir(); err != nil { + f.Close() + return nil, err + } + return f, nil +} + +func run(args []string) error { + if len(args) < 4 || args[2] != "--" || !filepath.IsAbs(args[0]) { + return fmt.Errorf("expected absolute checkout root, relative parent, -- and command") + } + root, parent := args[0], args[1] + if filepath.IsAbs(parent) || filepath.Clean(parent) != parent || parent == ".." || strings.HasPrefix(parent, "../") { + return fmt.Errorf("parent escapes checkout") + } + // The process inherits the caller's already-pinned checkout cwd. Reopening + // its absolute name would reintroduce races in ancestors of the checkout. + paths := []string{"."} + if parent != "." { + paths = append(paths, strings.Split(parent, string(os.PathSeparator))...) + } + var dirs []*os.File + defer func() { + for _, d := range dirs { + d.Close() + } + }() + for _, path := range paths { + d, err := enter(path) + if err != nil { + return err + } + dirs = append(dirs, d) + } + cmd := exec.Command(args[3], args[4:]...) + cmd.Stdin, cmd.Stdout, cmd.Stderr = os.Stdin, os.Stdout, os.Stderr + if err := cmd.Run(); err != nil { + return err + } + // A successful write to an anchored directory does not publish a moved path. + for i, d := range dirs { + path := root + if i > 0 { + path = filepath.Join(root, filepath.Join(paths[1:i+1]...)) + } + current, err := os.Lstat(path) + pinned, statErr := d.Stat() + if err != nil || statErr != nil || !current.IsDir() || !os.SameFile(current, pinned) { + return fmt.Errorf("parent moved during operation; recovery retained: %s", path) + } + } + return nil +} + +func main() { + if err := run(os.Args[1:]); err != nil { + if child, ok := err.(*exec.ExitError); ok { + os.Exit(child.ExitCode()) + } + fmt.Fprintf(os.Stderr, "atomic-parent: %v\n", err) + os.Exit(1) + } +} diff --git a/scripts/atomic-write.lib.sh b/scripts/atomic-write.lib.sh index e6d34d6a..81f66eb2 100644 --- a/scripts/atomic-write.lib.sh +++ b/scripts/atomic-write.lib.sh @@ -5,30 +5,59 @@ # failed recovery keeps its backup beside the destination for the operator. # The optional second argument, true, permits creating previously absent destinations. atomic_write_batch() ( + local atomic_root atomic_tool_dir atomic_tool atomic_here + atomic_root=$(pwd -P) || return 1 + atomic_here=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P) || return 1 + atomic_tool_dir=$(mktemp -d) || return 1 + atomic_tool="$atomic_tool_dir/anchor" + if ! GOENV=off GOWORK=off GO111MODULE=off GOTOOLCHAIN=local go build -o "$atomic_tool" "$atomic_here/atomic-parent-go/main.go"; then + rm -rf "$atomic_tool_dir"; return 1 + fi + # Every path-bearing command uses leaf operands in a directory pinned by the + # observer. Checks, staging, replacement, rollback and cleanup share the bind. + atomic_at() { + local target=$1 parent arg + shift + parent=${target%/*}; [ "$parent" != "$target" ] || parent=. + local command_args=() + for arg in "$@"; do + if [ "$parent" != . ] && [[ "$arg" == "$parent/"* ]]; then + command_args+=("${arg#"$parent/"}") + else command_args+=("$arg"); fi + done + ATOMIC_DESTINATION="$target" "$atomic_tool" "$atomic_root" "$parent" -- "${command_args[@]}" + } + atomic_temp() { + local target=$1 leaf + leaf=$(atomic_at "$target" mktemp "$target") || return 1 + if [[ $target == */* ]]; then printf '%s/%s\n' "${target%/*}" "$leaf" + else printf '%s\n' "$leaf"; fi + } local lock=.agent-plugin-write.lock lock_rc=0 if ! mkdir "$lock" 2>/dev/null; then echo '::error::Generated writes are already locked; inspect the existing writer before retrying.' >&2 + rm -rf "$atomic_tool_dir" return 1 fi # The subshell keeps this cleanup independent of each caller's staging trap. - trap 'lock_rc=$?; if ! rmdir "$lock"; then echo "::error::Recovery required; generated-write lock retained." >&2; lock_rc=1; fi; exit "$lock_rc"' EXIT + trap 'lock_rc=$?; rm -rf "$atomic_tool_dir"; if ! rmdir "$lock"; then echo "::error::Recovery required; generated-write lock retained." >&2; lock_rc=1; fi; exit "$lock_rc"' EXIT local plan=$1 allow_new=${2:-false} destination='' staged='' next original i failed=0 local destinations=() replacements=() originals=() sources=() while IFS= read -r -d '' destination; do if ! IFS= read -r -d '' staged; then failed=1; break; fi - if [ -L "$destination" ] || [ ! -f "$staged" ] || + if atomic_at "$destination" test -L "$destination" || [ ! -f "$staged" ] || { [ ! -f "$destination" ] && { [ "$allow_new" != true ] || [ -e "$destination" ]; }; }; then failed=1; break; fi original='' if [ -f "$destination" ]; then - original=$(mktemp "$destination.original.XXXXXX") || { failed=1; break; } + original=$(atomic_temp "$destination.original.XXXXXX") || { failed=1; break; } fi originals+=("$original") destinations+=("$destination") sources+=("$staged") - next=$(mktemp "$destination.next.XXXXXX") || { failed=1; break; } + next=$(atomic_temp "$destination.next.XXXXXX") || { failed=1; break; } replacements+=("$next") - if { [ -n "$original" ] && { ! cp -p "$destination" "$original" || ! cp -p "$destination" "$next"; }; } || - ! cp "$staged" "$next"; then + if { [ -n "$original" ] && { ! atomic_at "$destination" cp -p "$destination" "$original" || ! atomic_at "$destination" cp -p "$destination" "$next"; }; } || + ! atomic_at "$destination" cp "$staged" "$next"; then failed=1; break fi done < "$plan" @@ -37,13 +66,17 @@ atomic_write_batch() ( # Refuse a moved target before the first replacement. for i in "${!destinations[@]}"; do if [ -L "${destinations[$i]}" ] || - { [ -n "${originals[$i]}" ] && ! cmp -s "${destinations[$i]}" "${originals[$i]}"; } || + { [ -n "${originals[$i]}" ] && ! atomic_at "${destinations[$i]}" cmp -s "${destinations[$i]}" "${originals[$i]}"; } || { [ -z "${originals[$i]}" ] && [ -e "${destinations[$i]}" ]; }; then failed=1; break; fi done fi if [ "$failed" -eq 0 ]; then for i in "${!destinations[@]}"; do - if ! mv -f "${replacements[$i]}" "${destinations[$i]}"; then + # Linux -T and macOS -h prevent a last-component directory symlink from + # becoming mv's destination directory after the validation above. + local nofollow=-T + [ "$(uname -s)" != Darwin ] || nofollow=-h + if ! atomic_at "${destinations[$i]}" mv -f "$nofollow" "${replacements[$i]}" "${destinations[$i]}"; then failed=1 local j for j in "${!destinations[@]}"; do @@ -53,16 +86,16 @@ atomic_write_batch() ( if [ -z "${originals[$j]}" ]; then # Remove only a new file whose bytes still match this batch's staged source. if [ ! -e "${destinations[$j]}" ] && [ ! -L "${destinations[$j]}" ]; then continue; fi - if [ -L "${destinations[$j]}" ] || ! cmp -s "${destinations[$j]}" "${sources[$j]}" || - ! rm -f "${destinations[$j]}"; then + if [ -L "${destinations[$j]}" ] || ! atomic_at "${destinations[$j]}" cmp -s "${destinations[$j]}" "${sources[$j]}" || + ! atomic_at "${destinations[$j]}" rm -f "${destinations[$j]}"; then printf '::error::Recovery required; new destination retained at %s\n' "${destinations[$j]}" >&2 fi - elif [ ! -L "${destinations[$j]}" ] && cmp -s "${destinations[$j]}" "${originals[$j]}"; then + elif [ ! -L "${destinations[$j]}" ] && atomic_at "${destinations[$j]}" cmp -s "${destinations[$j]}" "${originals[$j]}"; then continue - elif [ -L "${destinations[$j]}" ] || ! cmp -s "${destinations[$j]}" "${sources[$j]}"; then + elif [ -L "${destinations[$j]}" ] || ! atomic_at "${destinations[$j]}" cmp -s "${destinations[$j]}" "${sources[$j]}"; then printf '::error::Recovery required; conflicting destination preserved at %s; original retained at %s\n' "${destinations[$j]}" "${originals[$j]}" >&2 originals[j]='' - elif ! mv -f "${originals[$j]}" "${destinations[$j]}"; then + elif ! atomic_at "${destinations[$j]}" mv -f "$nofollow" "${originals[$j]}" "${destinations[$j]}"; then printf '::error::Recovery required; original retained at %s\n' "${originals[$j]}" >&2 originals[j]='' fi @@ -71,7 +104,7 @@ atomic_write_batch() ( fi done fi - for next in ${replacements[@]+"${replacements[@]}"}; do rm -f "$next"; done - for original in ${originals[@]+"${originals[@]}"}; do [ -z "$original" ] || rm -f "$original"; done + for next in ${replacements[@]+"${replacements[@]}"}; do atomic_at "$next" rm -f "$next" || failed=1; done + for original in ${originals[@]+"${originals[@]}"}; do [ -z "$original" ] || atomic_at "$original" rm -f "$original" || failed=1; done [ "$failed" -eq 0 ] || { echo '::error::Batch replacement failed.' >&2; return 1; } ) diff --git a/scripts/generated-write-safety.test.sh b/scripts/generated-write-safety.test.sh index 095f082b..69501db5 100644 --- a/scripts/generated-write-safety.test.sh +++ b/scripts/generated-write-safety.test.sh @@ -70,7 +70,7 @@ EOF #!/usr/bin/env bash last=${!#} case "$last" in - "$FAULT_ROOT"/*.json|plugins/*.json|.claude-plugin/marketplace.json|.github/plugin/marketplace.json) + *.json) count=0; [[ ! -f "$FAULT_COUNT" ]] || read -r count < "$FAULT_COUNT" count=$((count+1)); printf '%s\n' "$count" > "$FAULT_COUNT" [[ $count != 2 ]] || exit 1 ;; @@ -114,7 +114,7 @@ mkdir "$root/bin" cat > "$root/bin/mv" <<'STUB' #!/usr/bin/env bash last=${!#} -if [[ $last == plugins/alpha/.claude-plugin/plugin.json && $2 == *.next.* && ! -e "$FAULT_ROOT/once" ]]; then +if [[ ${ATOMIC_DESTINATION:-} == plugins/alpha/.claude-plugin/plugin.json && $last == plugin.json && $* == *.next.* && ! -e "$FAULT_ROOT/once" ]]; then printf '{"name":"alpha","version":"1.2.4","description":"concurrent edit"}\n' > "$FAULT_ROOT/plugins/alpha/plugin.json" cp "$FAULT_ROOT/plugins/alpha/plugin.json" "$FAULT_ROOT/concurrent-evidence.json" touch "$FAULT_ROOT/once" @@ -139,7 +139,7 @@ printf '%s\0%s\0' plugins/alpha/plugin.json "$root/second-source" > "$root/secon cat > "$root/bin/mv" <<'STUB' #!/usr/bin/env bash last=${!#} -if [[ $last == plugins/alpha/.claude-plugin/plugin.json && $2 == *.next.* && ! -e "$FAULT_ROOT/failed-second" ]]; then +if [[ ${ATOMIC_DESTINATION:-} == plugins/alpha/.claude-plugin/plugin.json && $last == plugin.json && $* == *.next.* && ! -e "$FAULT_ROOT/failed-second" ]]; then touch "$FAULT_ROOT/failed-second" exit 1 fi @@ -148,10 +148,10 @@ STUB cat > "$root/bin/cmp" <<'STUB' #!/usr/bin/env bash rc=0; "$REAL_CMP" "$@" || rc=$? -if [[ $rc == 0 && ${2:-} == plugins/alpha/plugin.json && -e "$FAULT_ROOT/failed-second" && ! -e "$FAULT_ROOT/probed" ]]; then +if [[ $rc == 0 && ${ATOMIC_DESTINATION:-} == plugins/alpha/plugin.json && ${2:-} == plugin.json && -e "$FAULT_ROOT/failed-second" && ! -e "$FAULT_ROOT/probed" ]]; then touch "$FAULT_ROOT/probed" child_rc=0 - bash -c '. "$ATOMIC_LIB"; atomic_write_batch "$FAULT_ROOT/second-plan"' > "$FAULT_ROOT/second-output" 2>&1 || child_rc=$? + bash -c 'cd "$FAULT_ROOT"; . "$ATOMIC_LIB"; atomic_write_batch "$FAULT_ROOT/second-plan"' > "$FAULT_ROOT/second-output" 2>&1 || child_rc=$? printf '%s\n' "$child_rc" > "$FAULT_ROOT/second-status" fi exit "$rc" diff --git a/scripts/plugin-changelog-boundaries.test.sh b/scripts/plugin-changelog-boundaries.test.sh index 737114aa..169c308a 100644 --- a/scripts/plugin-changelog-boundaries.test.sh +++ b/scripts/plugin-changelog-boundaries.test.sh @@ -89,7 +89,7 @@ mkdir "$dir/bin"; real_mv=$(command -v mv) cat > "$dir/bin/mv" <<'SH' #!/usr/bin/env bash last=${!#} -if [[ $last == plugins/beta/CHANGELOG.md && ! -e $FAULT_ONCE ]]; then touch "$FAULT_ONCE"; exit 1; fi +if [[ ${ATOMIC_DESTINATION:-} == plugins/beta/CHANGELOG.md && $last == CHANGELOG.md && ! -e $FAULT_ONCE ]]; then touch "$FAULT_ONCE"; exit 1; fi exec "$REAL_MV" "$@" SH chmod +x "$dir/bin/mv" diff --git a/scripts/plugin-changelog-release-boundaries.test.sh b/scripts/plugin-changelog-release-boundaries.test.sh index f4b75dde..777a7615 100644 --- a/scripts/plugin-changelog-release-boundaries.test.sh +++ b/scripts/plugin-changelog-release-boundaries.test.sh @@ -42,7 +42,7 @@ run() { rc=0; (cd "$d" && "$@") > "$work/out" 2> "$work/err" || rc=$?; } # Commit only the fixture-owned plugin paths. commit() { git -C "$d" add -- plugins; git -C "$d" commit -qm change; } # Snapshot all publication files independently of the index. -snapshot() { find "$d/plugins" "$d/.claude-plugin" "$d/.github" -type f -exec shasum {} + | LC_ALL=C sort; } +snapshot() { find "$d/plugins" "$d/.claude-plugin" "$d/.github" -type f -exec cksum {} + | LC_ALL=C sort; } for mode in check write; do for selector in repository worktree index config; do fresh @@ -61,6 +61,25 @@ for mode in check write; do else check bash -c '[[ $1 == 0 ]] && grep -Fq refs/tags/v2.0.0 "$2"' _ "$rc" "$d/plugins/alpha/CHANGELOG.md"; fi done done +fresh +outside=$(mktemp -d "$work/racing-external.XXXXXX") +mkdir "$d/bin" +cat > "$d/bin/mv" <<'STUB' +#!/usr/bin/env bash +if [[ $* == *CHANGELOG.md* && ! -e "$RACE_ROOT/raced" ]]; then + cp -R "$RACE_ROOT/plugins" "$RACE_OUTSIDE/plugins" + "$REAL_MV" "$RACE_ROOT/plugins" "$RACE_ROOT/owned-plugins" + ln -s "$RACE_OUTSIDE/plugins" "$RACE_ROOT/plugins" + touch "$RACE_ROOT/raced" +fi +exec "$REAL_MV" "$@" +STUB +chmod +x "$d/bin/mv" +cp "$d/plugins/alpha/CHANGELOG.md" "$work/race-before" +run env PATH="$d/bin:$PATH" RACE_ROOT="$d" RACE_OUTSIDE="$outside" REAL_MV="$(command -v mv)" bash "$here/plugin-changelog.sh" write "$base" 2026-10-04 +label='ancestor replacement at actual rename refuses publication'; check test "$rc" -ne 0 +label='ancestor replacement cannot alter the external changelog'; check cmp "$work/race-before" "$outside/plugins/alpha/CHANGELOG.md" +label='ancestor replacement preserves the other external plugin'; check cmp "$d/owned-plugins/beta/CHANGELOG.md" "$outside/plugins/beta/CHANGELOG.md" for kind in root plugin destination; do fresh outside=$(mktemp -d "$work/external.XXXXXX") From 86bb08cc229aa89aa0c46079000146e64afb886b Mon Sep 17 00:00:00 2001 From: Nikolai Emil Damm Date: Sun, 4 Oct 2026 22:49:53 +0200 Subject: [PATCH 6/8] fix: retain caller identity and allow scoped skill updates --- .github/workflows/update-agent-skills.yaml | 30 ++++++++++++++++++- AGENTS.md | 4 +++ scripts/atomic-parent-go/main.go | 32 ++++++++++++-------- scripts/atomic-write.lib.sh | 2 ++ scripts/generated-write-safety.test.sh | 35 ++++++++++++++++++++++ scripts/skill-update-scope.sh | 10 +++++++ scripts/skill-update-scope.test.sh | 26 ++++++++++++++++ 7 files changed, 126 insertions(+), 13 deletions(-) create mode 100644 scripts/skill-update-scope.sh create mode 100644 scripts/skill-update-scope.test.sh diff --git a/.github/workflows/update-agent-skills.yaml b/.github/workflows/update-agent-skills.yaml index 2091d3dc..8d525304 100644 --- a/.github/workflows/update-agent-skills.yaml +++ b/.github/workflows/update-agent-skills.yaml @@ -2,6 +2,14 @@ name: ๐Ÿ”„ Update Agent Skills on: workflow_dispatch: + inputs: + scope: + description: Skills to synchronize + type: choice + default: all + options: + - all + - agentic-engineering schedule: - cron: "0 6 * * *" @@ -10,10 +18,30 @@ permissions: pull-requests: write jobs: + scope: + name: Resolve the requested skill scope + runs-on: ubuntu-latest + permissions: + contents: read + outputs: + dir: ${{ steps.scope.outputs.dir }} + steps: + - name: ๐Ÿ“„ Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + - name: Resolve the declared scope + id: scope + env: + EVENT_NAME: ${{ github.event_name }} + SKILL_SCOPE: ${{ inputs.scope }} + run: bash scripts/skill-update-scope.sh "$EVENT_NAME" "$SKILL_SCOPE" >> "$GITHUB_OUTPUT" + update: + needs: scope uses: devantler-tech/actions/.github/workflows/update-agent-skills.yaml@da153eb43b8333f13094abb151a48dc11e431988 # v13.7.4 with: - dir: plugins + dir: ${{ needs.scope.outputs.dir }} # One PR per changed skill, from `deps/agent-skills-update-`, so a skill whose # update is blocked (a review finding, a failing check) holds back only itself instead # of every other skill's update (#175). The bundled-skill edit guard exempts each such diff --git a/AGENTS.md b/AGENTS.md index d55f8287..e2826694 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -173,6 +173,10 @@ re-pulled. Fix it in the skill's **own** upstream (the repo named in its `metada let the update workflow pull it through. `validate-manifests.sh` enforces this mechanically: every bundled `SKILL.md` must carry a non-empty `metadata.github-repo` provenance line, so a hand-authored or provenance-stripped skill fails CI rather than reaching consumers. +For a portfolio-owned repair, dispatch `update-agent-skills.yaml` with +`scope=agentic-engineering` to update only that plugin's skills through the same programmed +PRs. The default `all` scope and scheduled updates cover the full catalogue. Unsupported +scope observations refuse before the updater starts. `guard-bundled-skill-edits.sh` covers the rest of the tree: a PR that changes any file inside a synced skill fails and names the upstream to fix it in, so the edit is refused at review instead of being silently reverted by the next sync. The programmed sync PR is exempt for its own skill, a wholly new skill diff --git a/scripts/atomic-parent-go/main.go b/scripts/atomic-parent-go/main.go index 2482ab66..f43832e7 100644 --- a/scripts/atomic-parent-go/main.go +++ b/scripts/atomic-parent-go/main.go @@ -58,24 +58,32 @@ func run(args []string) error { } dirs = append(dirs, d) } + check := func() error { + for i, d := range dirs { + path := root + if i > 0 { + path = filepath.Join(root, filepath.Join(paths[1:i+1]...)) + } + current, err := os.Lstat(path) + pinned, statErr := d.Stat() + if err != nil || statErr != nil || !current.IsDir() || !os.SameFile(current, pinned) { + return fmt.Errorf("parent moved; recovery retained: %s", path) + } + } + return nil + } + // Refuse before a cleanup side effect too: a post-only check would delete + // originals in a moved checkout while reporting that they were retained. + if err := check(); err != nil { + return err + } cmd := exec.Command(args[3], args[4:]...) cmd.Stdin, cmd.Stdout, cmd.Stderr = os.Stdin, os.Stdout, os.Stderr if err := cmd.Run(); err != nil { return err } // A successful write to an anchored directory does not publish a moved path. - for i, d := range dirs { - path := root - if i > 0 { - path = filepath.Join(root, filepath.Join(paths[1:i+1]...)) - } - current, err := os.Lstat(path) - pinned, statErr := d.Stat() - if err != nil || statErr != nil || !current.IsDir() || !os.SameFile(current, pinned) { - return fmt.Errorf("parent moved during operation; recovery retained: %s", path) - } - } - return nil + return check() } func main() { diff --git a/scripts/atomic-write.lib.sh b/scripts/atomic-write.lib.sh index 81f66eb2..e96126ed 100644 --- a/scripts/atomic-write.lib.sh +++ b/scripts/atomic-write.lib.sh @@ -45,6 +45,8 @@ atomic_write_batch() ( local destinations=() replacements=() originals=() sources=() while IFS= read -r -d '' destination; do if ! IFS= read -r -d '' staged; then failed=1; break; fi + # Staged operands are relative to the caller, before entering any parent. + case "$staged" in /*) ;; *) staged="$atomic_root/$staged" ;; esac if atomic_at "$destination" test -L "$destination" || [ ! -f "$staged" ] || { [ ! -f "$destination" ] && { [ "$allow_new" != true ] || [ -e "$destination" ]; }; }; then failed=1; break; fi original='' diff --git a/scripts/generated-write-safety.test.sh b/scripts/generated-write-safety.test.sh index 69501db5..8a14f78c 100644 --- a/scripts/generated-write-safety.test.sh +++ b/scripts/generated-write-safety.test.sh @@ -172,6 +172,41 @@ label='a pre-existing writer lock refuses publication'; check test "$rc" -ne 0 label='a pre-existing writer lock preserves destination bytes'; check cmp -s "$work/locked-before" "$root/plugins/alpha/plugin.json" label='a failed lock acquisition cannot remove another writer lock'; check test -d "$root/.agent-plugin-write.lock" +# Relative staging names belong to the caller, never to a destination parent. +version_fixture +mkdir -p "$root/staging" "$root/plugins/alpha/staging" +printf 'caller bytes\n' > "$root/staging/source" +printf 'wrong parent bytes\n' > "$root/plugins/alpha/staging/source" +printf '%s\0%s\0' plugins/alpha/plugin.json staging/source > "$root/relative-plan" +rc=0 +(cd "$root" && bash -c '. "$1"; atomic_write_batch relative-plan' _ "$plugins/scripts/atomic-write.lib.sh") > "$work/out" 2>&1 || rc=$? +label='relative staging source stays bound to caller root'; check test "$rc" -eq 0 +label='relative staging cannot publish a destination-parent impostor'; check cmp "$root/staging/source" "$root/plugins/alpha/plugin.json" + +# Cleanup must retain recovery files once the caller checkout path has moved. +version_fixture +mkdir "$root/bin" +outside=$(mktemp -d "$work/moved-outside.XXXXXX") +cp -R "$root/." "$outside/" +printf 'replacement\n' > "$work/moved-source" +printf '%s\0%s\0' plugins/alpha/plugin.json "$work/moved-source" > "$root/moved-plan" +cat > "$root/bin/cp" <<'STUB' +#!/usr/bin/env bash +last=${!#} +if [[ ${1:-} == -p && $last == *.original.* && ! -e "$RACE_FLAG" ]]; then + touch "$RACE_FLAG" + "$REAL_MV" "$RACE_ROOT" "$RACE_ROOT.owned" + ln -s "$RACE_OUTSIDE" "$RACE_ROOT" +fi +exec "$REAL_CP" "$@" +STUB +chmod +x "$root/bin/cp" +rc=0 +(cd "$root" && PATH="$root/bin:$PATH" RACE_ROOT="$root" RACE_OUTSIDE="$outside" RACE_FLAG="$work/moved-flag" REAL_CP="$(command -v cp)" REAL_MV="$(command -v mv)" bash -c '. "$1"; atomic_write_batch moved-plan' _ "$plugins/scripts/atomic-write.lib.sh") > "$work/out" 2>&1 || rc=$? +label='moved checkout refuses publication'; check test "$rc" -ne 0 +label='moved checkout leaves external destination untouched'; check cmp "$outside/plugins/alpha/plugin.json" "$root.owned/plugins/alpha/plugin.json" +label='moved checkout retains its original for recovery'; check test "$(find "$root.owned" -name '*.original.*' | wc -l | tr -d ' ')" -eq 1 + version_fixture git -C "$root" init -q git -C "$root" config user.name Test; git -C "$root" config user.email test@example.invalid diff --git a/scripts/skill-update-scope.sh b/scripts/skill-update-scope.sh new file mode 100644 index 00000000..5d4d71e7 --- /dev/null +++ b/scripts/skill-update-scope.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env bash +# Resolve only declared updater scopes before any reusable updater is called. +set -euo pipefail +refuse() { printf 'skill update scope: unsupported dispatch\n' >&2; exit 2; } +[[ $# -ge 1 && $# -le 2 ]] || refuse +case "$1:${2:-all}" in + schedule:all|workflow_dispatch:all) printf 'dir=plugins\n' ;; + workflow_dispatch:agentic-engineering) printf 'dir=plugins/agentic-engineering/skills\n' ;; + *) refuse ;; +esac diff --git a/scripts/skill-update-scope.test.sh b/scripts/skill-update-scope.test.sh new file mode 100644 index 00000000..51d4954a --- /dev/null +++ b/scripts/skill-update-scope.test.sh @@ -0,0 +1,26 @@ +#!/usr/bin/env bash +# Exercise the actual scope resolver used before the programmed updater. +set -euo pipefail +here=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) +work=$(mktemp -d) +trap 'rm -rf "$work"' EXIT +for event in schedule workflow_dispatch; do + bash "$here/skill-update-scope.sh" "$event" > "$work/out" + [[ $(cat "$work/out") == dir=plugins ]] +done +bash "$here/skill-update-scope.sh" workflow_dispatch all > "$work/out" +[[ $(cat "$work/out") == dir=plugins ]] +bash "$here/skill-update-scope.sh" workflow_dispatch agentic-engineering > "$work/out" +[[ $(cat "$work/out") == dir=plugins/agentic-engineering/skills ]] +for scope in unknown ../outside $'agentic-engineering\ndir=plugins'; do + rc=0 + bash "$here/skill-update-scope.sh" workflow_dispatch "$scope" > "$work/out" 2> "$work/err" || rc=$? + [[ $rc == 2 && ! -s $work/out && -s $work/err ]] +done +rc=0 +bash "$here/skill-update-scope.sh" schedule agentic-engineering > "$work/out" 2> "$work/err" || rc=$? +[[ $rc == 2 && ! -s $work/out ]] +rc=0 +bash "$here/skill-update-scope.sh" pull_request all > "$work/out" 2> "$work/err" || rc=$? +[[ $rc == 2 && ! -s $work/out ]] +printf 'skill update scopes: PASS (9 cases)\n' From 73e7ae0313eac25255057eda3d0998f56f674522 Mon Sep 17 00:00:00 2001 From: Nikolai Emil Damm Date: Sun, 4 Oct 2026 22:53:07 +0200 Subject: [PATCH 7/8] fix: preserve programmed skill branch identity for scoped sync --- .github/workflows/update-agent-skills.yaml | 14 ++++++++------ scripts/skill-update-scope.sh | 4 ++-- scripts/skill-update-scope.test.sh | 6 +++--- 3 files changed, 13 insertions(+), 11 deletions(-) diff --git a/.github/workflows/update-agent-skills.yaml b/.github/workflows/update-agent-skills.yaml index 8d525304..8ee2cb9d 100644 --- a/.github/workflows/update-agent-skills.yaml +++ b/.github/workflows/update-agent-skills.yaml @@ -25,6 +25,7 @@ jobs: contents: read outputs: dir: ${{ steps.scope.outputs.dir }} + branch: ${{ steps.scope.outputs.branch }} steps: - name: ๐Ÿ“„ Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 @@ -42,6 +43,7 @@ jobs: uses: devantler-tech/actions/.github/workflows/update-agent-skills.yaml@da153eb43b8333f13094abb151a48dc11e431988 # v13.7.4 with: dir: ${{ needs.scope.outputs.dir }} + pr-branch: ${{ needs.scope.outputs.branch }} # One PR per changed skill, from `deps/agent-skills-update-`, so a skill whose # update is blocked (a review finding, a failing check) holds back only itself instead # of every other skill's update (#175). The bundled-skill edit guard exempts each such @@ -69,7 +71,7 @@ jobs: # version; the next daily run rebuilds its branch from the new main and bumps it again. bump-versions: name: Refresh digests and release notes for ${{ matrix.skill.path }} - needs: update + needs: [scope, update] if: ${{ needs.update.outputs.skills != '' && needs.update.outputs.skills != '[]' }} runs-on: ubuntu-latest strategy: @@ -90,7 +92,7 @@ jobs: - name: ๐Ÿ“„ Checkout the update branch uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: - ref: deps/agent-skills-update-${{ matrix.skill.slug }} + ref: ${{ needs.scope.outputs.branch }}-${{ matrix.skill.slug }} fetch-depth: 0 token: ${{ steps.app-token.outputs.token }} # This job must push, so credentials stay on the checkout. @@ -113,7 +115,7 @@ jobs: - name: ๐Ÿ” Refresh the desired-state digests the sync moved if: steps.checkout.outcome == 'success' env: - SKILL_SLUG: ${{ matrix.skill.slug }} + SKILL_BRANCH: ${{ needs.scope.outputs.branch }}-${{ matrix.skill.slug }} run: | git config user.name "github-actions[bot]" git config user.email "41898282+github-actions[bot]@users.noreply.github.com" @@ -126,12 +128,12 @@ jobs: # change could never ride along in this commit. git add -- '*.desired-state.json' git commit -m "chore(deps): refresh desired-state digests for synced content" - git push origin "HEAD:deps/agent-skills-update-${SKILL_SLUG}" + git push origin "HEAD:${SKILL_BRANCH}" - name: ๐Ÿ”ข Bump every plugin whose content changed if: steps.checkout.outcome == 'success' env: - SKILL_SLUG: ${{ matrix.skill.slug }} + SKILL_BRANCH: ${{ needs.scope.outputs.branch }}-${{ matrix.skill.slug }} run: | git config user.name "github-actions[bot]" git config user.email "41898282+github-actions[bot]@users.noreply.github.com" @@ -147,4 +149,4 @@ jobs: exit 0 fi git commit -m "chore(deps): bump plugin versions and record skill updates" - git push origin "HEAD:deps/agent-skills-update-${SKILL_SLUG}" + git push origin "HEAD:${SKILL_BRANCH}" diff --git a/scripts/skill-update-scope.sh b/scripts/skill-update-scope.sh index 5d4d71e7..bafc8440 100644 --- a/scripts/skill-update-scope.sh +++ b/scripts/skill-update-scope.sh @@ -4,7 +4,7 @@ set -euo pipefail refuse() { printf 'skill update scope: unsupported dispatch\n' >&2; exit 2; } [[ $# -ge 1 && $# -le 2 ]] || refuse case "$1:${2:-all}" in - schedule:all|workflow_dispatch:all) printf 'dir=plugins\n' ;; - workflow_dispatch:agentic-engineering) printf 'dir=plugins/agentic-engineering/skills\n' ;; + schedule:all|workflow_dispatch:all) printf 'dir=plugins\nbranch=deps/agent-skills-update\n' ;; + workflow_dispatch:agentic-engineering) printf 'dir=plugins/agentic-engineering/skills\nbranch=deps/agent-skills-update-agentic-engineering-skills\n' ;; *) refuse ;; esac diff --git a/scripts/skill-update-scope.test.sh b/scripts/skill-update-scope.test.sh index 51d4954a..89803972 100644 --- a/scripts/skill-update-scope.test.sh +++ b/scripts/skill-update-scope.test.sh @@ -6,12 +6,12 @@ work=$(mktemp -d) trap 'rm -rf "$work"' EXIT for event in schedule workflow_dispatch; do bash "$here/skill-update-scope.sh" "$event" > "$work/out" - [[ $(cat "$work/out") == dir=plugins ]] + [[ $(cat "$work/out") == $'dir=plugins\nbranch=deps/agent-skills-update' ]] done bash "$here/skill-update-scope.sh" workflow_dispatch all > "$work/out" -[[ $(cat "$work/out") == dir=plugins ]] +[[ $(cat "$work/out") == $'dir=plugins\nbranch=deps/agent-skills-update' ]] bash "$here/skill-update-scope.sh" workflow_dispatch agentic-engineering > "$work/out" -[[ $(cat "$work/out") == dir=plugins/agentic-engineering/skills ]] +[[ $(cat "$work/out") == $'dir=plugins/agentic-engineering/skills\nbranch=deps/agent-skills-update-agentic-engineering-skills' ]] for scope in unknown ../outside $'agentic-engineering\ndir=plugins'; do rc=0 bash "$here/skill-update-scope.sh" workflow_dispatch "$scope" > "$work/out" 2> "$work/err" || rc=$? From d32485450dc96a48a60902440fd0e47b6487a9d4 Mon Sep 17 00:00:00 2001 From: Nikolai Emil Damm Date: Sun, 4 Oct 2026 22:55:41 +0200 Subject: [PATCH 8/8] test: enforce release boundaries and updater scopes in CI --- .github/workflows/ci.yaml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index a875b3ea..ee94f978 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -183,6 +183,12 @@ jobs: - name: ๐Ÿงช Verify changelog observation and recovery boundaries run: bash scripts/plugin-changelog-boundaries.test.sh + - name: ๐Ÿงช Verify release artifact identity and write boundaries + run: bash scripts/plugin-changelog-release-boundaries.test.sh + + - name: ๐Ÿงช Verify updater dispatch scopes + run: bash scripts/skill-update-scope.test.sh + - name: ๐Ÿงช Self-test the manifest guard # Proves validate-manifests.sh PASSES a consistent fixture and FAILS each # drift scenario it exists to catch (malformed/desynced manifests, every