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
27 changes: 17 additions & 10 deletions src/lib/api-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,9 +31,11 @@ export function errorFromResponse(body: unknown, response: Response): DevhelmErr
* One CLI invocation. `Idempotency-Key` values are derived from it so a
* transport retry of the same request replays, while every logical POST
* in the invocation gets its own key (doc 32 D-7): in GitHub Actions the
* seed is `sha256(GITHUB_RUN_ID, GITHUB_RUN_ATTEMPT, command)`, elsewhere a
* random UUID per invocation. `--idempotency-key` pins the first POST's
* key; later POSTs in the same invocation append an ordinal.
* seed is `sha256(GITHUB_RUN_ID, GITHUB_RUN_ATTEMPT, GITHUB_JOB,
* GITHUB_ACTION, argv)`, elsewhere a random UUID per invocation. The argv
* and step keep two different commands in one job apart; re-running the
* same step of the same attempt replays. `--idempotency-key` pins the first
* POST's key; later POSTs in the same invocation append an ordinal.
*/
interface InvocationContext {
command: string
Expand All @@ -45,18 +47,23 @@ interface InvocationContext {

let invocation: InvocationContext = newInvocation('cli')

function newInvocation(command: string, explicitKey?: string): InvocationContext {
const runId = process.env.GITHUB_RUN_ID
const attempt = process.env.GITHUB_RUN_ATTEMPT
const seed = runId
? createHash('sha256').update(`${runId}\n${attempt ?? '1'}\n${command}`).digest('hex')
function newInvocation(command: string, explicitKey?: string, argv: readonly string[] = []): InvocationContext {
const env = process.env
const seed = env.GITHUB_RUN_ID
? createHash('sha256')
.update(
[env.GITHUB_RUN_ID, env.GITHUB_RUN_ATTEMPT ?? '1', env.GITHUB_JOB ?? '', env.GITHUB_ACTION ?? '', command, ...argv].join(
'\n',
),
)
.digest('hex')
: randomUUID()
return {command, seed, explicitKey, counter: new Map(), posts: 0}
}

/** Called once per command invocation (from the base command's init). */
export function beginInvocation(command: string, explicitKey?: string): void {
invocation = newInvocation(command, explicitKey)
export function beginInvocation(command: string, explicitKey?: string, argv: readonly string[] = process.argv.slice(2)): void {
invocation = newInvocation(command, explicitKey, argv)
}

/** Next `Idempotency-Key` for a POST to `target` in this invocation. */
Expand Down
40 changes: 40 additions & 0 deletions test/lib/idempotency-key.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import {afterEach, beforeEach, describe, expect, it} from 'vitest'
import {beginInvocation, nextIdempotencyKey} from '../../src/lib/api-client.js'

const CI_ENV = {GITHUB_RUN_ID: '123', GITHUB_RUN_ATTEMPT: '1', GITHUB_JOB: 'deploy', GITHUB_ACTION: '__run_2'}

function keyFor(argv: string[], env: Record<string, string> = CI_ENV): string {
Object.assign(process.env, env)
beginInvocation('status-pages:create', undefined, argv)
return nextIdempotencyKey('POST', '/api/v1/status-pages')
}

describe('CI-derived idempotency keys', () => {
const saved = {...process.env}
beforeEach(() => {
for (const k of Object.keys(CI_ENV)) delete process.env[k]
})
afterEach(() => {
process.env = {...saved}
})

it('gives two different commands in one CI step different keys', () => {
expect(keyFor(['status-pages', 'create', '--name', 'a'])).not.toBe(keyFor(['status-pages', 'create', '--name', 'b']))
})

it('replays the same command in the same step and attempt', () => {
const argv = ['status-pages', 'create', '--name', 'a']
expect(keyFor(argv)).toBe(keyFor(argv))
})

it('gives the same command in another step or attempt a new key', () => {
const argv = ['status-pages', 'create', '--name', 'a']
expect(keyFor(argv)).not.toBe(keyFor(argv, {...CI_ENV, GITHUB_ACTION: '__run_3'}))
expect(keyFor(argv)).not.toBe(keyFor(argv, {...CI_ENV, GITHUB_RUN_ATTEMPT: '2'}))
})

it('uses a random seed outside CI', () => {
const argv = ['status-pages', 'create', '--name', 'a']
expect(keyFor(argv, {})).not.toBe(keyFor(argv, {}))
})
})
Loading