Skip to content
Merged
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
25 changes: 24 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,12 +60,17 @@ metaldeploy --host 1.2.3.4 --user root --ssh-key ~/.ssh/id_rsa --type docker
```yaml
- name: Deploy with MetalDeploy
uses: OpsGuild/MetalDeploy@v1
env:
# Set/Override specific variables
ENV_APP_PORT: 9090
with:
git_auth_method: token
git_token: ${{ secrets.GITHUB_TOKEN }}
remote_host: ${{ secrets.REMOTE_HOST }}
ssh_key: ${{ secrets.SSH_PRIVATE_KEY }}
environment: prod
# Pass all secrets in one go (Zero-Config)
env_blob: ${{ toJSON(secrets) }}
```

### Advanced Example with Docker
Expand Down Expand Up @@ -95,7 +100,25 @@ Copy specific files or directories (like `node_modules` or `dist/`) to the serve
ssh_key: ${{ secrets.SSH_PRIVATE_KEY }}
# Copy local 'dist' folder to remote '/app/dist'
# Copy local 'package.json' to remote '/app/package.json'
copy_artifacts: "dist/:/app/dist, package.json:/app/package.json"
copy_artifacts: "dist/:dist, package.json:package.json"
```

### Secure Secret Management

MetalDeploy is designed for Zero-Config secret management. Use the **`env_blob`** input to pass all repository secrets without mapping them manually.

- **Bulk Injection**: Use `env_blob: ${{ toJSON(secrets) }}` to securely tunnel all secrets starting with `ENV_` to your server.
- **Manual Overrides**: Use the standard GitHub Action **`env:`** block to override secrets for specific steps.

```yaml
- name: Deploy with Overrides
uses: OpsGuild/MetalDeploy@v1
env:
ENV_APP_PORT: 3000 # This wins over repository secrets
with:
env_blob: ${{ toJSON(secrets) }}
env_files_generate: true
...
```

### Multi-Server Deployment
Expand Down
5 changes: 5 additions & 0 deletions action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,9 @@ inputs:
copy_artifacts:
description: 'Comma-separated list of build artifacts to copy to the server (local_path:remote_path). Paths are relative to git_dir unless absolute.'
required: false
env_blob:
description: 'A block of environment variables in KEY=VALUE format (e.g., from a secret) to be included in .env files without prefixes.'
required: false
outputs:
deployment_status:
description: 'Deployment status (success/failed)'
Expand Down Expand Up @@ -201,3 +204,5 @@ runs:
ENV_FILES_CREATE_ROOT: ${{ inputs.env_files_create_root }}
ENV_FILES_FORMAT: ${{ inputs.env_files_format }}
COPY_ARTIFACTS: ${{ inputs.copy_artifacts }}
ENV_BLOB: ${{ inputs.env_blob }}
GITHUB_WORKSPACE: ${{ github.workspace }}
2 changes: 1 addition & 1 deletion changelogs/2026-01-29_16-48-08.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,4 +18,4 @@
### Removed
- Default value for `env_files_patterns` has been removed, making it an optional input

Note: The changes to the changelog file itself are not explicitly mentioned as they are not directly related to user-facing changes, but rather to the maintenance of the changelog.
Note: The changes to the changelog file itself are not explicitly mentioned as they are not directly related to user-facing changes, but rather to the maintenance of the changelog.
20 changes: 20 additions & 0 deletions changelogs/2026-01-29_17-26-50.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# Changelog

## [Unreleased]

### Added
- **Bulk Secret Injection**: Ability to pass all repository secrets in one go using `env_blob: ${{ toJSON(secrets) }}`.
- **Manual Overrides**: Support for overriding secrets for specific steps using the standard GitHub Action `env:` block.
- **Strict Filtering**: Only processes keys starting with `ENV_` or the literal `ENV` to ensure security.
- **Relative Path Resolution**: Resolves relative paths against the workspace if not absolute for `COPY_ARTIFACTS`.

### Changed
- **Environment File Generation**: Updated logic for generating environment files, including support for bulk secret injection and manual overrides.
- **Priority System**: Updated priority system to ensure that specific overrides always take precedence.
- **Copy Artifacts**: Updated `COPY_ARTIFACTS` to handle relative paths and absolute paths correctly.

### Fixed
- No explicit bug fixes mentioned in the diff, but several changes improve the handling of environment variables and secrets, potentially fixing related issues.

### Removed
- No features or functionality explicitly removed in the diff, but some configuration options and internal logic have been updated or simplified.
107 changes: 30 additions & 77 deletions docs/env-generation.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,104 +9,57 @@ MetalDeploy includes powerful environment file generation capabilities that auto
- ✅ **Priority System** - Environment-specific secrets override base secrets automatically
- ✅ **All-in-One Secret Support** - Store multiple variables in single secrets
- ✅ **Global Literal Blob** - Support for a literal `ENV` secret for non-prefixed global dumps
- ✅ **Strict Filtering** - Only processes keys starting with `ENV_` or the literal `ENV` to ensure security
- ✅ **Secure Handling** - Files created with `0o604` permissions, no secret logging
- ✅ **Secrets & Variables** - Supports both GitHub Secrets (encrypted) and GitHub Variables (plaintext)

## Configuration

| Input | Description | Default |
|-------|-------------|---------|
| `env_files_generate` | Enable environment file generation | `false` |
| `env_files_structure` | File structure: `single`, `flat`, `nested`, `auto`, `custom` | `auto` |
| `env_files_path` | Custom path (when `structure=custom`) | - |
| `env_files_patterns` | Comma-separated patterns (`.env.app,.env.database`) | - |
| `env_files_create_root` | Also create a combined `.env` file in project root | `false` |
| `env_files_format` | Format for parsing: `auto`, `env`, `json`, `yaml` | `auto` |

## How it Works

1. **Discovery**: The action scans environment variables starting with `ENV_` or a literal `ENV` secret.
1. **Discovery**: The action scans environment variables starting with `ENV_` or a literal `ENV`.
2. **Bucketing**:
- **Literal `ENV`**: Treated as a non-prefixed global blob. Its contents go directly into the `.env` file without any modifications.
- **Literal `ENV`**: Treated as a non-prefixed global blob. Its contents go directly into the `.env` file without prefixes.
- **`ENV_COMPONENT_...`**: Treated as part of a specific component (e.g., `ENV_APP_...` goes to `.env.app`).
- **`ENV_ENVIRONMENT_...`**: Automatically identifies prefixes matching common environments (`PROD`, `STAGING`, `DEV`, `TEST`) **plus** your current `environment` input.
3. **Generation**: Files are generated on the remote server with secure permissions.
- **`ENV_ENVIRONMENT_...`**: Identifies prefixes matching common environments (`PROD`, `STAGING`, `DEV`, `TEST`) **plus** your current `environment` input.
3. **Strict Filtering**: If using `env_blob: ${{ toJSON(secrets) }}`, MetalDeploy **only** processes keys inside that blob that start with `ENV_` or are the literal `ENV`. Other secrets (like `STRIPE_KEY` or `GITHUB_TOKEN`) are ignored for security unless specifically prefixed.

## Secret Naming Convention
## Priority System (Last one wins)

### Literal Global Blob (No Prefix)
If you want to dump a list of variables without any prefixing or component logic, use the literal secret name `ENV`.
The priority system ensures that specific overrides always take precedence:

```bash
# GitHub Secret: ENV
PORT=8080
DEBUG=true
```
**Result**: `.env` contains `PORT=8080` and `DEBUG=true`.
1. **`env_blob` Contents** (Lowest)
Everything inside your `env_blob` (like `toJSON(secrets)`) is loaded first.
2. **Explicit Workflow `env:` Variables** (Medium)
Variables you map directly in your workflow's `env:` block win over the blob.
3. **Environment Overrides** (Highest)
Secrets matching your `environment` input (e.g. `ENV_PROD_...`) win last.

### Individual Variables
## Workflow Structure Example

```bash
# Base (environment-agnostic)
ENV_APP_DEBUG=false
ENV_APP_SECRET_KEY=base-key
ENV_DATABASE_HOST=localhost

# Environment-specific (higher priority)
# If environment input is 'prod', this will override base values
ENV_PROD_APP_SECRET_KEY=prod-secret
ENV_PROD_DATABASE_HOST=prod-host
```

### All-in-One Variables

```bash
# Environment-specific all-in-one (highest priority)
ENV_PROD_APP="
DEBUG=false
SECRET_KEY=prod-secret
DATABASE_URL=postgresql://prod-host:5432/db
"

# Base all-in-one (fallback)
ENV_APP="
DEBUG=true
SECRET_KEY=dev-secret
"
```

## Priority System

The priority system ensures proper variable overriding (last one wins):

1. **Literal `ENV`** (Lowest priority - Base Layer)
2. **Base Component secrets** (e.g., `ENV_APP_...`)
3. **Base All-in-one secrets** (e.g., `ENV_APP="..."`)
4. **Env-specific Component secrets** (e.g., `ENV_PROD_APP_...`)
5. **Env-specific All-in-one secrets** (Highest priority - `ENV_PROD_APP="..."`)

## Usage Examples

### Example 1: Custom Environment Names
MetalDeploy automatically supports your custom environment names for prefixes.
Here is exactly how the `env:` block looks alongside the `with:` block in a GitHub Action:

```yaml
- uses: ./
- name: Deploy to Staging
uses: ./
env:
# MANUAL OVERRIDES GO HERE
ENV_APP_PORT: 9090 # This wins over any other PORT setting
with:
env_files_generate: 'true'
environment: 'qa' # Custom environment name
env_blob: ${{ toJSON(secrets) }}
environment: 'staging'
remote_host: ${{ secrets.REMOTE_HOST }}
# ... other inputs ...
```
**Secrets**:
- `ENV_APP_PORT=8080` (Base)
- `ENV_QA_APP_PORT=9090` (Overrides base because environment is 'qa')

**Result**: `.env.app` will have `PORT=9090`.
## Usage Example: Bulk Secret Injection

For the simplest setup, pass all secrets in one go:

### Example 2: Single Mode
```yaml
- uses: ./
with:
env_files_generate: 'true'
env_files_structure: 'single'
env_blob: ${{ toJSON(secrets) }}
```
**Result**: Creates `/project/.env` with all variables merged. Component variables are prefixed (e.g., `APP_PORT`, `DB_HOST`) to prevent collisions, while literal `ENV` variables remain un-prefixed.

**Security Check**: Only secrets you have named with the `ENV_` prefix in your repository settings will be processed. All other private secrets remain untouched.
7 changes: 6 additions & 1 deletion src/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,11 +70,16 @@ def get_env(name, default=None):
# Build Artifacts
artifacts = get_env("COPY_ARTIFACTS")
self.COPY_ARTIFACTS = []
workspace = get_env("GITHUB_WORKSPACE", ".")
if artifacts:
for item in artifacts.split(","):
if ":" in item:
local, remote = item.split(":", 1)
self.COPY_ARTIFACTS.append((local.strip(), remote.strip()))
local_path = local.strip()
# Resolve relative path against workspace if not absolute
if not os.path.isabs(local_path):
local_path = os.path.abspath(os.path.join(workspace, local_path))
self.COPY_ARTIFACTS.append((local_path, remote.strip()))

# Global state for temporary files
self.SSH_KEY_PATH = None
Expand Down
75 changes: 57 additions & 18 deletions src/env_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -126,9 +126,12 @@ def detect_file_patterns(
if var_name.startswith(prefix):
suffix = var_name[len(prefix) :]
break
else:
elif var_name.startswith("ENV_"):
# Strip ENV_
suffix = var_name[4:]
else:
# This is a direct variable (no prefix) from a blob, it goes to the primary .env
continue

# Clean up leading underscores if any (e.g. _REDIS)
suffix = suffix.lstrip("_")
Expand Down Expand Up @@ -203,8 +206,8 @@ def merge_env_vars_by_priority(
# Base variables (ENV_X)
for k, v in all_env_vars.items():
if k.startswith("ENV_"):
# Skip internal configuration (ENV_FILES_*)
if k.startswith("ENV_FILES_"):
# Skip internal configuration and raw blobs
if k.startswith("ENV_FILES_") or k == "ENV" or k == "ENV_BLOB":
continue

# Skip environment-specific ones
Expand Down Expand Up @@ -295,32 +298,67 @@ def merge_env_vars_by_priority(

def detect_environment_secrets() -> Dict[str, Dict[str, str]]:
"""Auto-detect and parse environment-specific secrets with priority system"""
all_env_vars = {
k: v
for k, v in os.environ.items()
if (k.startswith("ENV_") or k == "ENV") and not k.startswith("ENV_FILES_")
}
if not all_env_vars:
# 1. Parse Blobs (base layer)
# This supports env_blob: ${{ toJSON(secrets) }}
blob_content = os.environ.get("ENV") or os.environ.get("ENV_BLOB")
working_vars = {}
special_global_base = {}

if blob_content:
parsed_blob = parse_all_in_one_secret(blob_content, config.ENV_FILES_FORMAT)
if parsed_blob:
# If the blob content ITSELF is a dictionary (e.g. JSON/YAML)
for k, v in parsed_blob.items():
if k == "ENV":
# This is the literal ENV secret inside a bulk blob
global_parsed = parse_all_in_one_secret(v, config.ENV_FILES_FORMAT)
if global_parsed:
special_global_base.update(global_parsed)
elif k.startswith("ENV_"):
# This is a structured secret (e.g. ENV_APP_...)
working_vars[k] = v
# ALL OTHER KEYS are ignored (strictly no global fallout)

# 2. Overwrite with real environment variables (higher priority)
# This handles individual secrets mapped in the workflow 'env:' block
for k, v in os.environ.items():
if k.startswith("ENV_") and not k.startswith("ENV_FILES_"):
working_vars[k] = v
elif k == "ENV" or k == "ENV_BLOB":
# If ENV is passed directly as a string (not in a bulk blob)
# though usually it comes through the blob logic above
global_parsed = parse_all_in_one_secret(v, config.ENV_FILES_FORMAT)
if global_parsed:
# If v was a JSON blob like {"ENV_APP"...}, we already handled it.
# If v was a raw string "KEY=VAL", it goes to global base.
is_structured_json = any(key.startswith("ENV_") for key in global_parsed.keys())
if is_structured_json:
# Re-verify and update working_vars if it was a structured blob
for gk, gv in global_parsed.items():
if gk.startswith("ENV_"):
working_vars[gk] = gv
else:
special_global_base.update(global_parsed)

if not working_vars and not special_global_base:
return {}

structure = config.ENV_FILES_STRUCTURE
if structure == "single":
patterns = [".env"]
else:
patterns = detect_file_patterns(all_env_vars, structure, config.ENVIRONMENT)
patterns = detect_file_patterns(working_vars, structure, config.ENVIRONMENT)
if config.ENV_FILES_PATTERNS and structure != "auto":
patterns = [p.strip() for p in config.ENV_FILES_PATTERNS if p.strip()]

result = {}
if "ENV" in all_env_vars:
# Treat ENV as a global blob that goes into .env (or first pattern) without prefixing
parsed = parse_all_in_one_secret(all_env_vars["ENV"], config.ENV_FILES_FORMAT)
if parsed:
target_file = patterns[0] if patterns else ".env"
result[target_file] = parsed.copy()
# 3. Apply the literal global base layer to the main .env file
if special_global_base:
target_file = patterns[0] if patterns else ".env"
result[target_file] = special_global_base.copy()

for pattern in patterns:
merged_vars = merge_env_vars_by_priority(all_env_vars, config.ENVIRONMENT, pattern)
merged_vars = merge_env_vars_by_priority(working_vars, config.ENVIRONMENT, pattern)
if merged_vars:
if pattern in result:
result[pattern].update(merged_vars)
Expand Down Expand Up @@ -351,7 +389,8 @@ def generate_env_files(conn) -> None:
all_env_vars = {
k: v
for k, v in os.environ.items()
if (k.startswith("ENV_") or k == "ENV") and not k.startswith("ENV_FILES_")
if (k.startswith("ENV_") or k == "ENV" or k == "ENV_BLOB")
and not k.startswith("ENV_FILES_")
}
env_file_data = detect_environment_secrets()
if not env_file_data:
Expand Down