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
8 changes: 6 additions & 2 deletions dist/main/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -30180,9 +30180,13 @@ async function run() {
// Get OIDC token
core.info('Requesting OIDC token...');
const token = await (0, oidc_1.getOidcToken)(audience);
// Save for post step
// Save for post step. We persist `audience` rather than `token`
// because GitHub's runtime ID tokens are short-lived (≲10 min) and
// the post hook can fire well after a long-running job — replaying
// a cached JWT then yields HTTP 401 on the release call. The post
// step re-mints a fresh token via `getOidcToken(audience)`.
core.saveState('endpoint', endpoint);
core.saveState('token', token);
core.saveState('audience', audience);
// Create lease
const client = new client_1.KobeClient(endpoint, token);
core.info(`Claiming cluster from pool "${pool}" with TTL ${ttl}...`);
Expand Down
86 changes: 83 additions & 3 deletions dist/post/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -25790,6 +25790,69 @@ function sleep(ms) {
}


/***/ }),

/***/ 6434:
/***/ (function(__unused_webpack_module, exports, __nccwpck_require__) {

"use strict";

var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
var ownKeys = function(o) {
ownKeys = Object.getOwnPropertyNames || function (o) {
var ar = [];
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
return ar;
};
return ownKeys(o);
};
return function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
__setModuleDefault(result, mod);
return result;
};
})();
Object.defineProperty(exports, "__esModule", ({ value: true }));
exports.getOidcToken = getOidcToken;
const core = __importStar(__nccwpck_require__(7484));
const http_client_1 = __nccwpck_require__(4844);
async function getOidcToken(audience) {
const requestToken = process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN;
const requestUrl = process.env.ACTIONS_ID_TOKEN_REQUEST_URL;
if (!requestToken || !requestUrl) {
throw new Error('OIDC token not available. Add "permissions: id-token: write" to your job.');
}
const http = new http_client_1.HttpClient('kobe-action');
const url = `${requestUrl}&audience=${encodeURIComponent(audience)}`;
const response = await http.getJson(url, {
Authorization: `bearer ${requestToken}`,
});
if (!response.result?.value) {
throw new Error('Failed to obtain OIDC token from GitHub');
}
core.setSecret(response.result.value);
return response.result.value;
}


/***/ }),

