diff --git a/CLAUDE.md b/CLAUDE.md index d795eea..b3225aa 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -19,7 +19,7 @@ pnpm run lint # biome check . (lint + format + import checks; lint:fix to pnpm run format # biome format --write . pnpm run test # vitest run pnpm run test:watch # vitest in watch mode -pnpm run build # ncc bundles src/main.ts -> dist/index.js +pnpm run build # ncc bundles src/index.ts -> dist/index.js pnpm exec vitest run tests/version.test.ts # run a single test file pnpm exec vitest run -t "resolves a semver range" # run tests matching a name ``` @@ -39,6 +39,8 @@ GitHub Actions runs the bundled `dist/index.js` directly (see `action.yml` → ` ## Architecture +`src/index.ts` is the ncc **entry point** — a thin wrapper that calls `run()` and routes a rejection to `core.setFailed`. It is deliberately separate from `src/main.ts` so `run()`/`writeFailureSummary` can be imported by unit tests without executing the pipeline on import (`tests/main.test.ts` relies on this). + `src/main.ts` is the orchestrator. Its `run()` executes a fixed 7-step pipeline, and the rest of `src/` is single-responsibility modules it calls: 1. **Resolve version** — `version.ts` turns a spec (`latest` / exact / semver range) into a concrete version. diff --git a/package.json b/package.json index e98daa3..9f15682 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,7 @@ }, "scripts": { "preinstall": "npx only-allow pnpm", - "build": "ncc build src/main.ts -o dist --source-map --license licenses.txt", + "build": "ncc build src/index.ts -o dist --source-map --license licenses.txt", "typecheck": "tsc --noEmit", "test": "vitest run", "test:coverage": "vitest run --coverage", diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..2be8f88 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,9 @@ +import * as core from '@actions/core'; +import { errorMessage } from './errors'; +import { run } from './main'; + +// Action entry point. Kept separate from main.ts so run()/writeFailureSummary +// can be imported by unit tests without triggering the pipeline on import. +run().catch((err: unknown) => { + core.setFailed(errorMessage(err)); +}); diff --git a/src/main.ts b/src/main.ts index fdb879c..6e52f4e 100644 --- a/src/main.ts +++ b/src/main.ts @@ -13,161 +13,210 @@ import { resolveAsset } from './platform'; import { configureProxyFromEnv } from './proxy'; import { resolveFromCache, resolveVersion } from './version'; -async function run(): Promise { - // Route fetch through the runner's proxy before any network call (#54). - configureProxyFromEnv(); - - const versionSpec = core.getInput('version') || 'latest'; - const token = core.getInput('repo-token') || process.env.GITHUB_TOKEN || ''; - const archOverride = core.getInput('architecture'); - const checkLatest = core.getBooleanInput('check-latest'); - const skipChecksum = core.getBooleanInput('skip-checksum'); - const retries = parseRetryInput(core.getInput('retries'), DEFAULT_RETRIES, 'retries'); - const retryBaseMs = parseRetryInput( - core.getInput('retry-base-ms'), - DEFAULT_RETRY_BASE_MS, - 'retry-base-ms', - ); +// State collected as the pipeline runs. `phase` names the current step so a +// failure can be reported in the job summary (NFR-5), not just the log. +interface RunSummary { + version: string; + asset: string; + source: string; + cache: string; + checksum: string; + path: string; + phase: string; +} +export async function run(): Promise { // Summary state collected during the run and written at the end (NFR-5). - const summary = { + // Declared first so a failure anywhere below — including proxy/input setup — + // is captured by the failure summary in the catch. + const summary: RunSummary = { version: '', asset: '', source: 'go-task/task GitHub Releases', cache: 'miss', checksum: 'n/a', path: '', + phase: 'reading configuration', }; - // Mask the token so it can never leak into logs/summaries (NFR-1). - if (token) { - core.setSecret(token); - } - - if (!token) { - core.warning( - 'No GitHub token available; API requests are unauthenticated and may hit ' + - // biome-ignore lint/suspicious/noTemplateCurlyInString: literal GitHub Actions expression shown to the user - 'rate limits. Pass "repo-token: ${{ github.token }}" to avoid this.', + try { + // Route fetch through the runner's proxy before any network call (#54). + configureProxyFromEnv(); + + const versionSpec = core.getInput('version') || 'latest'; + const token = core.getInput('repo-token') || process.env.GITHUB_TOKEN || ''; + const archOverride = core.getInput('architecture'); + const checkLatest = core.getBooleanInput('check-latest'); + const skipChecksum = core.getBooleanInput('skip-checksum'); + const retries = parseRetryInput(core.getInput('retries'), DEFAULT_RETRIES, 'retries'); + const retryBaseMs = parseRetryInput( + core.getInput('retry-base-ms'), + DEFAULT_RETRY_BASE_MS, + 'retry-base-ms', ); - } - const asset = resolveAsset(process.platform, process.arch, archOverride || undefined); - summary.asset = asset.assetName; - core.debug(`Target asset: ${asset.assetName}`); - - // 1. Resolve the concrete version (FR-1). For a range with check-latest=false, - // prefer a satisfying cached version so we need no network round-trip and - // stay resilient to GitHub outages/rate limits (FR-7 / NFR-3 / G1). - const api = createReleaseApi(token || undefined); - let version = resolveFromCache( - tc.findAllVersions(TOOL_NAME, asset.arch), - versionSpec, - checkLatest, - ); - if (version) { - core.info( - `Using cached go-task ${version} satisfying "${versionSpec}" (skipped network resolution).`, + // Mask the token so it can never leak into logs/summaries (NFR-1). + if (token) { + core.setSecret(token); + } + + if (!token) { + core.warning( + 'No GitHub token available; API requests are unauthenticated and may hit ' + + // biome-ignore lint/suspicious/noTemplateCurlyInString: literal GitHub Actions expression shown to the user + 'rate limits. Pass "repo-token: ${{ github.token }}" to avoid this.', + ); + } + + summary.phase = 'resolving asset'; + const asset = resolveAsset(process.platform, process.arch, archOverride || undefined); + summary.asset = asset.assetName; + core.debug(`Target asset: ${asset.assetName}`); + + summary.phase = 'resolving version'; + // 1. Resolve the concrete version (FR-1). For a range with check-latest=false, + // prefer a satisfying cached version so we need no network round-trip and + // stay resilient to GitHub outages/rate limits (FR-7 / NFR-3 / G1). + const api = createReleaseApi(token || undefined); + let version = resolveFromCache( + tc.findAllVersions(TOOL_NAME, asset.arch), + versionSpec, + checkLatest, ); - } else { - version = await withRetry(() => resolveVersion(api, versionSpec, checkLatest), { - retries, - baseMs: retryBaseMs, - name: 'resolve version', - }); - core.info(`Resolved go-task version: ${version}`); - } - summary.version = version; - - // 2. Tool-cache lookup (FR-7). - let toolDir = tc.find(TOOL_NAME, version, asset.arch); - const cacheHit = Boolean(toolDir); - summary.cache = cacheHit ? 'hit' : 'miss'; - - if (cacheHit) { - core.info(`Restored task ${version} from tool cache.`); - } else { - const tag = `v${version}`; - const url = releaseDownloadUrl(tag, asset.assetName); - - // 3. Download (authenticated + retry, FR-3/FR-4). - core.info(`Downloading ${url}`); - const archivePath = await withRetry(() => downloadAsset(url, token || undefined), { - retries, - baseMs: retryBaseMs, - name: 'download asset', - }); - - // 4. Checksum verification (FR-5). - if (skipChecksum) { - summary.checksum = 'skipped'; - core.warning('Checksum verification skipped (skip-checksum=true).'); - } else { - const expected = await withRetry( - () => fetchChecksum(tag, asset.assetName, token || undefined), - { - retries, - baseMs: retryBaseMs, - name: 'fetch checksums', - }, + if (version) { + core.info( + `Using cached go-task ${version} satisfying "${versionSpec}" (skipped network resolution).`, ); - if (!expected) { - throw new Error( - `Checksum for ${asset.assetName} not found in the release checksums file. ` + - 'Set skip-checksum=true to bypass (not recommended).', + } else { + version = await withRetry(() => resolveVersion(api, versionSpec, checkLatest), { + retries, + baseMs: retryBaseMs, + name: 'resolve version', + }); + core.info(`Resolved go-task version: ${version}`); + } + summary.version = version; + + summary.phase = 'checking tool cache'; + // 2. Tool-cache lookup (FR-7). + let toolDir = tc.find(TOOL_NAME, version, asset.arch); + const cacheHit = Boolean(toolDir); + summary.cache = cacheHit ? 'hit' : 'miss'; + + if (cacheHit) { + core.info(`Restored task ${version} from tool cache.`); + } else { + const tag = `v${version}`; + const url = releaseDownloadUrl(tag, asset.assetName); + + summary.phase = 'downloading release asset'; + // 3. Download (authenticated + retry, FR-3/FR-4). + core.info(`Downloading ${url}`); + const archivePath = await withRetry(() => downloadAsset(url, token || undefined), { + retries, + baseMs: retryBaseMs, + name: 'download asset', + }); + + summary.phase = 'verifying checksum'; + // 4. Checksum verification (FR-5). + if (skipChecksum) { + summary.checksum = 'skipped'; + core.warning('Checksum verification skipped (skip-checksum=true).'); + } else { + const expected = await withRetry( + () => fetchChecksum(tag, asset.assetName, token || undefined), + { + retries, + baseMs: retryBaseMs, + name: 'fetch checksums', + }, ); + if (!expected) { + throw new Error( + `Checksum for ${asset.assetName} not found in the release checksums file. ` + + 'Set skip-checksum=true to bypass (not recommended).', + ); + } + verifyChecksum(archivePath, expected); + summary.checksum = 'verified (SHA256)'; + core.info('Checksum verified (SHA256).'); } - verifyChecksum(archivePath, expected); - summary.checksum = 'verified (SHA256)'; - core.info('Checksum verified (SHA256).'); + + summary.phase = 'extracting & caching'; + // 5. Extract + cache (FR-6/FR-7). + const extractedDir = await extract(archivePath, asset.ext); + toolDir = await tc.cacheDir(extractedDir, TOOL_NAME, version, asset.arch); } - // 5. Extract + cache (FR-6/FR-7). - const extractedDir = await extract(archivePath, asset.ext); - toolDir = await tc.cacheDir(extractedDir, TOOL_NAME, version, asset.arch); - } + summary.phase = 'installing onto PATH'; + // 6. Ensure executable + expose on PATH (FR-6/FR-8). + const binPath = path.join(toolDir, asset.binaryName); + summary.path = binPath; + if (process.platform !== 'win32') { + try { + fs.chmodSync(binPath, 0o755); + } catch { + // best effort; archive is usually already executable + } + } + core.addPath(toolDir); - // 6. Ensure executable + expose on PATH (FR-6/FR-8). - const binPath = path.join(toolDir, asset.binaryName); - summary.path = binPath; - if (process.platform !== 'win32') { + // 7. Outputs (FR-9). + core.setOutput('version', version); + core.setOutput('task-path', binPath); + core.setOutput('cache-hit', String(cacheHit)); + core.info(`task ${version} is ready at ${binPath}`); + + // Emit a job summary after the fixed pipeline completes (NFR-5). + // Best-effort: a summary write failure must not fail the action. try { - fs.chmodSync(binPath, 0o755); - } catch { - // best effort; archive is usually already executable + await core.summary + .addHeading('Install Task') + .addTable([ + [ + { data: 'Item', header: true }, + { data: 'Value', header: true }, + ], + ['Version', summary.version], + ['Asset', summary.asset], + ['Source', summary.source], + ['Cache', summary.cache], + ['Checksum', summary.checksum], + ['Executable', summary.path], + ]) + .write(); + } catch (err) { + core.warning(`Failed to write job summary: ${errorMessage(err)}`); } + } catch (err) { + // The pipeline failed: record where and why in the job summary before the + // outer handler marks the step failed. Best-effort; never masks the error. + await writeFailureSummary(summary, err); + throw err; } - core.addPath(toolDir); - - // 7. Outputs (FR-9). - core.setOutput('version', version); - core.setOutput('task-path', binPath); - core.setOutput('cache-hit', String(cacheHit)); - core.info(`task ${version} is ready at ${binPath}`); +} - // Emit a job summary after the fixed pipeline completes (NFR-5). - // Best-effort: a summary write failure must not fail the action. +// Best-effort failure summary: name the phase that broke and the state gathered +// so far, so a failed run is diagnosable from the job summary, not just the log. +export async function writeFailureSummary(summary: RunSummary, err: unknown): Promise { try { await core.summary - .addHeading('Install Task') + .addHeading('Install Task — failed') .addTable([ [ { data: 'Item', header: true }, { data: 'Value', header: true }, ], - ['Version', summary.version], - ['Asset', summary.asset], - ['Source', summary.source], + ['Phase', summary.phase], + ['Error', errorMessage(err)], + ['Version', summary.version || '—'], + ['Asset', summary.asset || '—'], ['Cache', summary.cache], ['Checksum', summary.checksum], - ['Executable', summary.path], ]) .write(); - } catch (err) { - core.warning(`Failed to write job summary: ${errorMessage(err)}`); + } catch (summaryErr) { + core.warning(`Failed to write failure summary: ${errorMessage(summaryErr)}`); } } - -run().catch((err: unknown) => { - core.setFailed(errorMessage(err)); -}); diff --git a/tests/main.test.ts b/tests/main.test.ts new file mode 100644 index 0000000..7458830 --- /dev/null +++ b/tests/main.test.ts @@ -0,0 +1,83 @@ +import * as core from '@actions/core'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { writeFailureSummary } from '../src/main'; + +// The failure job summary (#78 / NFR-5) is only reachable when run() throws, a +// path the happy-path self-test never exercises. writeFailureSummary is pure +// apart from the @actions/core summary builder, so mock that boundary and +// assert what lands in the summary table. Importing ../src/main has no side +// effect because the pipeline entry point lives in src/index.ts. +vi.mock('@actions/core', () => { + // Chainable stub mirroring core.summary: addHeading()/addTable() return the + // builder, write() resolves. Reached via `core.summary` in the assertions. + const summary: Record> = {}; + summary.addHeading = vi.fn(() => summary); + summary.addTable = vi.fn(() => summary); + summary.write = vi.fn(async () => summary); + return { summary, warning: vi.fn() }; +}); + +// A RunSummary with every field populated; individual tests tweak fields inline. +const fullSummary = { + version: '3.51.1', + asset: 'task_linux_amd64.tar.gz', + source: 'go-task/task GitHub Releases', + cache: 'miss', + checksum: 'n/a', + path: '', + phase: 'downloading release asset', +}; + +// The rows array handed to core.summary.addTable on the most recent call. +function lastTableRows(): unknown[] { + const calls = vi.mocked(core.summary.addTable).mock.calls; + return calls[calls.length - 1][0] as unknown[]; +} + +describe('writeFailureSummary', () => { + afterEach(() => vi.clearAllMocks()); + + it('records the failed phase, error, and partial state in the summary table', async () => { + await writeFailureSummary({ ...fullSummary }, new Error('boom')); + + expect(core.summary.addHeading).toHaveBeenCalledWith('Install Task — failed'); + const rows = lastTableRows(); + expect(rows).toContainEqual(['Phase', 'downloading release asset']); + expect(rows).toContainEqual(['Error', 'boom']); + expect(rows).toContainEqual(['Version', '3.51.1']); + expect(rows).toContainEqual(['Asset', 'task_linux_amd64.tar.gz']); + expect(rows).toContainEqual(['Cache', 'miss']); + expect(rows).toContainEqual(['Checksum', 'n/a']); + expect(core.summary.write).toHaveBeenCalledOnce(); + }); + + it('shows a "—" placeholder for state not yet collected when the run fails early', async () => { + await writeFailureSummary( + { ...fullSummary, version: '', asset: '', phase: 'reading configuration' }, + new Error('bad input'), + ); + + const rows = lastTableRows(); + expect(rows).toContainEqual(['Phase', 'reading configuration']); + expect(rows).toContainEqual(['Version', '—']); + expect(rows).toContainEqual(['Asset', '—']); + }); + + it('coerces a non-Error thrown value into a string for the Error cell', async () => { + await writeFailureSummary({ ...fullSummary }, 'weird string failure'); + + expect(lastTableRows()).toContainEqual(['Error', 'weird string failure']); + }); + + it('never throws when the summary write itself fails, warning instead (best-effort)', async () => { + vi.mocked(core.summary.write).mockRejectedValueOnce(new Error('disk full')); + + await expect( + writeFailureSummary({ ...fullSummary }, new Error('boom')), + ).resolves.toBeUndefined(); + expect(core.warning).toHaveBeenCalledWith( + expect.stringContaining('Failed to write failure summary'), + ); + expect(core.warning).toHaveBeenCalledWith(expect.stringContaining('disk full')); + }); +});