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
90 changes: 85 additions & 5 deletions .github/workflows/self-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -137,11 +137,90 @@ jobs:
skip-checksum: "true"
- run: task --version

# Install with all traffic forced through an HTTP proxy, proving the action's
# fetch (proxy-unaware by default) is routed via the proxy env (#54).
proxy:
name: Install behind an HTTP proxy
needs: changes
if: ${{ needs.changes.outputs.src == 'true' }}
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 24
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm run build

- name: Start a local HTTP proxy (tinyproxy)
run: |
sudo apt-get update
sudo apt-get install -y tinyproxy
# apt may auto-start a tinyproxy service on :8888 (default config,
# logging to /var/log). Stop it so our instance owns the port and logs
# where we can read it.
sudo systemctl stop tinyproxy 2>/dev/null || sudo service tinyproxy stop 2>/dev/null || true
puser="$(id -un)"; pgroup="$(id -gn)"
{
echo "Port 8888"
echo "Listen 127.0.0.1"
echo "Timeout 600"
echo "Allow 127.0.0.1"
# Stay as the runner user so the log/pid files are writable (avoid the
# packaged tinyproxy user's privilege drop swallowing the log).
echo "User $puser"
echo "Group $pgroup"
echo 'LogFile "/tmp/tinyproxy.log"'
echo 'PidFile "/tmp/tinyproxy.pid"'
echo "LogLevel Connect"
echo "ConnectPort 443"
} > /tmp/tinyproxy.conf
tinyproxy -c /tmp/tinyproxy.conf
ok=
for _ in $(seq 1 10); do
if curl -fsS -x http://127.0.0.1:8888 -o /dev/null https://github.com; then ok=1; break; fi
sleep 1
done
[ -n "$ok" ] || { echo "::error::tinyproxy did not come up"; cat /tmp/tinyproxy.log 2>/dev/null || true; exit 1; }
# Confirm our instance (not a leftover service) owns the port and logs.
test -f /tmp/tinyproxy.log || { echo "::error::tinyproxy is not logging to /tmp/tinyproxy.log"; exit 1; }

- name: Run setup-task through the proxy
uses: ./
env:
HTTP_PROXY: http://127.0.0.1:8888
HTTPS_PROXY: http://127.0.0.1:8888
with:
# A range + check-latest forces version resolution via the GitHub API,
# which only the built-in fetch reaches (tool-cache already proxies and
# only hits github.com / the asset CDN) — so this exercises the new
# fetch proxying. check-latest avoids a cache hit short-circuiting the
# API call and making the api.github.com assertion below vacuous.
version: "3.x"
check-latest: "true"
repo-token: ${{ github.token }}
- run: task --version

- name: Assert the built-in fetch went through the proxy
run: |
echo '--- tinyproxy log ---'
cat /tmp/tinyproxy.log || true
# api.github.com is reached ONLY by the action's fetch (version
# resolution) — never by tool-cache or the readiness curl above. Its
# presence proves configureProxyFromEnv() routed fetch through the proxy;
# if fetch bypassed it (direct egress), this is absent and the job fails.
grep -Eiq "api\.github\.com" /tmp/tinyproxy.log