/***/ 6661:
Expand Down Expand Up @@ -25831,18 +25894,33 @@ var __importStar = (this && this.__importStar) || (function () {
};
})();
Object.defineProperty(exports, "__esModule", ({ value: true }));
exports.post = post;
const core = __importStar(__nccwpck_require__(7484));
const fs = __importStar(__nccwpck_require__(9896));
const path = __importStar(__nccwpck_require__(6928));
const oidc_1 = __nccwpck_require__(6434);
const client_1 = __nccwpck_require__(9592);
async function post() {
const leaseId = core.getState('lease-id');
const endpoint = core.getState('endpoint');
const token = core.getState('token');
if (!leaseId || !endpoint || !token) {
if (!leaseId || !endpoint) {
core.info('No lease to release (claim may have failed)');
return;
}
// Re-mint the OIDC token rather than reusing the one minted in `main`.
// GitHub Actions runtime ID tokens are short-lived (≲10 min); a job
// that runs longer than the JWT's lifetime would 401 against an
// audience-validating server when the cached token is replayed here.
// Keep this in lockstep with `main.ts` writing `audience` to state.
const audience = core.getState('audience') || 'kobe-system';
let token;
try {
token = await (0, oidc_1.getOidcToken)(audience);
}
catch (err) {
core.warning(`Skipping lease release for ${leaseId}: failed to mint OIDC token: ${err instanceof Error ? err.message : String(err)}`);
return;
}
core.info(`Releasing cluster (lease: ${leaseId})...`);
const client = new client_1.KobeClient(endpoint, token);
await client.releaseLease(leaseId);
Expand All @@ -25856,7 +25934,9 @@ async function post() {
// ignore
}
}
post();
if (require.main === require.cache[eval('__filename')]) {
post();
}


/***/ }),
Expand Down
8 changes: 6 additions & 2 deletions src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,13 @@ async function run(): Promise<void> {
core.info('Requesting OIDC token...');
const token = await getOidcToken(audience);

// Save for post step
// Save for post step. We persist `audience` rather than `token`
// because GitHub's runtime ID tokens are short-lived (≲10 min) and
// the post hook can fire well after a long-running job — replaying
// a cached JWT then yields HTTP 401 on the release call. The post
// step re-mints a fresh token via `getOidcToken(audience)`.
core.saveState('endpoint', endpoint);
core.saveState('token', token);
core.saveState('audience', audience);

// Create lease
const client = new KobeClient(endpoint, token);
Expand Down
123 changes: 123 additions & 0 deletions src/post.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import * as core from '@actions/core';

// Pin the wiring contract of the post hook. The bug this guards against:
// v2 (and earlier) cached the OIDC token in `core.saveState('token', …)`
// from `main` and replayed it here. GitHub's runtime ID token is
// short-lived (≲10 min), so jobs that ran longer than the JWT's lifetime
// would 401 on `Releasing cluster` against an audience-validating kobe.
// These tests assert the post hook re-mints the token via
// `getOidcToken(audience)` instead — anyone reverting the wiring will
// fail the suite before it ships.
vi.mock('./oidc', () => ({
getOidcToken: vi.fn(),
}));
vi.mock('./client', () => ({
KobeClient: vi.fn(),
}));

import { getOidcToken } from './oidc';
import { KobeClient } from './client';
import { post } from './post';

const releaseLeaseMock = vi.fn();

beforeEach(() => {
vi.resetAllMocks();
// Silence post's logging — every test path emits at least `core.info`.
vi.spyOn(core, 'info').mockImplementation(() => {});
vi.spyOn(core, 'warning').mockImplementation(() => {});

releaseLeaseMock.mockReset().mockResolvedValue(undefined);
vi.mocked(KobeClient).mockImplementation(
() => ({ releaseLease: releaseLeaseMock }) as unknown as KobeClient
);
vi.mocked(getOidcToken).mockResolvedValue('fresh-oidc-jwt');
});

function stubState(state: Record<string, string>): void {
vi.spyOn(core, 'getState').mockImplementation((key: string) => state[key] ?? '');
}

describe('post hook', () => {
it('mints a fresh OIDC token with the audience saved in state', async () => {
stubState({
'lease-id': 'lease-abc123',
endpoint: 'https://kobe.example',
audience: 'kobe-system',
});

await post();

expect(getOidcToken).toHaveBeenCalledTimes(1);
expect(getOidcToken).toHaveBeenCalledWith('kobe-system');
});

it('passes the freshly-minted token (not a state-cached one) to KobeClient', async () => {
stubState({
'lease-id': 'lease-abc123',
endpoint: 'https://kobe.example',
audience: 'kobe-system',
});

await post();

expect(KobeClient).toHaveBeenCalledWith('https://kobe.example', 'fresh-oidc-jwt');
expect(releaseLeaseMock).toHaveBeenCalledWith('lease-abc123');
});

it('defaults audience to "kobe-system" when state was written by an older main', async () => {
// Backward-compat: state from a hypothetical older main.ts that
// didn't save `audience` would return '' here. We must not pass
// an empty audience to the OIDC endpoint (GitHub would reject it).
stubState({
'lease-id': 'lease-abc123',
endpoint: 'https://kobe.example',
});

await post();

expect(getOidcToken).toHaveBeenCalledWith('kobe-system');
});

it('uses a custom audience verbatim when main saved one', async () => {
stubState({
'lease-id': 'lease-abc123',
endpoint: 'https://kobe.example',
audience: 'kobe-staging',
});

await post();

expect(getOidcToken).toHaveBeenCalledWith('kobe-staging');
});

it('skips release (no throw) when state has no lease — claim failed before saveState', async () => {
stubState({});

await post();

expect(getOidcToken).not.toHaveBeenCalled();
expect(KobeClient).not.toHaveBeenCalled();
});

it('warns and skips release when minting a fresh token throws', async () => {
// If `id-token: write` was missing from the job perms, getOidcToken
// throws. Post must not propagate — it runs in `always()` and
// throwing would mark the cleanup as failed in the UI.
stubState({
'lease-id': 'lease-abc123',
endpoint: 'https://kobe.example',
audience: 'kobe-system',
});
vi.mocked(getOidcToken).mockRejectedValue(new Error('id-token not available'));
const warn = vi.spyOn(core, 'warning').mockImplementation(() => {});

await expect(post()).resolves.toBeUndefined();

expect(KobeClient).not.toHaveBeenCalled();
expect(warn).toHaveBeenCalledWith(
expect.stringContaining('failed to mint OIDC token: id-token not available')
);
});
});
27 changes: 23 additions & 4 deletions src/post.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,35 @@
import * as core from '@actions/core';
import * as fs from 'fs';
import * as path from 'path';
import { getOidcToken } from './oidc';
import { KobeClient } from './client';

async function post(): Promise<void> {
export async function post(): Promise<void> {
const leaseId = core.getState('lease-id');
const endpoint = core.getState('endpoint');
const token = core.getState('token');

if (!leaseId || !endpoint || !token) {
if (!leaseId || !endpoint) {
core.info('No lease to release (claim may have failed)');
return;
}

// Re-mint the OIDC token rather than reusing the one minted in `main`.
// GitHub Actions runtime ID tokens are short-lived (≲10 min); a job
// that runs longer than the JWT's lifetime would 401 against an
// audience-validating server when the cached token is replayed here.
// Keep this in lockstep with `main.ts` writing `audience` to state.
const audience = core.getState('audience') || 'kobe-system';

let token: string;
try {
token = await getOidcToken(audience);
} catch (err) {
core.warning(
`Skipping lease release for ${leaseId}: failed to mint OIDC token: ${err instanceof Error ? err.message : String(err)}`
);
return;
}

core.info(`Releasing cluster (lease: ${leaseId})...`);
const client = new KobeClient(endpoint, token);
await client.releaseLease(leaseId);
Expand All @@ -27,4 +44,6 @@ async function post(): Promise<void> {
}
}

post();
if (require.main === module) {
post();
}
Loading