Skip to content

Commit 565140e

Browse files
committed
ci: add update-pins tool and workflow
Signed-off-by: Jacob Howard <[email protected]>
1 parent 286c1cb commit 565140e

File tree

3 files changed

+327
-0
lines changed

3 files changed

+327
-0
lines changed

.github/workflows/update-pins.yaml

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
name: Update MCP Server Pins
2+
3+
on:
4+
schedule:
5+
- cron: "0 5 * * *"
6+
workflow_dispatch:
7+
8+
permissions:
9+
contents: write
10+
pull-requests: write
11+
12+
jobs:
13+
update-pins:
14+
runs-on: ubuntu-24.04
15+
steps:
16+
- name: Checkout repository
17+
uses: actions/checkout@v4
18+
with:
19+
fetch-depth: 0
20+
21+
- name: Configure Git user
22+
run: |
23+
git config user.name "docker-mcp-bot"
24+
git config user.email "[email protected]"
25+
26+
- name: Install Go
27+
uses: actions/setup-go@v5
28+
with:
29+
go-version-file: go.mod
30+
31+
- name: Install Task
32+
uses: arduino/setup-task@v2
33+
with:
34+
version: 3.x
35+
repo-token: ${{ secrets.GITHUB_TOKEN }}
36+
37+
- name: Update pinned commits
38+
env:
39+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
40+
run: |
41+
task update-pins
42+
43+
- name: Collect per-server patches
44+
id: prepare
45+
run: |
46+
# Gather the diff for each modified server YAML and store it as an
47+
# individual patch file so we can open one PR per server.
48+
mkdir -p patches
49+
changed_files=$(git status --porcelain | awk '$2 ~ /^servers\/.*\/server.yaml$/ {print $2}')
50+
if [ -z "$changed_files" ]; then
51+
echo "changed=false" >> "$GITHUB_OUTPUT"
52+
exit 0
53+
fi
54+
55+
server_list=()
56+
for file in $changed_files; do
57+
server=$(basename "$(dirname "$file")")
58+
git diff -- "$file" > "patches/${server}.patch"
59+
server_list+=("$server")
60+
done
61+
62+
# Reset the working tree so we can apply patches one-at-a-time.
63+
git checkout -- servers
64+
65+
# Expose the server list to later steps.
66+
printf '%s\n' "${server_list[@]}" | paste -sd',' - > patches/servers.txt
67+
echo "changed=true" >> "$GITHUB_OUTPUT"
68+
echo "servers=$(cat patches/servers.txt)" >> "$GITHUB_OUTPUT"
69+
70+
- name: Create pull requests
71+
if: steps.prepare.outputs.changed == 'true'
72+
env:
73+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
74+
run: |
75+
IFS=',' read -ra SERVERS <<< "${{ steps.prepare.outputs.servers }}"
76+
for server in "${SERVERS[@]}"; do
77+
patch="patches/${server}.patch"
78+
if [ ! -s "$patch" ]; then
79+
echo "No patch found for $server, skipping."
80+
continue
81+
fi
82+
83+
# Look up the new commit hash in the patch so we can decide whether
84+
# an existing automation branch already covers it.
85+
new_commit=$(awk '/^\+.*commit:/{print $2}' "$patch" | tail -n1)
86+
branch="automation/update-pin-${server}"
87+
88+
# Start from a clean copy of main for each server so branches do not
89+
# interfere with one another.
90+
git checkout main
91+
git fetch origin main
92+
git reset --hard origin/main
93+
94+
# If a prior PR exists for this server, fetch it and bail out when
95+
# the requested commit is identical (no update required).
96+
if git ls-remote --exit-code --heads origin "$branch" >/dev/null 2>&1; then
97+
git fetch origin "$branch"
98+
existing_commit=$(git show "origin/${branch}:servers/${server}/server.yaml" 2>/dev/null | awk '/commit:/{print $2}' | tail -n1)
99+
if [ -n "$existing_commit" ] && [ "$existing_commit" = "$new_commit" ]; then
100+
echo "Existing PR for $server already pins ${existing_commit}; skipping."
101+
continue
102+
fi
103+
fi
104+
105+
# Apply the patch onto a fresh branch for this server.
106+
git checkout -B "$branch" origin/main
107+
if ! git apply "$patch"; then
108+
echo "Failed to apply patch for $server, skipping."
109+
continue
110+
fi
111+
112+
if git diff --quiet; then
113+
echo "No changes after applying patch for $server, skipping."
114+
continue
115+
fi
116+
117+
# Commit the server YAML change and force-push the automation branch.
118+
git add "servers/${server}/server.yaml"
119+
git commit -m "chore: update pin for ${server}"
120+
git push --force origin "$branch"
121+
122+
# Create or update the PR dedicated to this server.
123+
if gh pr view --head "$branch" >/dev/null 2>&1; then
124+
gh pr edit "$branch" \
125+
--title "chore: update pin for ${server}" \
126+
--body "Automated commit pin update for ${server}."
127+
else
128+
gh pr create \
129+
--title "chore: update pin for ${server}" \
130+
--body "Automated commit pin update for ${server}." \
131+
--base main \
132+
--head "$branch"
133+
fi
134+
done
135+
136+
git checkout main

