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
4 changes: 3 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```
Expand All @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
9 changes: 9 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -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));
});
293 changes: 171 additions & 122 deletions src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,161 +13,210 @@ import { resolveAsset } from './platform';
import { configureProxyFromEnv } from './proxy';
import { resolveFromCache, resolveVersion } from './version';

async function run(): Promise<void> {
// 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<void> {
// 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<void> {
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));
});
Loading
Loading