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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ jobs:
| `token` | `true` | `${{ github.token }}` | The GitHub token used to create an authenticated client - Provided for you by default! |
| `timeout` | `false` | `"600000"` | Time in milliseconds after which to timeout and cancel the deployment (default: 10 minutes) |
| `error_count` | `false` | `"10"` | Maximum number of status report errors before cancelling a deployment (default: 10) |
| `reporting_interval` | `false` | `"5000"` | Time in milliseconds between two deployment status reports (default: 5 seconds) |
| `reporting_interval` | `false` | `"5000"` | Initial time in milliseconds between deployment status reports. Successful non-terminal polls use exponential backoff up to 30 seconds, or the configured interval when higher, with ±20% jitter. Error backoff is added separately (default: 5 seconds). |
| `artifact_name` | `false` | `"github-pages"` | The name of the artifact to deploy |
| `preview` | `false` | `"false"` | Is this attempting to deploy a pull request as a GitHub Pages preview site? (NOTE: This feature is only in alpha currently and is not available to the public!) |

Expand Down
2 changes: 1 addition & 1 deletion action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ inputs:
required: false
default: '10'
reporting_interval:
description: 'Time in milliseconds between two deployment status report (default: 5 seconds)'
description: 'Initial time between deployment status reports; successful polls use capped backoff and jitter, with error backoff added separately (default: 5 seconds)'
required: false
default: '5000'
Comment thread
yoannchaudet marked this conversation as resolved.
artifact_name:
Expand Down
33 changes: 30 additions & 3 deletions dist/index.js

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

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

Large diffs are not rendered by default.

151 changes: 148 additions & 3 deletions src/__tests__/internal/deployment.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,14 @@ const nock = require('nock')
// For mocking network calls with native Fetch (octokit)
const { MockAgent, setGlobalDispatcher } = require('undici')

const { Deployment, MAX_TIMEOUT, ONE_GIGABYTE, SIZE_LIMIT_DESCRIPTION } = require('../../internal/deployment')
const {
Deployment,
MAX_TIMEOUT,
DEFAULT_REPORTING_INTERVAL,
MAX_REPORTING_INTERVAL,
ONE_GIGABYTE,
SIZE_LIMIT_DESCRIPTION
} = require('../../internal/deployment')

