Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions cmd/sst/mosaic/ui/ui.go
Original file line number Diff line number Diff line change
Expand Up @@ -505,6 +505,15 @@ func (u *UI) Event(unknown interface{}) {
}
}
u.blank()
case *project.HookStartEvent:
u.printEvent(TEXT_INFO, "Hook", fmt.Sprintf("Running %s hook...", evt.Hook))

case *project.HookCompleteEvent:
u.printEvent(TEXT_SUCCESS, "Hook", fmt.Sprintf("%s completed", evt.Hook))

case *project.HookErrorEvent:
u.printEvent(TEXT_DANGER, "Hook", fmt.Sprintf("%s failed: %s", evt.Hook, evt.Error))

case *cloudflare.WorkerBuildEvent:
if len(evt.Errors) > 0 {
u.printEvent(TEXT_DANGER, "Build Error", u.functionName(evt.WorkerID)+" "+strings.Join(evt.Errors, "\n"))
Expand Down
1 change: 1 addition & 0 deletions pkg/flag/flag.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ var SST_EXPERIMENTAL = isTrue("SST_EXPERIMENTAL") || isTrue("SST_EXPERIMENTAL_RU
var SST_RUN_ID = os.Getenv("SST_RUN_ID")
var SST_SKIP_APPSYNC = isTrue("SST_SKIP_APPSYNC")
var SST_NO_BUN = isTrue("NO_BUN") || isTrue("SST_NO_BUN")
var SST_HOOK_TIMEOUT = os.Getenv("SST_HOOK_TIMEOUT")

func isTrue(name string) bool {
val, ok := os.LookupEnv(name)
Expand Down
152 changes: 152 additions & 0 deletions pkg/project/hooks.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
package project

import (
"context"
"encoding/json"
"fmt"
"log/slog"
"os"
"path/filepath"
"strconv"
"time"

"github.com/sst/sst/v3/pkg/bus"
"github.com/sst/sst/v3/pkg/flag"
"github.com/sst/sst/v3/pkg/js"
"github.com/sst/sst/v3/pkg/process"
)

const HookDeployAfter = "deploy.after"

type DeployHookResult struct {
Success bool `json:"success"`
Errors []DeployHookError `json:"errors"`
Outputs map[string]interface{} `json:"outputs"`
Resources int `json:"resources"`
App DeployHookApp `json:"app"`
}

type DeployHookError struct {
Message string `json:"message"`
URN string `json:"urn,omitempty"`
Help []string `json:"help,omitempty"`
}

type DeployHookApp struct {
Name string `json:"name"`
Stage string `json:"stage"`
}

func (p *Project) RunDeployHook(complete *CompleteEvent) error {
log := slog.Default().With("service", "project.hooks")

if complete == nil {
log.Warn("skipping deploy.after hook: complete event is nil")
return nil
}

log.Info("running deploy.after hook")

// Set up timeout context
timeout := 30 * time.Second
if flag.SST_HOOK_TIMEOUT != "" {
seconds, err := strconv.Atoi(flag.SST_HOOK_TIMEOUT)
if err != nil {
log.Warn("invalid SST_HOOK_TIMEOUT value, using default",
"value", flag.SST_HOOK_TIMEOUT,
"default", timeout,
"err", err)
} else if seconds <= 0 {
log.Warn("SST_HOOK_TIMEOUT must be positive, using default",
"value", seconds,
"default", timeout)
} else {
timeout = time.Duration(seconds) * time.Second
}
}
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()

bus.Publish(&HookStartEvent{Hook: HookDeployAfter})

// Build the hook result from CompleteEvent
hookErrors := make([]DeployHookError, len(complete.Errors))
for i, err := range complete.Errors {
hookErrors[i] = DeployHookError{
Message: err.Message,
URN: err.URN,
Help: err.Help,
}
}

result := DeployHookResult{
Success: len(complete.Errors) == 0,
Errors: hookErrors,
Outputs: complete.Outputs,
Resources: len(complete.Resources),
App: DeployHookApp{
Name: p.app.Name,
Stage: p.app.Stage,
},
}

resultBytes, err := json.Marshal(result)
if err != nil {
bus.Publish(&HookErrorEvent{Hook: HookDeployAfter, Error: err.Error()})
return fmt.Errorf("failed to marshal hook result: %w", err)
}

outfile := filepath.Join(p.PathPlatformDir(), fmt.Sprintf("hook.deploy.%v.mjs", time.Now().UnixMilli()))

buildResult, err := js.Build(js.EvalOptions{
Dir: p.PathRoot(),
Outfile: outfile,
Define: map[string]string{
"$hookResult": string(resultBytes),
},
Code: fmt.Sprintf(`
import mod from '%s';
const result = $hookResult;
if (mod.hooks?.deploy?.after) {
await mod.hooks.deploy.after(result);
}
`,
filepath.ToSlash(p.PathConfig()),
),
})
if err != nil {
bus.Publish(&HookErrorEvent{Hook: HookDeployAfter, Error: err.Error()})
return fmt.Errorf("failed to build hook: %w", err)
}
if !flag.SST_NO_CLEANUP {
defer js.Cleanup(buildResult)
}

log.Info("executing deploy.after hook", "outfile", outfile, "timeout", timeout)
node := process.CommandContext(ctx, "node", "--no-warnings", outfile)
node.Env = os.Environ()
output, err := node.CombinedOutput()
if err != nil {
var errMsg string
switch ctx.Err() {
case context.Canceled:
errMsg = fmt.Sprintf("hook was canceled\n%s", string(output))
case context.DeadlineExceeded:
errMsg = fmt.Sprintf("hook timed out after %v\n%s", timeout, string(output))
default:
errMsg = fmt.Sprintf("hook execution failed: %s\n%s", err.Error(), string(output))
}
bus.Publish(&HookErrorEvent{Hook: HookDeployAfter, Error: errMsg})
log.Error("deploy.after hook failed", "err", err, "output", string(output))
return fmt.Errorf("hook %s failed: %s", HookDeployAfter, errMsg)
}

// Log any output from the hook
if len(output) > 0 {
log.Info("deploy.after hook output", "output", string(output))
}

bus.Publish(&HookCompleteEvent{Hook: HookDeployAfter})
log.Info("deploy.after hook completed")
return nil
}
7 changes: 7 additions & 0 deletions pkg/project/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -573,6 +573,13 @@ loop:
provider.Cleanup(p.home, p.app.Name, p.app.Stage)
}

// Run deploy.after hook for deploy command only (not dev or diff)
if input.Command == "deploy" && !input.Dev && complete.Finished {
if err := p.RunDeployHook(complete); err != nil {
return err
}
}

log.Info("done running stack command", "resources", len(complete.Resources))
if cmd.ProcessState.ExitCode() > 0 {
return ErrStackRunFailed
Expand Down
13 changes: 13 additions & 0 deletions pkg/project/stack.go
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,19 @@ type StackCommandEvent struct {
Version string
}

type HookStartEvent struct {
Hook string
}

type HookCompleteEvent struct {
Hook string
}

type HookErrorEvent struct {
Hook string
Error string
}

type Error struct {
Message string `json:"message"`
URN string `json:"urn"`
Expand Down
133 changes: 133 additions & 0 deletions platform/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -833,6 +833,81 @@ export interface WorkflowInput {
event: BranchEvent | PullRequestEvent | TagEvent | UserEvent;
}

/**
* An error that occurred during deployment.
*/
export interface DeployError {
/**
* The error message.
*/
message: string;
/**
* The URN of the resource that caused the error, if applicable.
*/
urn?: string;
/**
* Additional help text for resolving the error.
*/
help?: string[];
}

/**
* The result passed to the `deploy.after` hook.
*/
export interface DeployResult {
/**
* Whether the deployment completed without errors.
*/
success: boolean;
/**
* Array of errors that occurred during deployment. Empty if `success` is true.
*/
errors: DeployError[];
/**
* The outputs from your `run` function.
*/
outputs: Record<string, any>;
/**
* Number of resources in the stack.
*/
resources: number;
/**
* Information about the app.
*/
app: {
/**
* The app name.
*/
name: string;
/**
* The stage name.
*/
stage: string;
};
}

/**
* Hooks for the deploy operation.
*/
export interface DeployHooks {
/**
* Runs after a deployment completes (success or failure).
*
* @param result - The deployment result
*/
after?(result: DeployResult): Promise<void> | void;
}

/**
* Hooks that run at specific points during operations.
*/
export interface Hooks {
/**
* Hooks for the deploy operation.
*/
deploy?: DeployHooks;
}

export interface Config {
/**
* The config for your app. It needs to return an object of type [`App`](#app-1). The `app`
Expand Down Expand Up @@ -1270,6 +1345,64 @@ export interface Config {
* ```
*/
run(): Promise<Record<string, any> | void>;
/**
* Configure hooks that run at specific points during operations.
*
* Currently supports a `deploy.after` hook that runs after a deployment completes.
* This is useful for sending notifications (like Slack), triggering follow-up actions,
* or logging deployment results.
*
* :::note
* The hook runs after `sst deploy` completes, not during `sst dev` or `sst diff`.
* :::
*
* :::caution
* The hook will not run if the deployment is interrupted (e.g., Ctrl+C).
* :::
*
* :::caution
* Hook errors will cause the deployment to fail. If you want to handle errors
* gracefully and prevent deployment failure, wrap your hook logic in a try-catch block.
* :::
*
* @example
*
* ```ts title="sst.config.ts"
* hooks: {
* deploy: {
* async after(result) {
* // Send a notification to your webhook
* await fetch("https://example.com/webhook", {
* method: "POST",
* body: JSON.stringify({
* text: `Deployed ${result.app.name} to ${result.app.stage}`
* })
* });
* }
* }
* }
* ```
*
* The `after` hook receives a `DeployResult` object containing:
* - `success` — Whether the deployment completed without errors
* - `errors` — Array of errors if any occurred
* - `outputs` — The outputs from your `run` function
* - `resources` — Number of resources in the stack
* - `app.name` — The app name
* - `app.stage` — The stage name
*
* #### Timeout
*
* By default, the hook has a 30-second timeout. You can customize this by setting
* the `SST_HOOK_TIMEOUT` environment variable to the number of seconds.
*
* ```bash
* SST_HOOK_TIMEOUT=60 sst deploy
* ```
*
* This sets the timeout to 60 seconds.
*/
hooks?: Hooks;
}

/** @internal */
Expand Down