feat: release action
This commit is contained in:
11
Dockerfile
Normal file
11
Dockerfile
Normal file
@@ -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"]
|
||||||
54
action.yml
Normal file
54
action.yml
Normal file
@@ -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 }}
|
||||||
167
main.go
Normal file
167
main.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user