From 4d577122bdba47a9899851cb39a8dc6f12be8a4e Mon Sep 17 00:00:00 2001 From: etwodev Date: Sun, 5 Oct 2025 01:21:16 +0100 Subject: [PATCH] feat: release action --- Dockerfile | 11 ++++ action.yml | 54 +++++++++++++++++ main.go | 167 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 232 insertions(+) create mode 100644 Dockerfile create mode 100644 action.yml create mode 100644 main.go diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..ef939e7 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,11 @@ +# Stage 1: Build Go binary +FROM golang:1.23-alpine AS builder +WORKDIR /app +COPY . . +RUN go mod tidy && go build -o /portainer-deploy main.go + +# Stage 2: Runtime container +FROM alpine:3.20 +RUN apk add --no-cache ca-certificates +COPY --from=builder /portainer-deploy /usr/local/bin/portainer-deploy +ENTRYPOINT ["portainer-deploy"] diff --git a/action.yml b/action.yml new file mode 100644 index 0000000..43799f5 --- /dev/null +++ b/action.yml @@ -0,0 +1,54 @@ +name: "Portainer Deploy Action" +description: "Deploy or redeploy Portainer stacks directly from GitHub Actions." +author: "Zoe" +branding: + icon: "upload-cloud" + color: "blue" + +inputs: + portainer-url: + description: "Base API URL for your Portainer instance (e.g. https://portainer.example.com/api)" + required: true + portainer-token: + description: "Portainer API token" + required: true + portainer-endpoint: + description: "Portainer endpoint ID" + required: true + stack-name: + description: "Name of the stack to create or redeploy" + required: true + repo-url: + description: "Git repository URL containing the stack definition" + required: true + repo-ref: + description: "Git reference (e.g. refs/heads/main)" + required: true + repo-compose-file: + description: "Path to the docker-compose file within the repo" + required: true + repo-username: + description: "Git username (if repository requires authentication)" + required: false + repo-password: + description: "Git password/token (if repository requires authentication)" + required: false + tls-skip-verify: + description: "Set to 'true' to skip TLS verification" + required: false + default: "false" + +runs: + using: "docker" + image: "Dockerfile" + env: + PORTAINER_URL: ${{ inputs.portainer-url }} + PORTAINER_TOKEN: ${{ inputs.portainer-token }} + PORTAINER_ENDPOINT: ${{ inputs.portainer-endpoint }} + STACK_NAME: ${{ inputs.stack-name }} + REPO_URL: ${{ inputs.repo-url }} + REPO_REF: ${{ inputs.repo-ref }} + REPO_COMPOSE_FILE: ${{ inputs.repo-compose-file }} + REPO_USERNAME: ${{ inputs.repo-username }} + REPO_PASSWORD: ${{ inputs.repo-password }} + TLS_SKIP_VERIFY: ${{ inputs.tls-skip-verify }} diff --git a/main.go b/main.go new file mode 100644 index 0000000..6472394 --- /dev/null +++ b/main.go @@ -0,0 +1,167 @@ +package main + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "os" +) + +// ------------------------------- +// Data structures +// ------------------------------- + +type Stack struct { + ID int `json:"Id"` + Name string `json:"Name"` + EndpointId int `json:"EndpointId"` + GitConfig GitConfig `json:"gitConfig"` +} + +type GitConfig struct { + URL string `json:"url"` + ReferenceName string `json:"referenceName"` + ConfigFilePath string `json:"configFilePath"` +} + +type EnvVar struct { + Name string `json:"name"` + Value string `json:"value"` +} + +// ------------------------------- +// Environment-driven configuration +// ------------------------------- + +var ( + portainerURL = os.Getenv("PORTAINER_URL") // e.g. https://portainer.example.com/api + portainerToken = os.Getenv("PORTAINER_TOKEN") // API access token + endpointID = os.Getenv("PORTAINER_ENDPOINT") // Portainer endpoint ID + stackName = os.Getenv("STACK_NAME") // Name of stack to create/redeploy + repoURL = os.Getenv("REPO_URL") // Git repo URL + repoRef = os.Getenv("REPO_REF") // e.g. refs/heads/main + repoComposeFile = os.Getenv("REPO_COMPOSE_FILE") // e.g. docker-compose.yml + repoUsername = os.Getenv("REPO_USERNAME") // Git username (if needed) + repoPassword = os.Getenv("REPO_PASSWORD") // Git password/token (if needed) + tlsSkipVerify = os.Getenv("TLS_SKIP_VERIFY") // "true" / "false" +) + +// ------------------------------- +// Helpers +// ------------------------------- + +func request(client *http.Client, method, url string, body interface{}) (*http.Response, error) { + var buf io.Reader + if body != nil { + data, _ := json.Marshal(body) + buf = bytes.NewBuffer(data) + } + req, err := http.NewRequest(method, url, buf) + if err != nil { + return nil, err + } + req.Header.Set("Authorization", "Bearer "+portainerToken) + req.Header.Set("Content-Type", "application/json") + return client.Do(req) +} + +// ------------------------------- +// Main logic +// ------------------------------- + +func main() { + if portainerURL == "" || portainerToken == "" || endpointID == "" || repoURL == "" || repoRef == "" || repoComposeFile == "" || stackName == "" { + fmt.Println("Missing required environment variables.") + os.Exit(1) + } + + client := &http.Client{} + + // 1. Fetch existing stacks + resp, err := request(client, "GET", fmt.Sprintf("%s/stacks", portainerURL), nil) + if err != nil { + panic(err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + fmt.Printf("Error fetching stacks: %s\n", string(body)) + os.Exit(1) + } + + var stacks []Stack + if err := json.NewDecoder(resp.Body).Decode(&stacks); err != nil { + panic(err) + } + + // 2. Find existing stack from same repo + name + var existing *Stack + for _, s := range stacks { + if s.GitConfig.URL == repoURL && s.Name == stackName { + existing = &s + break + } + } + + if existing == nil { + // 3. Create new stack + payload := map[string]interface{}{ + "composeFile": repoComposeFile, + "env": []EnvVar{}, + "fromAppTemplate": false, + "name": stackName, + "repositoryAuthentication": repoUsername != "" && repoPassword != "", + "repositoryUsername": repoUsername, + "repositoryPassword": repoPassword, + "repositoryReferenceName": repoRef, + "repositoryURL": repoURL, + "tlsskipVerify": tlsSkipVerify == "true", + } + + url := fmt.Sprintf("%s/stacks/create/standalone/repository?endpointId=%s", portainerURL, endpointID) + resp, err = request(client, "POST", url, payload) + if err != nil { + panic(err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated { + body, _ := io.ReadAll(resp.Body) + fmt.Printf("Error creating stack: %s\n", string(body)) + os.Exit(1) + } + + fmt.Printf("✅ Stack %s deployed successfully.\n", stackName) + } else { + // 4. Redeploy stack + payload := map[string]interface{}{ + "env": []EnvVar{}, + "prune": true, + "pullImage": true, + "repositoryAuthentication": repoUsername != "" && repoPassword != "", + "repositoryAuthorizationType": 0, + "repositoryUsername": repoUsername, + "repositoryPassword": repoPassword, + "repositoryReferenceName": repoRef, + "stackName": stackName, + } + + url := fmt.Sprintf("%s/stacks/%d/git/redeploy?endpointId=%s", portainerURL, existing.ID, endpointID) + resp, err = request(client, "PUT", url, payload) + if err != nil { + panic(err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + fmt.Printf("Error redeploying stack: %s\n", string(body)) + os.Exit(1) + } + + fmt.Printf("♻️ Stack %s redeployed successfully.\n", stackName) + } +}