From 565140e8220807def710d55b2386d5a6d7e6c993 Mon Sep 17 00:00:00 2001 From: Jacob Howard Date: Fri, 17 Oct 2025 17:11:20 -0600 Subject: [PATCH] ci: add update-pins tool and workflow Signed-off-by: Jacob Howard --- .github/workflows/update-pins.yaml | 136 +++++++++++++++++++++ Taskfile.yml | 4 + cmd/update-pins/main.go | 187 +++++++++++++++++++++++++++++ 3 files changed, 327 insertions(+) create mode 100644 .github/workflows/update-pins.yaml create mode 100644 cmd/update-pins/main.go diff --git a/.github/workflows/update-pins.yaml b/.github/workflows/update-pins.yaml new file mode 100644 index 00000000..525fb792 --- /dev/null +++ b/.github/workflows/update-pins.yaml @@ -0,0 +1,136 @@ +name: Update MCP Server Pins + +on: + schedule: + - cron: "0 5 * * *" + workflow_dispatch: + +permissions: + contents: write + pull-requests: write + +jobs: + update-pins: + runs-on: ubuntu-24.04 + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Configure Git user + run: | + git config user.name "docker-mcp-bot" + git config user.email "docker-mcp-bot@users.noreply.github.com" + + - name: Install Go + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + + - name: Install Task + uses: arduino/setup-task@v2 + with: + version: 3.x + repo-token: ${{ secrets.GITHUB_TOKEN }} + + - name: Update pinned commits + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + task update-pins + + - name: Collect per-server patches + id: prepare + run: | + # Gather the diff for each modified server YAML and store it as an + # individual patch file so we can open one PR per server. + mkdir -p patches + changed_files=$(git status --porcelain | awk '$2 ~ /^servers\/.*\/server.yaml$/ {print $2}') + if [ -z "$changed_files" ]; then + echo "changed=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + + server_list=() + for file in $changed_files; do + server=$(basename "$(dirname "$file")") + git diff -- "$file" > "patches/${server}.patch" + server_list+=("$server") + done + + # Reset the working tree so we can apply patches one-at-a-time. + git checkout -- servers + + # Expose the server list to later steps. + printf '%s\n' "${server_list[@]}" | paste -sd',' - > patches/servers.txt + echo "changed=true" >> "$GITHUB_OUTPUT" + echo "servers=$(cat patches/servers.txt)" >> "$GITHUB_OUTPUT" + + - name: Create pull requests + if: steps.prepare.outputs.changed == 'true' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + IFS=',' read -ra SERVERS <<< "${{ steps.prepare.outputs.servers }}" + for server in "${SERVERS[@]}"; do + patch="patches/${server}.patch" + if [ ! -s "$patch" ]; then + echo "No patch found for $server, skipping." + continue + fi + + # Look up the new commit hash in the patch so we can decide whether + # an existing automation branch already covers it. + new_commit=$(awk '/^\+.*commit:/{print $2}' "$patch" | tail -n1) + branch="automation/update-pin-${server}" + + # Start from a clean copy of main for each server so branches do not + # interfere with one another. + git checkout main + git fetch origin main + git reset --hard origin/main + + # If a prior PR exists for this server, fetch it and bail out when + # the requested commit is identical (no update required). + if git ls-remote --exit-code --heads origin "$branch" >/dev/null 2>&1; then + git fetch origin "$branch" + existing_commit=$(git show "origin/${branch}:servers/${server}/server.yaml" 2>/dev/null | awk '/commit:/{print $2}' | tail -n1) + if [ -n "$existing_commit" ] && [ "$existing_commit" = "$new_commit" ]; then + echo "Existing PR for $server already pins ${existing_commit}; skipping." + continue + fi + fi + + # Apply the patch onto a fresh branch for this server. + git checkout -B "$branch" origin/main + if ! git apply "$patch"; then + echo "Failed to apply patch for $server, skipping." + continue + fi + + if git diff --quiet; then + echo "No changes after applying patch for $server, skipping." + continue + fi + + # Commit the server YAML change and force-push the automation branch. + git add "servers/${server}/server.yaml" + git commit -m "chore: update pin for ${server}" + git push --force origin "$branch" + + # Create or update the PR dedicated to this server. + if gh pr view --head "$branch" >/dev/null 2>&1; then + gh pr edit "$branch" \ + --title "chore: update pin for ${server}" \ + --body "Automated commit pin update for ${server}." + else + gh pr create \ + --title "chore: update pin for ${server}" \ + --body "Automated commit pin update for ${server}." \ + --base main \ + --head "$branch" + fi + done + + git checkout main diff --git a/Taskfile.yml b/Taskfile.yml index 6098d96f..270a89f4 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -5,6 +5,10 @@ tasks: desc: Create a new mcp server definition cmd: go run ./cmd/create {{.CLI_ARGS}} + update-pins: + desc: Refresh server commit pins from upstream repositories + cmd: go run ./cmd/update-pins {{.CLI_ARGS}} + build: desc: Build a server image cmd: go run ./cmd/build {{.CLI_ARGS}} diff --git a/cmd/update-pins/main.go b/cmd/update-pins/main.go new file mode 100644 index 00000000..90aa6942 --- /dev/null +++ b/cmd/update-pins/main.go @@ -0,0 +1,187 @@ +/* +Copyright © 2025 Docker, Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. +*/ + +package main + +import ( + "context" + "fmt" + "os" + "path/filepath" + "regexp" + "sort" + "strings" + + "github.com/docker/mcp-registry/pkg/github" + "github.com/docker/mcp-registry/pkg/servers" +) + +// main orchestrates the pin refresh process, updating server definitions when +// upstream branches advance. +func main() { + ctx := context.Background() + + // Enumerate the server directories that contain YAML definitions. + entries, err := os.ReadDir("servers") + if err != nil { + fmt.Fprintf(os.Stderr, "reading servers directory: %v\n", err) + os.Exit(1) + } + + var updated []string + for _, entry := range entries { + // Ignore any files that are not server directories. + if !entry.IsDir() { + continue + } + + serverPath := filepath.Join("servers", entry.Name(), "server.yaml") + server, err := servers.Read(serverPath) + if err != nil { + fmt.Fprintf(os.Stderr, "reading %s: %v\n", serverPath, err) + continue + } + + if server.Type != "server" { + continue + } + + if !strings.HasPrefix(server.Image, "mcp/") { + continue + } + + if server.Source.Project == "" { + continue + } + + // Only GitHub repositories are supported by the current workflow. + if !strings.Contains(server.Source.Project, "github.com/") { + fmt.Printf("Skipping %s: project is not hosted on GitHub.\n", server.Name) + continue + } + + // Unpinned servers have to undergo a separate security audit first. + existing := strings.ToLower(server.Source.Commit) + if existing == "" { + fmt.Printf("Skipping %s: no pinned commit present.\n", server.Name) + continue + } + + // Resolve the current branch head for comparison. + branch := server.GetBranch() + client := github.NewFromServer(server) + + latest, err := client.GetCommitSHA1(ctx, server.Source.Project, branch) + if err != nil { + fmt.Fprintf(os.Stderr, "fetching commit for %s: %v\n", server.Name, err) + continue + } + + latest = strings.ToLower(latest) + + changed, err := writeCommit(serverPath, latest) + if err != nil { + fmt.Fprintf(os.Stderr, "updating %s: %v\n", server.Name, err) + continue + } + + if existing != latest { + fmt.Printf("Updated %s: %s -> %s\n", server.Name, existing, latest) + } else if changed { + fmt.Printf("Reformatted pinned commit for %s at %s\n", server.Name, latest) + } + + if changed { + updated = append(updated, server.Name) + } + if existing == latest && !changed { + continue + } + } + + if len(updated) == 0 { + fmt.Println("No commit updates required.") + return + } + + sort.Strings(updated) + fmt.Println("Servers with updated pins:", strings.Join(updated, ", ")) +} + +// writeCommit inserts or updates the commit field inside the source block of +// a server definition while preserving the surrounding formatting. The bool +// return value indicates whether the file contents were modified. +func writeCommit(path string, updated string) (bool, error) { + content, err := os.ReadFile(path) + if err != nil { + return false, err + } + + lines := strings.Split(string(content), "\n") + sourceIndex := -1 + for i, line := range lines { + if strings.HasPrefix(line, "source:") { + sourceIndex = i + break + } + } + if sourceIndex == -1 { + return false, fmt.Errorf("no source block found") + } + + commitIndex := -1 + indent := "" + commitPattern := regexp.MustCompile(`^([ \t]+)commit:\s*[a-fA-F0-9]{40}\s*$`) + for i := sourceIndex + 1; i < len(lines); i++ { + line := lines[i] + if !strings.HasPrefix(line, " ") { + break + } + + if match := commitPattern.FindStringSubmatch(line); match != nil { + commitIndex = i + indent = match[1] + break + } + } + + if commitIndex < 0 { + return false, fmt.Errorf("no commit line found in source block") + } + + newLine := indent + "commit: " + updated + lines[commitIndex] = newLine + + output := strings.Join(lines, "\n") + if !strings.HasSuffix(output, "\n") { + output += "\n" + } + + if output == string(content) { + return false, nil + } + + if err := os.WriteFile(path, []byte(output), 0o644); err != nil { + return false, err + } + return true, nil +}