const fakeJwt =
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJqdGkiOiJiNjllMWIxOC1jOGFiLTRhZGQtOGYxOC03MzVlMzVjZGJhZjAiLCJzdWIiOiJyZXBvOnBhcGVyLXNwYS9taW55aTplbnZpcm9ubWVudDpQcm9kdWN0aW9uIiwiYXVkIjoiaHR0cHM6Ly9naXRodWIuY29tL3BhcGVyLXNwYSIsInJlZiI6InJlZnMvaGVhZHMvbWFpbiIsInNoYSI6ImEyODU1MWJmODdiZDk3NTFiMzdiMmM0YjM3M2MxZjU3NjFmYWM2MjYiLCJyZXBvc2l0b3J5IjoicGFwZXItc3BhL21pbnlpIiwicmVwb3NpdG9yeV9vd25lciI6InBhcGVyLXNwYSIsInJ1bl9pZCI6IjE1NDY0NTkzNjQiLCJydW5fbnVtYmVyIjoiMzQiLCJydW5fYXR0ZW1wdCI6IjIiLCJhY3RvciI6IllpTXlzdHkiLCJ3b3JrZmxvdyI6IkNJIiwiaGVhZF9yZWYiOiIiLCJiYXNlX3JlZiI6IiIsImV2ZW50X25hbWUiOiJwdXNoIiwicmVmX3R5cGUiOiJicmFuY2giLCJlbnZpcm9ubWVudCI6IlByb2R1Y3Rpb24iLCJqb2Jfd29ya2Zsb3dfcmVmIjoicGFwZXItc3BhL21pbnlpLy5naXRodWIvd29ya2Zsb3dzL2JsYW5rLnltbEByZWZzL2hlYWRzL21haW4iLCJpc3MiOiJodHRwczovL3Rva2VuLmFjdGlvbnMuZ2l0aHVidXNlcmNvbnRlbnQuY29tIiwibmJmIjoxNjM4ODI4MDI4LCJleHAiOjE2Mzg4Mjg5MjgsImlhdCI6MTYzODgyODYyOH0.1wyupfxu1HGoTyIqatYg0hIxy2-0bMO-yVlmLSMuu2w'
Expand Down Expand Up @@ -35,7 +42,7 @@ describe('Deployment', () => {
case 'token':
return process.env.GITHUB_TOKEN
case 'reporting_interval':
return 50 // Lower reporting interval to speed up test
return process.env.INPUT_REPORTING_INTERVAL || 50 // Lower reporting interval to speed up test
default:
return process.env[`INPUT_${param.toUpperCase()}`] || ''
}
Expand Down Expand Up @@ -608,6 +615,42 @@ describe('Deployment', () => {
})

describe('#check', () => {
afterEach(() => {
jest.restoreAllMocks()
delete process.env.INPUT_ERROR_COUNT
delete process.env.INPUT_REPORTING_INTERVAL
})

const mockDeploymentStatus = (status, times = 1) => {
mockPool
.intercept({
path: `/repos/${process.env.GITHUB_REPOSITORY}/pages/deployments/${process.env.GITHUB_SHA}`,
method: 'GET'
})
.reply(200, { status }, { headers: { 'content-type': 'application/json' } })
.times(times)
}

const createPendingDeployment = () => {
const deployment = new Deployment()
deployment.deploymentInfo = {
id: process.env.GITHUB_SHA,
pending: true
}
deployment.startTime = Date.now()
return deployment
}

const runWithoutWaiting = async deployment => {
const timeoutSpy = jest.spyOn(global, 'setTimeout').mockImplementation(resolve => {
resolve()
return 0
})

await deployment.check()
return timeoutSpy
}

it('sets output to success when deployment is successful', async () => {
process.env.GITHUB_SHA = 'valid-build-version'

Expand Down Expand Up @@ -964,7 +1007,7 @@ describe('Deployment', () => {
case 'error_count':
return 10
case 'reporting_interval':
return 0 // The default of 5000 is too long for the test
return 1 // The default of 5000 is too long for the test
case 'timeout':
return 42
default:
Expand All @@ -990,6 +1033,108 @@ describe('Deployment', () => {
expect(core.info).toHaveBeenLastCalledWith('Reported success!')
twirpScope.done()
})

it('backs off successful non-terminal status checks', async () => {
process.env.GITHUB_SHA = 'valid-build-version'
process.env.INPUT_ERROR_COUNT = '10'
mockDeploymentStatus('deployment_in_progress', 2)
mockDeploymentStatus('succeed')
const randomSpy = jest.spyOn(Math, 'random').mockReturnValue(0.5)

const timeoutSpy = await runWithoutWaiting(createPendingDeployment())

expect(timeoutSpy).toHaveBeenNthCalledWith(1, expect.any(Function), 50)
expect(timeoutSpy).toHaveBeenNthCalledWith(2, expect.any(Function), 75)
expect(timeoutSpy).toHaveBeenNthCalledWith(3, expect.any(Function), 113)

timeoutSpy.mockRestore()
randomSpy.mockRestore()
delete process.env.INPUT_ERROR_COUNT
})

it('caps the successful status check backoff', async () => {
process.env.GITHUB_SHA = 'valid-build-version'
process.env.INPUT_ERROR_COUNT = '10'
process.env.INPUT_REPORTING_INTERVAL = '20000'
mockDeploymentStatus('deployment_in_progress')
mockDeploymentStatus('succeed')
const randomSpy = jest.spyOn(Math, 'random').mockReturnValue(0.5)

const timeoutSpy = await runWithoutWaiting(createPendingDeployment())

expect(timeoutSpy.mock.calls.map(([, interval]) => interval)).toEqual([20000, MAX_REPORTING_INTERVAL])

timeoutSpy.mockRestore()
randomSpy.mockRestore()
})

it('does not reduce a configured interval above the backoff cap', async () => {
process.env.GITHUB_SHA = 'valid-build-version'
process.env.INPUT_ERROR_COUNT = '10'
process.env.INPUT_REPORTING_INTERVAL = '45000'
mockDeploymentStatus('deployment_in_progress')
mockDeploymentStatus('succeed')
jest.spyOn(Math, 'random').mockReturnValue(0.5)

const timeoutSpy = await runWithoutWaiting(createPendingDeployment())

expect(timeoutSpy.mock.calls.map(([, interval]) => interval)).toEqual([45000, 45000])
})

it.each(['not-a-number', '0', '-1'])(
'uses the default reporting interval for invalid input %s',
async reportingInterval => {
process.env.GITHUB_SHA = 'valid-build-version'
process.env.INPUT_ERROR_COUNT = '10'
process.env.INPUT_REPORTING_INTERVAL = reportingInterval
mockDeploymentStatus('succeed')
jest.spyOn(Math, 'random').mockReturnValue(0.5)

const timeoutSpy = await runWithoutWaiting(createPendingDeployment())

expect(timeoutSpy).toHaveBeenCalledWith(expect.any(Function), DEFAULT_REPORTING_INTERVAL)
expect(core.warning).toHaveBeenCalledWith(
`Invalid reporting_interval value; using the default of ${DEFAULT_REPORTING_INTERVAL} milliseconds.`
)
}
)

it('jitters status check intervals by up to twenty percent', async () => {
process.env.GITHUB_SHA = 'valid-build-version'
process.env.INPUT_ERROR_COUNT = '10'
mockDeploymentStatus('deployment_in_progress')
mockDeploymentStatus('succeed')
const randomSpy = jest.spyOn(Math, 'random').mockReturnValueOnce(0).mockReturnValueOnce(1)

const timeoutSpy = await runWithoutWaiting(createPendingDeployment())

expect(timeoutSpy).toHaveBeenNthCalledWith(1, expect.any(Function), 40)
expect(timeoutSpy).toHaveBeenNthCalledWith(2, expect.any(Function), 90)

timeoutSpy.mockRestore()
randomSpy.mockRestore()
})

it('keeps success backoff separate from error backoff', async () => {
process.env.GITHUB_SHA = 'valid-build-version'
process.env.INPUT_ERROR_COUNT = '10'
mockPool
.intercept({
path: `/repos/${process.env.GITHUB_REPOSITORY}/pages/deployments/${process.env.GITHUB_SHA}`,
method: 'GET'
})
.reply(500, {}, { headers: { 'content-type': 'application/json' } })
mockDeploymentStatus('deployment_in_progress')
mockDeploymentStatus('succeed')
const randomSpy = jest.spyOn(Math, 'random').mockReturnValue(0.5)

const timeoutSpy = await runWithoutWaiting(createPendingDeployment())

expect(timeoutSpy.mock.calls.map(([, interval]) => interval)).toEqual([50, 51, 75])

timeoutSpy.mockRestore()
randomSpy.mockRestore()
})
})

describe('#cancel', () => {
Expand Down
33 changes: 30 additions & 3 deletions src/internal/deployment.js
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,18 @@ const finalErrorStatus = {
}

const MAX_TIMEOUT = 600000
const DEFAULT_REPORTING_INTERVAL = 5000
const MAX_REPORTING_INTERVAL = 30000
const REPORTING_BACKOFF_MULTIPLIER = 1.5
const REPORTING_JITTER_FACTOR = 0.2
const ONE_GIGABYTE = 1073741824
const SIZE_LIMIT_DESCRIPTION = '1 GB'

function getJitteredInterval(interval) {
const jitter = interval * REPORTING_JITTER_FACTOR
return Math.round(interval - jitter + Math.random() * jitter * 2)
}

class Deployment {
constructor() {
const context = getContext()
Expand Down Expand Up @@ -138,9 +147,19 @@ class Deployment {
}

const deploymentId = this.deploymentInfo.id || this.buildVersion
const reportingInterval = Number(core.getInput('reporting_interval'))
const reportingIntervalInput = Number(core.getInput('reporting_interval'))
const initialReportingInterval =
Number.isFinite(reportingIntervalInput) && reportingIntervalInput > 0
? reportingIntervalInput
: DEFAULT_REPORTING_INTERVAL
const maxReportingInterval = Math.max(MAX_REPORTING_INTERVAL, initialReportingInterval)
const maxErrorCount = Number(core.getInput('error_count'))

Comment thread
yoannchaudet marked this conversation as resolved.
if (initialReportingInterval !== reportingIntervalInput) {
core.warning(`Invalid reporting_interval value; using the default of ${DEFAULT_REPORTING_INTERVAL} milliseconds.`)
}

let reportingInterval = initialReportingInterval
let errorCount = 0

// Time in milliseconds between two deployment status report when status errored, default 0.
Expand All @@ -151,7 +170,7 @@ class Deployment {
/*eslint no-constant-condition: ["error", { "checkLoops": false }]*/
while (true) {
// Handle reporting interval
await new Promise(resolve => setTimeout(resolve, reportingInterval + errorReportingInterval))
await new Promise(resolve => setTimeout(resolve, getJitteredInterval(reportingInterval + errorReportingInterval)))

// Check status
try {
Expand Down Expand Up @@ -179,6 +198,7 @@ class Deployment {

// reset the error reporting interval once get the proper status back.
errorReportingInterval = 0
reportingInterval = Math.min(Math.round(reportingInterval * REPORTING_BACKOFF_MULTIPLIER), maxReportingInterval)
} catch (error) {
core.error(error.stack)

Expand Down Expand Up @@ -242,4 +262,11 @@ class Deployment {
}
}

module.exports = { Deployment, MAX_TIMEOUT, ONE_GIGABYTE, SIZE_LIMIT_DESCRIPTION }
module.exports = {
Deployment,
MAX_TIMEOUT,
DEFAULT_REPORTING_INTERVAL,
MAX_REPORTING_INTERVAL,
ONE_GIGABYTE,
SIZE_LIMIT_DESCRIPTION
}
Loading