# Bridge job that always succeeds so branch protection can require it even
# when the real self-test jobs are skipped for markdown-only changes.
self-test-gate:
name: Self-test (gate)
needs: [changes, matrix, skip-checksum]
needs: [changes, matrix, skip-checksum, proxy]
if: always()
runs-on: ubuntu-latest
steps:
Expand All @@ -150,12 +229,13 @@ jobs:
changes="${{ needs.changes.result }}"
matrix="${{ needs.matrix.result }}"
skip="${{ needs.skip-checksum.result }}"
if [ "$changes" == "failure" ] || [ "$matrix" == "failure" ] || [ "$skip" == "failure" ]; then
echo "::error::Self-test failed (changes=$changes, matrix=$matrix, skip-checksum=$skip)"
proxy="${{ needs.proxy.result }}"
if [ "$changes" == "failure" ] || [ "$matrix" == "failure" ] || [ "$skip" == "failure" ] || [ "$proxy" == "failure" ]; then
echo "::error::Self-test failed (changes=$changes, matrix=$matrix, skip-checksum=$skip, proxy=$proxy)"
exit 1
fi
if [ "$changes" == "cancelled" ] || [ "$matrix" == "cancelled" ] || [ "$skip" == "cancelled" ]; then
echo "::error::Self-test was cancelled (changes=$changes, matrix=$matrix, skip-checksum=$skip)"
if [ "$changes" == "cancelled" ] || [ "$matrix" == "cancelled" ] || [ "$skip" == "cancelled" ] || [ "$proxy" == "cancelled" ]; then
echo "::error::Self-test was cancelled (changes=$changes, matrix=$matrix, skip-checksum=$skip, proxy=$proxy)"
exit 1
fi
echo "Self-test passed or skipped (markdown-only changes)."
4 changes: 3 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,9 @@ Key design seams to preserve when editing:
- **Retry vs. permanent (`download.ts` `withRetry` + `errors.ts` `PermanentError`)**: `withRetry` does exponential backoff, but `PermanentError` (and HTTP 404, including tool-cache's `httpStatusCode === 404`) bail out immediately with no retry. When adding a new failure mode, decide deliberately which side it falls on — checksum mismatches and 404s are permanent; everything network-ish is transient.
- **Platform mapping (`platform.ts`)**: maps Node `process.platform`/`process.arch` to go-task's asset naming (`task_<os>_<arch>.<ext>`) and validates against a hardcoded `SUPPORTED` matrix mirroring go-task's published assets. Update `SUPPORTED` if upstream adds/drops a target.
- **`constants.ts`** centralizes the upstream repo (`go-task/task`), the `task_checksums.txt` asset name, URL builders, and retry tuning.
- **Host allowlist (NFR-1, `url-guard.ts` + `github.ts` `secureFetch`)**: `secureFetch` follows redirects manually and validates *every* hop's host against an explicit set — `github.com` / `api.github.com` / `release-assets.githubusercontent.com` / `objects.githubusercontent.com` (`url-guard.ts` `isAllowedHost`); an untrusted host (or a malformed/missing redirect `Location`) is a `PermanentError` (never retried). It's an explicit set, not a `.githubusercontent.com` suffix, because `raw.`/`gist.githubusercontent.com` serve arbitrary user content. GitHub renamed the asset CDN (`objects.githubusercontent.com` → `release-assets.githubusercontent.com`) once — both are listed; if it renames again the download fails loudly until you add the new host. `Authorization` is dropped on cross-origin redirects so the token never leaks off `github.com`. Because tool-cache's downloader follows redirects opaquely, `download.ts` pre-flights the binary URL via `assertRedirectTrusted`; a network/proxy failure there (global `fetch` ignores runner proxy settings) falls through to tool-cache, but a `PermanentError` blocks.
- **Host allowlist (NFR-1, `url-guard.ts` + `github.ts` `secureFetch`)**: `secureFetch` follows redirects manually and validates *every* hop's host against an explicit set — `github.com` / `api.github.com` / `release-assets.githubusercontent.com` / `objects.githubusercontent.com` (`url-guard.ts` `isAllowedHost`); an untrusted host (or a malformed/missing redirect `Location`) is a `PermanentError` (never retried). It's an explicit set, not a `.githubusercontent.com` suffix, because `raw.`/`gist.githubusercontent.com` serve arbitrary user content. GitHub renamed the asset CDN (`objects.githubusercontent.com` → `release-assets.githubusercontent.com`) once — both are listed; if it renames again the download fails loudly until you add the new host. `Authorization` is dropped on cross-origin redirects so the token never leaks off `github.com`. Because tool-cache's downloader follows redirects opaquely, `download.ts` pre-flights the binary URL via `assertRedirectTrusted`; a transient network/proxy failure there falls through to tool-cache, but a `PermanentError` blocks.
- **Fetch shim (`fetch.ts`)**: re-exports `fetch` from the npm `undici` package with the standard string-URL signature. `github.ts` imports from here instead of using Node's built-in fetch so the `EnvHttpProxyAgent` dispatcher configured by `proxy.ts` is guaranteed to be used across Node versions. Tests mock `./fetch` rather than `globalThis.fetch`.
- **Proxy (`proxy.ts`)**: `configureProxyFromEnv()` runs first in `run()` and, when a proxy env var is set, installs undici's `EnvHttpProxyAgent` as the global dispatcher so `fetch.ts` honours `HTTP_PROXY`/`HTTPS_PROXY`/`NO_PROXY`. Because `fetch.ts` imports from the same npm `undici` instance, the dispatcher is picked up reliably on Node 24 and future Node versions; using Node's built-in fetch would break when the runtime's undici symbol version diverges from the package's. Verified by the `proxy` self-test job (tinyproxy). No-op without a proxy env.

## Conventions

Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ Built as a modern, reliable alternative to `arduino/setup-task`:
- 🚫 **Host-pinned** — downloads and their redirects are restricted to GitHub hosts; a redirect to any other host is refused, and the token is never forwarded off `github.com`
- ♻️ **Cached** — uses the runner tool cache to avoid re-downloading
- 🔁 **Resilient** — retries transient network failures with exponential backoff
- 🌐 **Proxy-aware** — honours `HTTP_PROXY` / `HTTPS_PROXY` / `NO_PROXY` for installs behind a corporate proxy or on self-hosted runners
- 🧩 **Drop-in** — `version` / `repo-token` inputs are compatible with `arduino/setup-task`

## Usage
Expand Down
3 changes: 3 additions & 0 deletions TODO.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,8 @@
| [#41](https://github.com/yk-lab/setup-task/issues/41) | `[test]` platform.test.ts を §9 全 os/arch 組合せに拡張 | `P3: low` | `test` |
| [#44](https://github.com/yk-lab/setup-task/issues/44) | `[enhancement]` フォールバックソースをサポート(FR-11) | `P3: low` | `enhancement` |
| [#45](https://github.com/yk-lab/setup-task/issues/45) | `[chore]` Biome 導入を評価 | `P3: low` | `chore` |
| [#55](https://github.com/yk-lab/setup-task/issues/55) | `[documentation]` テスト規約(CLAUDE.md)を stub-fetch unit test の実態に合わせる | `P3: low` | `documentation` | |
| [#56](https://github.com/yk-lab/setup-task/issues/56) | `[security]` 取得ボディにサイズ上限/タイムアウトを設ける | `P3: low` | `security` | |

### 未 Issue 化

Expand All @@ -77,3 +79,4 @@ v1.0.0 実装中に作成・解決済みの Issue(参考)。
| [#40](https://github.com/yk-lab/setup-task/issues/40) | `[enhancement]` リトライ回数・間隔を input 化(FR-4) | #51 |
| [#43](https://github.com/yk-lab/setup-task/issues/43) | `[chore]` checksum 改ざんテストの重複ファイルを統合 | #52 |
| [#42](https://github.com/yk-lab/setup-task/issues/42) | `[security]` ダウンロード先ホスト/リダイレクト先を検証(NFR-1) | #53 |
| [#54](https://github.com/yk-lab/setup-task/issues/54) | `[enhancement]` HTTP(S) proxy 環境で全 fetch を proxy 経由にする | #57 |
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,8 @@
"dependencies": {
"@actions/core": "^3.0.1",
"@actions/tool-cache": "^4.0.0",
"semver": "^7.6.3"
"semver": "^7.6.3",
"undici": "^6.27.0"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
Expand Down
3 changes: 3 additions & 0 deletions pnpm-lock.yaml

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

7 changes: 3 additions & 4 deletions src/download.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,10 +58,9 @@ export async function withRetry<T>(fn: () => Promise<T>, opts: RetryOptions = {}
*/
export async function downloadAsset(url: string, token?: string): Promise<string> {
// Vet the redirect chain's hosts before tool-cache (which follows redirects
// opaquely) fetches the body (NFR-1). The preflight's global fetch ignores
// runner proxy settings, so only an untrusted host (PermanentError) is fatal;
// a network/proxy failure falls through to the proxy-capable, checksum-verified
// tool-cache download.
// opaquely) fetches the body (NFR-1). Only an untrusted host (PermanentError)
// is fatal; a transient network failure in the preflight falls through to the
// checksum-verified tool-cache download rather than blocking it.
try {
await assertRedirectTrusted(url, token);
} catch (err) {
Expand Down
11 changes: 11 additions & 0 deletions src/fetch.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { fetch as undiciFetch } from 'undici';

/**
* Re-export undici's fetch with a string-URL signature.
*
* This avoids type mismatches between undici's own Request/Response types and
* the global DOM types exposed by Node, while still using the npm undici
* implementation so the dispatcher configured by `proxy.ts` is guaranteed to
* be picked up across Node versions.
*/
export const fetch = undiciFetch as (input: string, init?: RequestInit) => Promise<Response>;
1 change: 1 addition & 0 deletions src/github.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import * as semver from 'semver';
import { GITHUB_API, MAX_REDIRECTS, REPO_NAME, REPO_OWNER } from './constants';
import { PermanentError } from './errors';
import { fetch } from './fetch';
import { assertAllowedHost } from './url-guard';
import type { ReleaseApi } from './version';

Expand Down
4 changes: 4 additions & 0 deletions src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,13 @@ import { errorMessage } from './errors';
import { createReleaseApi } from './github';
import { extract } from './install';
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');
Expand Down
30 changes: 30 additions & 0 deletions src/proxy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import * as core from '@actions/core';
import { EnvHttpProxyAgent, setGlobalDispatcher } from 'undici';

/**
* Route the action's `fetch()` through the runner's proxy when one is set.
* Node's built-in fetch ignores `HTTP_PROXY`/`HTTPS_PROXY`/`NO_PROXY`; undici's
* `EnvHttpProxyAgent` reads them and `setGlobalDispatcher` applies it (picked up
* by `fetch.ts`, which uses the same undici instance). No-op without a proxy,
* so direct-egress runners are unchanged (#54).
*/
export function configureProxyFromEnv(): void {
// First non-empty proxy var. A plain truthiness check (not `??`) so an empty
// var (HTTP_PROXY="", a common "unset") doesn't shadow a real proxy in a
// later variable.
const source = ['HTTP_PROXY', 'HTTPS_PROXY', 'http_proxy', 'https_proxy'].find(
(name) => process.env[name],
);
if (!source) {
return;
}
try {
// EnvHttpProxyAgent's constructor throws on a malformed proxy URL.
setGlobalDispatcher(new EnvHttpProxyAgent());
} catch (err) {
// Name the offending variable, never its value — a proxy URL can embed
// credentials that must not land in the Actions log.
throw new Error(`Invalid proxy URL in ${source}.`, { cause: err });
}
core.info('Detected a proxy in the environment; routing requests through it.');
}
14 changes: 10 additions & 4 deletions tests/checksum.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,14 @@ import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { afterAll, afterEach, describe, expect, it, vi } from 'vitest';
import { fetch } from '../src/fetch';
import { fetchChecksum, parseChecksums, sha256File, verifyChecksum } from '../src/checksum';
import { PermanentError } from '../src/errors';

vi.mock('../src/fetch', () => ({ fetch: vi.fn() }));

const mockedFetch = vi.mocked(fetch);

describe('parseChecksums', () => {
const sample = [
'd1c2f0...not64hex skip-this-malformed-line',
Expand Down Expand Up @@ -57,7 +62,9 @@ describe('sha256File / verifyChecksum', () => {
// (FR-5, 要求仕様書 §10.3). The action hard-codes its download source (CON-2),
// so the published checksums file is injected via a fetch stub.
describe('fetchChecksum + verifyChecksum: tamper detection', () => {
afterEach(() => vi.unstubAllGlobals());
afterEach(() => {
mockedFetch.mockReset();
});

const ASSET = 'task_linux_amd64.tar.gz';
const genuine = Buffer.from('genuine task archive payload');
Expand All @@ -68,9 +75,8 @@ describe('fetchChecksum + verifyChecksum: tamper detection', () => {
].join('\n');

function stubChecksums(body: string): void {
vi.stubGlobal(
'fetch',
vi.fn(async () => new Response(body, { status: 200, headers: { 'content-type': 'text/plain' } })),
mockedFetch.mockImplementation(
async () => new Response(body, { status: 200, headers: { 'content-type': 'text/plain' } }),
);
}

Expand Down
5 changes: 3 additions & 2 deletions tests/download.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,8 +96,9 @@ describe('downloadAsset (redirect preflight wiring, NFR-1)', () => {
});

it('falls through to tool-cache on a network/proxy fetch failure (TypeError)', async () => {
// A failed fetch() throws TypeError; global fetch ignores proxy settings, so
// this must not block the proxy-capable tool-cache download.
// A failed preflight fetch() throws TypeError; only a PermanentError (untrusted
// host) should block the download. Transient network/proxy failures fall through
// to tool-cache so the action still has a chance to fetch the binary.
vi.mocked(assertRedirectTrusted).mockRejectedValueOnce(new TypeError('fetch failed'));
await expect(downloadAsset(ASSET_URL, 'tok')).resolves.toBe('/tmp/task-archive');
expect(tc.downloadTool).toHaveBeenCalledOnce();
Expand Down
Loading
Loading