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
123 changes: 121 additions & 2 deletions __tests__/main.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -429,9 +429,11 @@ describe('Gigalixir Deploy Action', () => {
// Mock exec to handle git rev-parse for SHA resolution
function mockExecWithRevParse(sha: string): void {
mockedExec.exec.mockImplementation(
async (_cmd: string, args?: string[], options?: Record<string, unknown>) => {
async (_cmd: string, args?: string[], options?: exec.ExecOptions) => {
if (args && args[0] === 'rev-parse' && options?.listeners) {
const listeners = options.listeners as { stdout?: (data: Buffer) => void }
const listeners = options.listeners as {
stdout?: (data: Buffer) => void
}
listeners.stdout?.(Buffer.from(sha))
}
return 0
Expand Down Expand Up @@ -1016,4 +1018,121 @@ describe('Gigalixir Deploy Action', () => {
)
})
})

describe('create action with size/replicas', () => {
function mockHttpsResponses(
responses: { statusCode: number; body: string }[]
): void {
const https = require('https')
let callIndex = 0
https.request.mockImplementation(
(
_options: unknown,
callback: (res: {
statusCode: number
on: (event: string, cb: (data?: string) => void) => void
}) => void
) => {
const response =
responses[callIndex] || responses[responses.length - 1]
callIndex++
const res = {
statusCode: response.statusCode,
on: jest.fn((event: string, cb: (data?: string) => void) => {
if (event === 'data') cb(response.body)
if (event === 'end') cb()
})
}
callback(res)
return {
on: jest.fn(),
write: jest.fn(),
end: jest.fn()
}
}
)
}

it('should scale after creating app when size is provided', async () => {
mockedCore.getInput.mockImplementation((name: string) => {
const inputs: Record<string, string> = {
gigalixir_email: 'test@example.com',
gigalixir_api_key: 'test-api-key',
app_name: 'test-app',
action: 'create',
size: '1.0',
github_deployments: 'false'
}
return inputs[name] || ''
})

// 1st call: GET app (check exists) -> 404 (doesn't exist)
// 2nd call: POST create app -> 201
// 3rd call: PUT scale -> 200
mockHttpsResponses([
{ statusCode: 404, body: '{"errors":{"detail":"not found"}}' },
{ statusCode: 201, body: '{}' },
{ statusCode: 200, body: '{}' }
])

await runAction()

const https = require('https')
const calls = https.request.mock.calls

// Verify scale call was made
const scaleCall = calls.find(
(c: [{ path: string; method: string }]) =>
c[0].path === '/api/apps/test-app/scale' && c[0].method === 'PUT'
)
expect(scaleCall).toBeDefined()

// Verify scale sends the size
const scaleCallIndex = calls.indexOf(scaleCall)
const writeCall = https.request.mock.results[scaleCallIndex].value.write
expect(writeCall).toHaveBeenCalledWith(JSON.stringify({ size: 1 }))

expect(mockedCore.setOutput).toHaveBeenCalledWith(
'deploy_status',
'success'
)
})

it('should not scale after creating app when size is not provided', async () => {
mockedCore.getInput.mockImplementation((name: string) => {
const inputs: Record<string, string> = {
gigalixir_email: 'test@example.com',
gigalixir_api_key: 'test-api-key',
app_name: 'test-app',
action: 'create',
github_deployments: 'false'
}
return inputs[name] || ''
})

// 1st call: GET app (check exists) -> 404 (doesn't exist)
// 2nd call: POST create app -> 201
mockHttpsResponses([
{ statusCode: 404, body: '{"errors":{"detail":"not found"}}' },
{ statusCode: 201, body: '{}' }
])

await runAction()

const https = require('https')
const calls = https.request.mock.calls

// Verify no scale call was made
const scaleCall = calls.find(
(c: [{ path: string; method: string }]) =>
c[0].path === '/api/apps/test-app/scale' && c[0].method === 'PUT'
)
expect(scaleCall).toBeUndefined()

expect(mockedCore.setOutput).toHaveBeenCalledWith(
'deploy_status',
'success'
)
})
})
})
4 changes: 2 additions & 2 deletions action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -72,11 +72,11 @@ inputs:
required: false
default: '0'
replicas:
description: 'Number of replicas to run (used with action: scale)'
description: 'Number of replicas to run (used with action: create, create_deploy, or scale)'
required: false
default: ''
size:
description: 'Size of each replica between 0.5 and 128 (used with action: scale)'
description: 'Size of each replica between 0.5 and 128 (used with action: create, create_deploy, or scale)'
required: false
default: ''
# Backwards-compatible aliases from gigalixir-action
Expand Down
30 changes: 28 additions & 2 deletions dist/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -25771,7 +25771,7 @@ async function run() {
// Wait for deployment rollout if timeout is set
if (deployTimeout > 0 &&
(action === 'deploy' || action === 'create_deploy')) {
const sha = process.env.GITHUB_SHA || '';
const sha = await resolveGitSha(core.getInput('git_ref'));
await waitForDeployment(email, apiKey, appName, sha, deployTimeout);
}
core.setOutput('deploy_status', 'success');
Expand Down Expand Up @@ -25877,6 +25877,12 @@ async function handleCreate(email, apiKey, appName) {
await copyConfig(email, apiKey, appName, copyConfigFrom);
core.info('Config copied successfully');
}
// Scale the app if size or replicas are provided
const replicasInput = core.getInput('replicas');
const sizeInput = core.getInput('size');
if (replicasInput || sizeInput) {
await handleScale(email, apiKey, appName);
}
}
async function handleDestroy(email, apiKey, appName) {
core.info(`Destroying Gigalixir app: ${appName}`);
Expand Down Expand Up @@ -26157,12 +26163,19 @@ async function waitForDeployment(email, apiKey, appName, sha, timeoutSeconds) {
}
await new Promise((resolve) => setTimeout(resolve, pollIntervalMs));
}
// Timeout: get final status for error message
// Final check: the pod may have become healthy during the last sleep interval
let statusSummary = 'Could not retrieve final status';
try {
const response = (await gigalixirApiRequest(email, apiKey, 'GET', `/api/apps/${encodedAppName}/status`));
const pods = response.data?.pods || [];
const replicasDesired = response.data?.replicas_desired || 0;
const healthyNewPods = pods.filter((pod) => pod.name.startsWith(appName) &&
pod.sha === sha &&
pod.status === 'Healthy');
if (healthyNewPods.length >= replicasDesired && replicasDesired > 0) {
core.info(`Deployment rollout complete: ${healthyNewPods.length}/${replicasDesired} healthy pods`);
return;
}
const podSummaries = pods.map((pod) => `${pod.name} (sha: ${pod.sha.substring(0, 7)}, status: ${pod.status})`);
statusSummary = `replicas_desired: ${replicasDesired}, pods: [${podSummaries.join(', ')}]`;
}
Expand All @@ -26172,6 +26185,19 @@ async function waitForDeployment(email, apiKey, appName, sha, timeoutSeconds) {
throw new Error(`Deployment rollout timed out after ${timeoutSeconds}s. Status: ${statusSummary}`);
}
// Git Functions
async function resolveGitSha(gitRef) {
const ref = gitRef || process.env.GITHUB_SHA || 'HEAD';
let sha = '';
await exec.exec('git', ['rev-parse', ref], {
listeners: {
stdout: (data) => {
sha += data.toString();
}
},
silent: true
});
return sha.trim();
}
async function configureGitCredentials(email, apiKey) {
core.info('Configuring git credentials...');
// Use git credential store for authentication
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/main.d.ts.map

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

7 changes: 7 additions & 0 deletions src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -285,6 +285,13 @@ async function handleCreate(
await copyConfig(email, apiKey, appName, copyConfigFrom)
core.info('Config copied successfully')
}

// Scale the app if size or replicas are provided
const replicasInput = core.getInput('replicas')
const sizeInput = core.getInput('size')
if (replicasInput || sizeInput) {
await handleScale(email, apiKey, appName)
}
}

async function handleDestroy(
Expand Down