How to Implement Manual Approval Gates for Critical Workflow Operations with gh-aw

Add manual-approval: <environment-name> to your workflow's front-matter on: section, and the gh-aw compiler automatically configures GitHub environment protection rules to pause execution until a human approves the deployment.

The github/gh-aw compiler transforms markdown-based workflow definitions into executable GitHub Actions by injecting activation jobs that respect environment protection rules. Implementing manual approval gates for critical workflow operations ensures that sensitive deployments require explicit human authorization before proceeding, leveraging GitHub's native environment protection capabilities.

Declaring Manual Approval in Workflow Front-matter

To enable a manual approval gate, declare the manual-approval key inside the on: section of your workflow's YAML front-matter. The value must be a string matching a protected environment name configured in your repository settings.

---
description: Deploy to production only after human approval
on:
  workflow_dispatch:
  manual-approval: production   # <-- protected environment name

permissions:
  contents: read
engine: copilot
---

# Your workflow steps follow here

The manual-approval key can coexist with any trigger type, such as workflow_dispatch, issues, or schedule. When the compiler processes this definition, it extracts the environment name and propagates it through the compilation pipeline.

How the Compiler Processes Approval Gates

The implementation spans four core components that extract, store, annotate, and enforce the manual approval configuration.

Extracting the Approval Configuration

File: pkg/workflow/manual_approval.go

The extractManualApprovalFromOn function parses the front-matter map and retrieves the manual-approval value when the on section is structured as a map.

func (c *Compiler) extractManualApprovalFromOn(frontmatter map[string]any) (string, error) {
    onSection, exists := frontmatter["on"]
    if !exists {
        return "", nil
    }

    switch on := onSection.(type) {
    case map[string]any:
        if manualApproval, exists := on["manual-approval"]; exists {
            if str, ok := manualApproval.(string); ok {
                return str, nil
            }
            return "", fmt.Errorf("manual-approval value must be a string, got %T", manualApproval)
        }
    }
    return "", nil
}

This function returns the environment name as a string or an empty string if the configuration is absent. It returns an error only when the value is present but not a string, ensuring type safety.

Storing Approval Data in Workflow Structures

File: pkg/workflow/manual_approval.go and pkg/workflow/compiler_types.go

The processManualApprovalConfiguration method invokes the extractor and assigns the result to the WorkflowData struct:

manualApproval, err := c.extractManualApprovalFromOn(frontmatter)
if err != nil {
    return err
}
workflowData.ManualApproval = manualApproval

The WorkflowData struct definition in compiler_types.go includes:

type WorkflowData struct {
    // … other fields …
    ManualApproval string // environment name for manual‑approval from on: section
}

Generating Lock File Annotations

File: pkg/workflow/compiler_yaml.go

When generating the compiled lock file, the compiler injects a comment to document the approval requirement:

if data.ManualApproval != "" {
    cleanManualApproval := stringutil.StripANSIEscapeCodes(data.ManualApproval)
    fmt.Fprintf(yaml, "# Manual approval required: environment '%s'\n", cleanManualApproval)

}

This annotation ensures that developers reviewing the compiled workflow understand that an approval gate is active.

Configuring the Activation Job Environment

File: pkg/workflow/compiler_activation_jobs.go

The compiler constructs the activation job that triggers the main workflow. If ManualApproval is set, it adds the environment key:

var environment string
if data.ManualApproval != "" {
    // Strip ANSI escape codes from manual‑approval environment name
    cleanManualApproval := stringutil.StripANSIEscapeCodes(data.ManualApproval)
    environment = fmt.Sprintf("environment: %s", cleanManualApproval)
}
// …
job := &Job{
    Name:        string(constants.ActivationJobName),
    Environment: environment,
    // …
}

When GitHub Actions executes this job, it evaluates the environment: field. If the environment is protected with a "Required reviewers" or "Require approval before job can proceed" rule, the workflow pauses until an authorized user approves it via the GitHub UI or API.

End-to-End Workflow Execution Flow

The complete lifecycle of a manual approval gate involves five distinct stages:

  1. Declaration: The workflow author adds manual-approval: <environment> to the front-matter on: section.
  2. Compilation: gh aw compile invokes extractManualApprovalFromOn to parse the value, stores it in WorkflowData.ManualApproval, and generates the lock file with an annotation comment.
  3. Job Generation: The compiler creates an activation job in compiler_activation_jobs.go that includes environment: <value>.
  4. Execution Pause: When the workflow triggers, GitHub detects the environment protection rules and halts the activation job, displaying a "Review pending" button in the Actions UI.
  5. Continuation: After a user with appropriate permissions approves the deployment, the activation job completes and triggers the main agent job to proceed with the critical operation.

