Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
44 changes: 37 additions & 7 deletions .github/workflows/update-agent-skills.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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 * * *"

Expand All @@ -10,10 +18,32 @@ 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 }}
branch: ${{ steps.scope.outputs.branch }}
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 }}
pr-branch: ${{ needs.scope.outputs.branch }}
# One PR per changed skill, from `deps/agent-skills-update-<slug>`, 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
Expand Down Expand Up @@ -41,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:
Expand All @@ -62,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.
Expand All @@ -85,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"
Expand All @@ -98,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"
Expand All @@ -119,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}"
10 changes: 10 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -301,6 +305,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
Expand Down Expand Up @@ -331,6 +340,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
Expand Down
97 changes: 97 additions & 0 deletions scripts/atomic-parent-go/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
// 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)
}
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.
return check()
}

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)
}
}
65 changes: 50 additions & 15 deletions scripts/atomic-write.lib.sh
Original file line number Diff line number Diff line change
Expand Up @@ -5,30 +5,61 @@
# 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" ] ||
# 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=''
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"
Expand All @@ -37,13 +68,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
Expand All @@ -53,16 +88,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
Expand All @@ -71,7 +106,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; }
)
4 changes: 4 additions & 0 deletions scripts/bump-plugin-version.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand All @@ -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
Expand All @@ -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
Expand Down
Loading
Loading