docs: clarify release deployment process and enable manual deploy trigger
- Add workflow_dispatch to deploy.yml for manual deployment triggers - Document that releases don't auto-deploy to production - Add instructions for deploying releases to production - Add verification steps for deployment 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
df018fc43b
commit
c40374c280
29 changed files with 134 additions and 112 deletions
1
.github/workflows/deploy.yml
vendored
1
.github/workflows/deploy.yml
vendored
|
|
@ -4,6 +4,7 @@ on:
|
|||
push:
|
||||
branches:
|
||||
- main
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
|
|
|||
|
|
@ -308,7 +308,7 @@ func createServerJSON(
|
|||
URL: repoURL,
|
||||
Source: repoSource,
|
||||
},
|
||||
Version: version,
|
||||
Version: version,
|
||||
Packages: []model.Package{pkg},
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ func LogoutCommand() error {
|
|||
}
|
||||
|
||||
tokenPath := filepath.Join(homeDir, TokenFileName)
|
||||
|
||||
|
||||
// Check if token file exists
|
||||
if _, err := os.Stat(tokenPath); os.IsNotExist(err) {
|
||||
_, _ = fmt.Fprintln(os.Stdout, "Not logged in")
|
||||
|
|
@ -30,7 +30,7 @@ func LogoutCommand() error {
|
|||
".mcpregistry_github_token",
|
||||
".mcpregistry_registry_token",
|
||||
}
|
||||
|
||||
|
||||
for _, file := range legacyFiles {
|
||||
path := filepath.Join(homeDir, file)
|
||||
if _, err := os.Stat(path); err == nil {
|
||||
|
|
@ -40,4 +40,4 @@ func LogoutCommand() error {
|
|||
|
||||
_, _ = fmt.Fprintln(os.Stdout, "✓ Successfully logged out")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -67,4 +67,4 @@ func printUsage() {
|
|||
_, _ = fmt.Fprintln(os.Stdout, " publish Publish server.json to the registry")
|
||||
_, _ = fmt.Fprintln(os.Stdout)
|
||||
_, _ = fmt.Fprintln(os.Stdout, "Use 'mcp-publisher <command> --help' for more information about a command.")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -51,7 +51,7 @@ func SetupIngressController(ctx *pulumi.Context, cluster *providers.ProviderInfo
|
|||
Values: pulumi.Map{
|
||||
"controller": pulumi.Map{
|
||||
"service": pulumi.Map{
|
||||
"type": serviceType,
|
||||
"type": serviceType,
|
||||
"annotations": pulumi.Map{},
|
||||
},
|
||||
"config": pulumi.Map{
|
||||
|
|
|
|||
|
|
@ -22,19 +22,19 @@ type Provider struct{}
|
|||
// createGCPProvider creates a GCP provider with explicit credentials if configured
|
||||
func createGCPProvider(ctx *pulumi.Context, name string) (*gcp.Provider, error) {
|
||||
gcpConf := config.New(ctx, "gcp")
|
||||
|
||||
|
||||
// Get project ID from config
|
||||
projectID := gcpConf.Get("project")
|
||||
if projectID == "" {
|
||||
return nil, fmt.Errorf("GCP project ID not configured. Set gcp:project")
|
||||
}
|
||||
|
||||
|
||||
// Get region from config or use default
|
||||
region := gcpConf.Get("region")
|
||||
if region == "" {
|
||||
region = "us-central1"
|
||||
}
|
||||
|
||||
|
||||
// Get credentials from config (base64 encoded service account JSON)
|
||||
credentials := gcpConf.Get("credentials")
|
||||
if credentials != "" {
|
||||
|
|
@ -45,7 +45,7 @@ func createGCPProvider(ctx *pulumi.Context, name string) (*gcp.Provider, error)
|
|||
}
|
||||
credentials = string(decodedCreds)
|
||||
}
|
||||
|
||||
|
||||
// Create a GCP provider with explicit credentials if provided
|
||||
if credentials != "" {
|
||||
return gcp.NewProvider(ctx, name, &gcp.ProviderArgs{
|
||||
|
|
@ -54,7 +54,7 @@ func createGCPProvider(ctx *pulumi.Context, name string) (*gcp.Provider, error)
|
|||
Credentials: pulumi.String(credentials),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -16,10 +16,34 @@ The release workflow will automatically:
|
|||
|
||||
## After Release
|
||||
|
||||
- Docker images will be available at:
|
||||
The release workflow will automatically:
|
||||
- Build and publish Docker images:
|
||||
- `ghcr.io/modelcontextprotocol/registry:latest` - Latest stable release
|
||||
- `ghcr.io/modelcontextprotocol/registry:vX.Y.Z` - Specific release version
|
||||
- Binaries can be downloaded from the GitHub release page
|
||||
- Binaries will be available on the GitHub release page
|
||||
|
||||
**Important:** Creating a release does **not** automatically deploy to production. Production deployments are triggered by pushes to the `main` branch.
|
||||
|
||||
## Deploying the Release to Production
|
||||
|
||||
After creating a release, you need to ensure production pulls the new `:latest` Docker image. You have two options:
|
||||
|
||||
1. **Recommended:** Merge a commit to `main` (e.g., a changelog update or version bump). This will trigger the deployment workflow which will:
|
||||
- Build and push a new `:main` Docker image
|
||||
- Deploy to staging
|
||||
- Deploy to production (which uses `:latest` and will pull the new image)
|
||||
|
||||
2. **Manual restart (requires kubectl access):**
|
||||
```bash
|
||||
kubectl rollout restart deployment/mcp-registry -n default
|
||||
```
|
||||
|
||||
## Verifying the Deployment
|
||||
|
||||
After deployment completes, verify the changes are live:
|
||||
- Check the deployment workflow succeeded: https://github.com/modelcontextprotocol/registry/actions/workflows/deploy.yml
|
||||
- Test the API endpoint to confirm schema changes are applied
|
||||
- Check migrations ran successfully in the pod logs
|
||||
|
||||
## Docker Image Tags
|
||||
|
||||
|
|
|
|||
|
|
@ -387,7 +387,6 @@ func TestDefaultHTTPKeyFetcher(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
|
||||
func TestHTTPAuthHandler_Permissions(t *testing.T) {
|
||||
cfg := &config.Config{
|
||||
JWTPrivateKey: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
|
||||
|
|
@ -738,4 +737,3 @@ func TestHTTPvsDNS_PermissionDifferences(t *testing.T) {
|
|||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -97,8 +97,8 @@ func RegisterEditEndpoints(api huma.API, registry service.RegistryService, cfg *
|
|||
|
||||
// Prevent undeleting servers - once deleted, they stay deleted
|
||||
if currentServer.Meta.Official != nil &&
|
||||
currentServer.Meta.Official.Status == model.StatusDeleted &&
|
||||
newStatus != model.StatusDeleted {
|
||||
currentServer.Meta.Official.Status == model.StatusDeleted &&
|
||||
newStatus != model.StatusDeleted {
|
||||
return nil, huma.Error400BadRequest("Cannot change status of deleted server. Deleted servers cannot be undeleted.")
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -639,4 +639,4 @@ func TestEditServerEndpointEdgeCases(t *testing.T) {
|
|||
// Helper function
|
||||
func stringPtr(s string) *string {
|
||||
return &s
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -75,7 +75,7 @@ func buildPermissionErrorMessage(attemptedResource string, permissions []auth.Pe
|
|||
permissionStrs = append(permissionStrs, perm.ResourcePattern)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
errorMsg := "You do not have permission to publish this server"
|
||||
if len(permissionStrs) > 0 {
|
||||
errorMsg += ". You have permission to publish: " + strings.Join(permissionStrs, ", ")
|
||||
|
|
@ -83,6 +83,6 @@ func buildPermissionErrorMessage(attemptedResource string, permissions []auth.Pe
|
|||
errorMsg += ". You do not have any publish permissions"
|
||||
}
|
||||
errorMsg += ". Attempting to publish: " + attemptedResource
|
||||
|
||||
|
||||
return errorMsg
|
||||
}
|
||||
|
|
|
|||
|
|
@ -162,9 +162,9 @@ func TestPublishIntegration(t *testing.T) {
|
|||
|
||||
t.Run("publish fails with invalid token", func(t *testing.T) {
|
||||
publishReq := apiv0.ServerJSON{
|
||||
Name: "io.github.domdomegg/test-server",
|
||||
Description: "Test server",
|
||||
Version: "1.0.0",
|
||||
Name: "io.github.domdomegg/test-server",
|
||||
Description: "Test server",
|
||||
Version: "1.0.0",
|
||||
}
|
||||
|
||||
body, err := json.Marshal(publishReq)
|
||||
|
|
@ -185,7 +185,7 @@ func TestPublishIntegration(t *testing.T) {
|
|||
publishReq := apiv0.ServerJSON{
|
||||
Name: "io.github.other/test-server",
|
||||
Description: "A test server",
|
||||
Version: "1.0.0",
|
||||
Version: "1.0.0",
|
||||
Repository: model.Repository{
|
||||
URL: "https://github.com/example/test-server",
|
||||
Source: "github",
|
||||
|
|
@ -221,7 +221,7 @@ func TestPublishIntegration(t *testing.T) {
|
|||
publishReq := apiv0.ServerJSON{
|
||||
Name: "io.github.domdomegg/airtable-mcp-server",
|
||||
Description: "A test server with MCPB package",
|
||||
Version: "1.7.2",
|
||||
Version: "1.7.2",
|
||||
Packages: []model.Package{
|
||||
{
|
||||
RegistryType: model.RegistryTypeMCPB,
|
||||
|
|
|
|||
|
|
@ -47,7 +47,7 @@ func TestPublishRegistryValidation(t *testing.T) {
|
|||
publishReq := apiv0.ServerJSON{
|
||||
Name: "com.example/test-server-with-npm",
|
||||
Description: "A test server with invalid npm package reference",
|
||||
Version: "1.0.0",
|
||||
Version: "1.0.0",
|
||||
Packages: []model.Package{
|
||||
{
|
||||
RegistryType: model.RegistryTypeNPM,
|
||||
|
|
@ -88,7 +88,7 @@ func TestPublishRegistryValidation(t *testing.T) {
|
|||
publishReq := apiv0.ServerJSON{
|
||||
Name: "com.example/test-server-mcpb-validation",
|
||||
Description: "A test server with MCPB package and registry validation enabled",
|
||||
Version: "0.0.36",
|
||||
Version: "0.0.36",
|
||||
Packages: []model.Package{
|
||||
{
|
||||
RegistryType: model.RegistryTypeMCPB,
|
||||
|
|
@ -138,7 +138,7 @@ func TestPublishRegistryValidation(t *testing.T) {
|
|||
publishReq := apiv0.ServerJSON{
|
||||
Name: "com.example/test-server-multiple-packages",
|
||||
Description: "A test server with multiple packages where second fails",
|
||||
Version: "1.0.0",
|
||||
Version: "1.0.0",
|
||||
Packages: []model.Package{
|
||||
{
|
||||
RegistryType: model.RegistryTypeMCPB,
|
||||
|
|
@ -189,7 +189,7 @@ func TestPublishRegistryValidation(t *testing.T) {
|
|||
publishReq := apiv0.ServerJSON{
|
||||
Name: "com.example/test-server-first-package-fails",
|
||||
Description: "A test server where first package fails",
|
||||
Version: "1.0.0",
|
||||
Version: "1.0.0",
|
||||
Packages: []model.Package{
|
||||
{
|
||||
RegistryType: model.RegistryTypeNPM,
|
||||
|
|
|
|||
|
|
@ -113,9 +113,9 @@ func TestPublishEndpoint(t *testing.T) {
|
|||
{
|
||||
name: "invalid authorization header format",
|
||||
requestBody: apiv0.ServerJSON{
|
||||
Name: "io.github.domdomegg/test-server",
|
||||
Description: "Test server",
|
||||
Version: "1.0.0",
|
||||
Name: "io.github.domdomegg/test-server",
|
||||
Description: "Test server",
|
||||
Version: "1.0.0",
|
||||
},
|
||||
authHeader: "InvalidFormat",
|
||||
setupRegistryService: func(_ service.RegistryService) {
|
||||
|
|
@ -129,7 +129,7 @@ func TestPublishEndpoint(t *testing.T) {
|
|||
requestBody: apiv0.ServerJSON{
|
||||
Name: "test-server",
|
||||
Description: "A test server",
|
||||
Version: "1.0.0",
|
||||
Version: "1.0.0",
|
||||
},
|
||||
authHeader: "Bearer invalidToken",
|
||||
setupRegistryService: func(_ service.RegistryService) {
|
||||
|
|
@ -143,7 +143,7 @@ func TestPublishEndpoint(t *testing.T) {
|
|||
requestBody: apiv0.ServerJSON{
|
||||
Name: "io.github.other/test-server",
|
||||
Description: "A test server",
|
||||
Version: "1.0.0",
|
||||
Version: "1.0.0",
|
||||
Repository: model.Repository{
|
||||
URL: "https://github.com/example/test-server",
|
||||
Source: "github",
|
||||
|
|
@ -167,7 +167,7 @@ func TestPublishEndpoint(t *testing.T) {
|
|||
requestBody: apiv0.ServerJSON{
|
||||
Name: "example/test-server",
|
||||
Description: "A test server",
|
||||
Version: "1.0.0",
|
||||
Version: "1.0.0",
|
||||
Repository: model.Repository{
|
||||
URL: "https://github.com/example/test-server",
|
||||
Source: "github",
|
||||
|
|
@ -185,7 +185,7 @@ func TestPublishEndpoint(t *testing.T) {
|
|||
existingServer := apiv0.ServerJSON{
|
||||
Name: "example/test-server",
|
||||
Description: "Existing test server",
|
||||
Version: "1.0.0",
|
||||
Version: "1.0.0",
|
||||
Repository: model.Repository{
|
||||
URL: "https://github.com/example/test-server-existing",
|
||||
Source: "github",
|
||||
|
|
@ -202,7 +202,7 @@ func TestPublishEndpoint(t *testing.T) {
|
|||
requestBody: apiv0.ServerJSON{
|
||||
Name: "com.example/test-server-mcpb",
|
||||
Description: "A test server with MCPB package",
|
||||
Version: "1.0.0",
|
||||
Version: "1.0.0",
|
||||
Packages: []model.Package{
|
||||
{
|
||||
RegistryType: model.RegistryTypeMCPB,
|
||||
|
|
@ -499,7 +499,7 @@ func TestPublishEndpoint_MultipleSlashesEdgeCases(t *testing.T) {
|
|||
mux.ServeHTTP(rr, req)
|
||||
|
||||
// Assertions
|
||||
assert.Equal(t, tc.expectedStatus, rr.Code,
|
||||
assert.Equal(t, tc.expectedStatus, rr.Code,
|
||||
"%s: expected status %d, got %d", tc.description, tc.expectedStatus, rr.Code)
|
||||
|
||||
if tc.expectedStatus == http.StatusBadRequest {
|
||||
|
|
@ -508,4 +508,4 @@ func TestPublishEndpoint_MultipleSlashesEdgeCases(t *testing.T) {
|
|||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -41,6 +41,7 @@ type ServerVersionsInput struct {
|
|||
}
|
||||
|
||||
// RegisterServersEndpoints registers all server-related endpoints
|
||||
//
|
||||
//nolint:cyclop // Multiple endpoint registrations are inherently complex
|
||||
func RegisterServersEndpoints(api huma.API, registry service.RegistryService) {
|
||||
// List servers endpoint
|
||||
|
|
|
|||
|
|
@ -509,4 +509,4 @@ func TestServersEndpointEdgeCases(t *testing.T) {
|
|||
assert.Contains(t, []model.Status{model.StatusActive, model.StatusDeprecated, model.StatusDeleted}, server.Meta.Official.Status)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,12 +23,12 @@ func TrailingSlashMiddleware(next http.Handler) http.Handler {
|
|||
// Create a copy of the URL and remove the trailing slash
|
||||
newURL := *r.URL
|
||||
newURL.Path = strings.TrimSuffix(r.URL.Path, "/")
|
||||
|
||||
|
||||
// Use 308 Permanent Redirect to preserve the request method
|
||||
http.Redirect(w, r, newURL.String(), http.StatusPermanentRedirect)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,4 +6,4 @@ var BlockedNamespaces = []string{
|
|||
// Add blocked namespaces here, e.g.:
|
||||
// "io.github.spammer",
|
||||
// "com.evil-domain",
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -300,9 +300,9 @@ func TestJWTManager_BlockedNamespaces(t *testing.T) {
|
|||
originalBlocked := auth.BlockedNamespaces
|
||||
auth.BlockedNamespaces = []string{"io.github.spammer"}
|
||||
defer func() { auth.BlockedNamespaces = originalBlocked }()
|
||||
|
||||
|
||||
jwtManager := auth.NewJWTManager(cfg)
|
||||
|
||||
|
||||
claims := auth.JWTClaims{
|
||||
AuthMethod: auth.MethodGitHubAT,
|
||||
AuthMethodSubject: "spammer",
|
||||
|
|
@ -325,9 +325,9 @@ func TestJWTManager_BlockedNamespaces(t *testing.T) {
|
|||
originalBlocked := auth.BlockedNamespaces
|
||||
auth.BlockedNamespaces = []string{"io.github.spammer"}
|
||||
defer func() { auth.BlockedNamespaces = originalBlocked }()
|
||||
|
||||
|
||||
jwtManager := auth.NewJWTManager(cfg)
|
||||
|
||||
|
||||
claims := auth.JWTClaims{
|
||||
AuthMethod: auth.MethodGitHubAT,
|
||||
AuthMethodSubject: "gooduser",
|
||||
|
|
@ -349,9 +349,9 @@ func TestJWTManager_BlockedNamespaces(t *testing.T) {
|
|||
originalBlocked := auth.BlockedNamespaces
|
||||
auth.BlockedNamespaces = []string{"io.github.badorg"}
|
||||
defer func() { auth.BlockedNamespaces = originalBlocked }()
|
||||
|
||||
|
||||
jwtManager := auth.NewJWTManager(cfg)
|
||||
|
||||
|
||||
claims := auth.JWTClaims{
|
||||
AuthMethod: auth.MethodGitHubAT,
|
||||
AuthMethodSubject: "user",
|
||||
|
|
@ -378,9 +378,9 @@ func TestJWTManager_BlockedNamespaces(t *testing.T) {
|
|||
originalBlocked := auth.BlockedNamespaces
|
||||
auth.BlockedNamespaces = []string{"io.github.spammer"}
|
||||
defer func() { auth.BlockedNamespaces = originalBlocked }()
|
||||
|
||||
|
||||
jwtManager := auth.NewJWTManager(cfg)
|
||||
|
||||
|
||||
claims := auth.JWTClaims{
|
||||
AuthMethod: auth.MethodNone,
|
||||
AuthMethodSubject: "admin",
|
||||
|
|
|
|||
|
|
@ -16,4 +16,4 @@ const (
|
|||
MethodHTTP Method = "http"
|
||||
// No authentication - should only be used for local development and testing
|
||||
MethodNone Method = "none"
|
||||
)
|
||||
)
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue