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
88 changes: 88 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ Check the **Actions** tab to see your deployment tracked!
| `status` | ❌ | `success` | Event status (`success`, `failure`, `in_progress`) |
| `metadata` | ❌ | `{}` | Additional JSON metadata to attach to the event |
| `fail_on_rejection` | ❌ | `true` | Fail the workflow if Versioner rejects the deployment (e.g., conflicts, no-deploy windows) |
| `skip_preflight_checks` | ❌ | `false` | Skip preflight checks (use for emergency deployments only) |

\* Required unless provided via `VERSIONER_API_KEY` environment variable

Expand All @@ -99,6 +100,93 @@ Check the **Actions** tab to see your deployment tracked!
| `version_id` | UUID of the version record (all events) |
| `product_id` | UUID of the product (all events) |

## 🛡️ Preflight Checks

When starting a deployment (`status: started`), Versioner automatically runs preflight checks to validate:
- **No concurrent deployments** - Prevents multiple simultaneous deployments to the same environment
- **No active no-deploy windows** - Respects scheduled freeze periods (e.g., Friday afternoons, holidays)
- **Required approvals obtained** - Ensures proper authorization before deployment
- **Flow/soak time requirements met** - Validates promotion path and minimum soak time in lower environments

If checks fail, the action will fail and the deployment will **NOT** be created.

### Default Behavior

Preflight checks run automatically by default:

```yaml
- name: Deploy to production
uses: versioner-io/versioner-github-action@v1
with:
api_key: ${{ secrets.VERSIONER_API_KEY }}
product_name: my-service
version: ${{ github.sha }}
environment: production
status: started # Checks run automatically
```

### Skip Checks (Emergency Only)

For emergency hotfixes, you can skip preflight checks:

```yaml
- name: Emergency hotfix deployment
uses: versioner-io/versioner-github-action@v1
with:
api_key: ${{ secrets.VERSIONER_API_KEY }}
product_name: my-service
version: ${{ github.sha }}
environment: production
status: started
skip_preflight_checks: true # ⚠️ Use sparingly!
```

**⚠️ Warning:** Skipping checks bypasses all deployment policies. Use only for genuine emergencies.

### Error Messages

When preflight checks fail, you'll see detailed error messages:

**Schedule Block (423):**
```
🔒 Deployment Blocked by Schedule

Rule: Production Freeze - Friday Afternoons
Deployment blocked by no-deploy window

Retry after: 2025-11-21T18:00:00-08:00

To skip checks (emergency only), add to your workflow:
skip-preflight-checks: true
```

**Flow Violation (428):**
```
❌ Deployment Precondition Failed

Error: FLOW_VIOLATION
Rule: Staging Required Before Production
Version must be deployed to staging first

Deploy to required environments first, then retry.
```

**Insufficient Soak Time (428):**
```
❌ Deployment Precondition Failed

Error: INSUFFICIENT_SOAK_TIME
Rule: 24hr Staging Soak
Version must soak in staging for at least 24 hours

Retry after: 2025-11-22T10:00:00Z

Wait for soak time to complete, then retry.

To skip checks (emergency only), add to your workflow:
skip-preflight-checks: true
```

## 🔧 Usage Examples

### Using Environment Variables (Recommended for Multiple Events)
Expand Down
4 changes: 4 additions & 0 deletions action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,10 @@ inputs:
description: 'Fail the workflow if Versioner rejects the deployment (e.g., conflicts, no-deploy windows)'
required: false
default: 'true'
skip_preflight_checks:
description: 'Skip preflight checks (use for emergency deployments only)'
required: false
default: 'false'

outputs:
deployment_id:
Expand Down
2 changes: 1 addition & 1 deletion dist/api-client.d.ts.map

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

66 changes: 63 additions & 3 deletions dist/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -34598,9 +34598,64 @@ async function sendDeploymentEvent(apiUrl, apiKey, payload, failOnRejection = fa
const data = axiosError.response?.data;
// Handle rejection status codes (409, 423, 428)
if (status === 409 || status === 423 || status === 428) {
const errorData = data;
const message = errorData?.message || errorData?.error || 'Deployment rejected by Versioner';
const rejectionError = `Deployment rejected: ${message}`;
const errorResponse = data;
const detail = errorResponse?.detail;
const errorCode = detail?.code || 'UNKNOWN';
const message = detail?.message || 'Deployment rejected by Versioner';
const ruleName = detail?.details?.rule_name || 'Unknown Rule';
let rejectionError = '';
// Format error based on status code
if (status === 409) {
// DEPLOYMENT_IN_PROGRESS
rejectionError = `⚠️ Deployment Conflict\n\n`;
rejectionError += `${message}\n`;
rejectionError += `Another deployment is in progress. Please wait and retry.`;
}
else if (status === 423) {
// NO_DEPLOY_WINDOW
rejectionError = `🔒 Deployment Blocked by Schedule\n\n`;
rejectionError += `Rule: ${ruleName}\n`;
rejectionError += `${message}\n`;
if (detail?.retry_after) {
rejectionError += `\nRetry after: ${detail.retry_after}`;
}
rejectionError += `\n\nTo skip checks (emergency only), add to your workflow:\n`;
rejectionError += ` skip-preflight-checks: true`;
}
else if (status === 428) {
// Precondition failures (FLOW_VIOLATION, INSUFFICIENT_SOAK_TIME, etc.)
rejectionError = `❌ Deployment Precondition Failed\n\n`;
rejectionError += `Error: ${errorCode}\n`;
rejectionError += `Rule: ${ruleName}\n`;
rejectionError += `${message}\n`;
if (detail?.retry_after) {
rejectionError += `\nRetry after: ${detail.retry_after}`;
}
// Add specific guidance based on error code
if (errorCode === 'FLOW_VIOLATION') {
rejectionError += `\n\nDeploy to required environments first, then retry.`;
}
else if (errorCode === 'INSUFFICIENT_SOAK_TIME') {
rejectionError += `\n\nWait for soak time to complete, then retry.`;
rejectionError += `\n\nTo skip checks (emergency only), add to your workflow:\n`;
rejectionError += ` skip-preflight-checks: true`;
}
else if (errorCode === 'QUALITY_APPROVAL_REQUIRED' ||
errorCode === 'APPROVAL_REQUIRED') {
rejectionError += `\n\nApproval required before deployment can proceed.`;
rejectionError += `\nObtain approval via Versioner UI, then retry.`;
}
else {
// Generic handler for unknown/future error codes
rejectionError += `\n\nResolve the issue described above, then retry.`;
rejectionError += `\n\nTo skip checks (emergency only), add to your workflow:\n`;
rejectionError += ` skip-preflight-checks: true`;
}
// Always include full details for debugging (all error codes)
if (detail?.details) {
rejectionError += `\n\nDetails: ${JSON.stringify(detail.details, null, 2)}`;
}
}
if (failOnRejection) {
throw new Error(rejectionError);
}
Expand Down Expand Up @@ -35025,6 +35080,7 @@ async function run() {
version: inputs.version,
environment_name: inputs.environment,
status: inputs.status,
skip_preflight_checks: inputs.skipPreflightChecks,
scm_repository: githubMetadata.scm_repository,
scm_sha: githubMetadata.scm_sha,
source_system: githubMetadata.source_system,
Expand Down Expand Up @@ -35124,6 +35180,7 @@ function getInputs() {
const status = core.getInput('status', { required: false }) || 'success';
const metadataInput = core.getInput('metadata', { required: false }) || '{}';
const failOnRejectionInput = core.getInput('fail_on_rejection', { required: false }) || 'true';
const skipPreflightChecksInput = core.getInput('skip_preflight_checks', { required: false }) || 'false';
// Validate API key is provided
if (!apiKey) {
throw new Error(`api_key is required (provide via input or VERSIONER_API_KEY environment variable)`);
Expand Down Expand Up @@ -35159,6 +35216,8 @@ function getInputs() {
}
// Parse fail_on_rejection boolean
const failOnRejection = failOnRejectionInput.toLowerCase() === 'true';
// Parse skip_preflight_checks boolean
const skipPreflightChecks = skipPreflightChecksInput.toLowerCase() === 'true';
return {
apiUrl,
apiKey,
Expand All @@ -35169,6 +35228,7 @@ function getInputs() {
status,
metadata,
failOnRejection,
skipPreflightChecks,
};
}

Expand Down
2 changes: 1 addition & 1 deletion dist/index.js.map

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion dist/inputs.d.ts.map

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions dist/types.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ export interface DeploymentEventPayload {
version: string;
environment_name?: string;
status: string;
skip_preflight_checks?: boolean;
scm_repository?: string;
scm_sha?: string;
source_system?: string;
Expand Down Expand Up @@ -68,6 +69,7 @@ export interface ActionInputs {
status: string;
metadata: Record<string, unknown>;
failOnRejection: boolean;
skipPreflightChecks: boolean;
}
export interface GitHubMetadata {
scm_repository: string;
Expand Down
2 changes: 1 addition & 1 deletion dist/types.d.ts.map

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

37 changes: 37 additions & 0 deletions src/__tests__/inputs.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ describe('getInputs', () => {
status: 'success',
metadata: {},
failOnRejection: true,
skipPreflightChecks: false,
})
})

Expand Down Expand Up @@ -259,4 +260,40 @@ describe('getInputs', () => {

expect(inputs.failOnRejection).toBe(false)
})

it('should parse skip_preflight_checks as false by default', () => {
mockGetInput.mockImplementation((name: string) => {
const inputs: Record<string, string> = {
api_url: 'https://api.versioner.io',
api_key: 'sk_test_key',
product_name: 'test-product',
version: '1.0.0',
environment: 'production',
skip_preflight_checks: '',
}
return inputs[name] || ''
})

const inputs = getInputs()

expect(inputs.skipPreflightChecks).toBe(false)
})

it('should parse skip_preflight_checks as true when explicitly set', () => {
mockGetInput.mockImplementation((name: string) => {
const inputs: Record<string, string> = {
api_url: 'https://api.versioner.io',
api_key: 'sk_test_key',
product_name: 'test-product',
version: '1.0.0',
environment: 'production',
skip_preflight_checks: 'true',
}
return inputs[name] || ''
})

const inputs = getInputs()

expect(inputs.skipPreflightChecks).toBe(true)
})
})
Loading