Taskfile.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,10 @@ tasks:
55
desc: Create a new mcp server definition
66
cmd: go run ./cmd/create {{.CLI_ARGS}}
77

8+
update-pins:
9+
desc: Refresh server commit pins from upstream repositories
10+
cmd: go run ./cmd/update-pins {{.CLI_ARGS}}
11+
812
build:
913
desc: Build a server image
1014
cmd: go run ./cmd/build {{.CLI_ARGS}}

cmd/update-pins/main.go

Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
1+
/*
2+
Copyright © 2025 Docker, Inc.
3+
4+
Permission is hereby granted, free of charge, to any person obtaining a copy
5+
of this software and associated documentation files (the "Software"), to deal
6+
in the Software without restriction, including without limitation the rights
7+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
8+
copies of the Software, and to permit persons to whom the Software is
9+
furnished to do so, subject to the following conditions:
10+
11+
The above copyright notice and this permission notice shall be included in
12+
all copies or substantial portions of the Software.
13+
14+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
15+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
16+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
17+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
18+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
19+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
20+
THE SOFTWARE.
21+
*/
22+
23+
package main
24+
25+
import (
26+
"context"
27+
"fmt"
28+
"os"
29+
"path/filepath"
30+
"regexp"
31+
"sort"
32+
"strings"
33+
34+
"github.com/docker/mcp-registry/pkg/github"
35+
"github.com/docker/mcp-registry/pkg/servers"
36+
)
37+
38+
// main orchestrates the pin refresh process, updating server definitions when
39+
// upstream branches advance.
40+
func main() {
41+
ctx := context.Background()
42+
43+
// Enumerate the server directories that contain YAML definitions.
44+
entries, err := os.ReadDir("servers")
45+
if err != nil {
46+
fmt.Fprintf(os.Stderr, "reading servers directory: %v\n", err)
47+
os.Exit(1)
48+
}
49+
50+
var updated []string
51+
for _, entry := range entries {
52+
// Ignore any files that are not server directories.
53+
if !entry.IsDir() {
54+
continue
55+
}
56+
57+
serverPath := filepath.Join("servers", entry.Name(), "server.yaml")
58+
server, err := servers.Read(serverPath)
59+
if err != nil {
60+
fmt.Fprintf(os.Stderr, "reading %s: %v\n", serverPath, err)
61+
continue
62+
}
63+
64+
if server.Type != "server" {
65+
continue
66+
}
67+
68+
if !strings.HasPrefix(server.Image, "mcp/") {
69+
continue
70+
}
71+
72+
if server.Source.Project == "" {
73+
continue
74+
}
75+
76+
// Only GitHub repositories are supported by the current workflow.
77+
if !strings.Contains(server.Source.Project, "github.com/") {
78+
fmt.Printf("Skipping %s: project is not hosted on GitHub.\n", server.Name)
79+
continue
80+
}
81+
82+
// Unpinned servers have to undergo a separate security audit first.
83+
existing := strings.ToLower(server.Source.Commit)
84+
if existing == "" {
85+
fmt.Printf("Skipping %s: no pinned commit present.\n", server.Name)
86+
continue
87+
}
88+
89+
// Resolve the current branch head for comparison.
90+
branch := server.GetBranch()
91+
client := github.NewFromServer(server)
92+
93+
latest, err := client.GetCommitSHA1(ctx, server.Source.Project, branch)
94+
if err != nil {
95+
fmt.Fprintf(os.Stderr, "fetching commit for %s: %v\n", server.Name, err)
96+
continue
97+
}
98+
99+
latest = strings.ToLower(latest)
100+
101+
changed, err := writeCommit(serverPath, latest)
102+
if err != nil {
103+
fmt.Fprintf(os.Stderr, "updating %s: %v\n", server.Name, err)
104+
continue
105+
}
106+
107+
if existing != latest {
108+
fmt.Printf("Updated %s: %s -> %s\n", server.Name, existing, latest)
109+
} else if changed {
110+
fmt.Printf("Reformatted pinned commit for %s at %s\n", server.Name, latest)
111+
}
112+
113+
if changed {
114+
updated = append(updated, server.Name)
115+
}
116+
if existing == latest && !changed {
117+
continue
118+
}
119+
}
120+
121+
if len(updated) == 0 {
122+
fmt.Println("No commit updates required.")
123+
return
124+
}
125+
126+
sort.Strings(updated)
127+
fmt.Println("Servers with updated pins:", strings.Join(updated, ", "))
128+
}
129+
130+
// writeCommit inserts or updates the commit field inside the source block of
131+
// a server definition while preserving the surrounding formatting. The bool
132+
// return value indicates whether the file contents were modified.
133+
func writeCommit(path string, updated string) (bool, error) {
134+
content, err := os.ReadFile(path)
135+
if err != nil {
136+
return false, err
137+
}
138+
139+
lines := strings.Split(string(content), "\n")
140+
sourceIndex := -1
141+
for i, line := range lines {
142+
if strings.HasPrefix(line, "source:") {
143+
sourceIndex = i
144+
break
145+
}
146+
}
147+
if sourceIndex == -1 {
148+
return false, fmt.Errorf("no source block found")
149+
}
150+
151+
commitIndex := -1
152+
indent := ""
153+
commitPattern := regexp.MustCompile(`^([ \t]+)commit:\s*[a-fA-F0-9]{40}\s*$`)
154+
for i := sourceIndex + 1; i < len(lines); i++ {
155+
line := lines[i]
156+
if !strings.HasPrefix(line, " ") {
157+
break
158+
}
159+
160+
if match := commitPattern.FindStringSubmatch(line); match != nil {
161+
commitIndex = i
162+
indent = match[1]
163+
break
164+
}
165+
}
166+
167+
if commitIndex < 0 {
168+
return false, fmt.Errorf("no commit line found in source block")
169+
}
170+
171+
newLine := indent + "commit: " + updated
172+
lines[commitIndex] = newLine
173+
174+
output := strings.Join(lines, "\n")
175+
if !strings.HasSuffix(output, "\n") {
176+
output += "\n"
177+
}
178+
179+
if output == string(content) {
180+
return false, nil
181+
}
182+
183+
if err := os.WriteFile(path, []byte(output), 0o644); err != nil {
184+
return false, err
185+
}
186+
return true, nil
187+
}

0 commit comments

Comments
 (0)