Practical Implementation Examples

Basic Workflow with Production Approval

Create a file at .github/workflows/deploy-prod.md with the following content:

---
description: Production deployment with mandatory human approval
on:
  workflow_dispatch:
  manual-approval: production
permissions:
  contents: read
  deployments: write
engine: copilot
---

## Deployment Steps

1. Build container image
2. Run security scans
3. Deploy to production cluster

After running gh aw compile, the generated lock file will contain:


# Manual approval required: environment 'production'

jobs:
  activation:
    environment: production
    # ... additional auto-generated configuration ...

Programmatic Configuration Access

For custom tooling that needs to validate or extract manual approval settings:

package main

import (
    "log"
    "github.com/github/gh-aw/pkg/workflow"
)

func main() {
    compiler := workflow.NewCompiler()
    
    // Parse frontmatter from markdown content
    frontmatter, err := compiler.ParseFrontmatter(mdContent)
    if err != nil {
        log.Fatalf("Failed to parse frontmatter: %v", err)
    }
    
    // Extract manual approval configuration
    approvalEnv, err := compiler.ExtractManualApprovalFromOn(frontmatter)
    if err != nil {
        log.Fatalf("Invalid manual-approval configuration: %v", err)
    }
    
    if approvalEnv != "" {
        log.Printf("Workflow requires manual approval for environment: %s", approvalEnv)
    } else {
        log.Println("No manual approval gate configured")
    }
}

Validating with Unit Tests

The repository includes comprehensive tests in pkg/workflow/manual_approval_test.go that verify extraction logic:

func TestExtractManualApproval(t *testing.T) {
    tests := []struct {
        name        string
        frontmatter map[string]any
        want        string
        wantErr     bool
    }{
        {
            name: "valid manual approval in on section",
            frontmatter: map[string]any{
                "on": map[string]any{
                    "workflow_dispatch": nil,
                    "manual-approval":   "production",
                },
            },
            want:    "production",
            wantErr: false,
        },
        {
            name: "missing manual approval",
            frontmatter: map[string]any{
                "on": map[string]any{
                    "workflow_dispatch": nil,
                },
            },
            want:    "",
            wantErr: false,
        },
    }
    
    // Test execution logic here...
}

Run the test suite with:

make test-unit

# or specifically:

go test ./pkg/workflow/... -run TestManualApproval

Key Source Files Reference

Understanding the manual approval implementation requires familiarity with these specific files in the github/gh-aw repository:

Summary

  • Declare manual approval gates by adding manual-approval: <environment-name> to the on: section of your workflow front-matter.
  • Compile workflows using gh aw compile to generate activation jobs that include the environment: field required by GitHub's protection rules.
  • Extract configuration programmatically via extractManualApprovalFromOn in pkg/workflow/manual_approval.go for custom validation tooling.
  • Validate implementations using the comprehensive test suite in pkg/workflow/manual_approval_test.go and integration tests.
  • Protect critical operations by configuring the target environment in GitHub repository settings with required reviewers or deployment protection rules.

Frequently Asked Questions

How do I configure the required reviewers for a manual approval gate?

Configure the protection rules directly in your GitHub repository settings under Settings > Environments. Select the environment name specified in your manual-approval value (e.g., production), then enable "Required reviewers" and specify which users or teams must approve the deployment. The gh-aw compiler only injects the environment: field into the activation job; GitHub's platform handles the actual approval UI and notifications.

What happens if I specify a manual-approval environment that does not exist?

If the environment name specified in manual-approval does not exist in the repository settings, GitHub Actions will attempt to create it dynamically when the workflow runs. However, dynamically created environments do not inherit protection rules, so the workflow will execute without pausing for approval. Always pre-configure the environment in repository settings with protection rules to ensure the gate functions correctly.

Can I use manual approval gates with triggers other than workflow_dispatch?

Yes, the manual-approval key functions alongside any valid GitHub Actions trigger. You can combine it with issues, pull_request, schedule, or custom repository events. The compiler extracts the value from the on: section regardless of the trigger type, and the activation job will always include the environment: field, causing GitHub to evaluate protection rules before proceeding.

How do I troubleshoot when the manual approval gate is not triggering?

First, verify that the compiled lock file contains the environment declaration by checking for the comment # Manual approval required: environment '...' in the generated YAML. If absent, ensure your front-matter syntax is correct and that gh aw compile completed without errors. If the comment exists but the gate still doesn't trigger, confirm that the environment name in your repository settings exactly matches the value in manual-approval (case-sensitive) and that protection rules are enabled for that environment. Check the workflow run logs to see if the activation job is being created with the correct environment: field.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →