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
179 changes: 179 additions & 0 deletions src/test/unit/upload-action.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,9 @@ const TEST_CID = 'bafkreia5fn4rmshmb7cl7fufkpcw733b5anhuhydtqstnglpkzosqln5kq'
const inputsModulePath: string = '../../../upload-action/src/inputs.js'
const filecoinModulePath: string = '../../../upload-action/src/filecoin.js'
const commentsModulePath: string = '../../../upload-action/src/comments/comment.js'
const githubModulePath: string = '../../../upload-action/src/github.js'
const buildModulePath: string = '../../../upload-action/src/build.js'
const uploadModulePath: string = '../../../upload-action/src/upload.js'

interface ParseInputsModule {
parseInputs: (phase?: string) => {
Expand Down Expand Up @@ -79,6 +82,182 @@ interface CommentsModule {
}) => Promise<void>
}

interface GitHubModule {
evaluateUploadProvenance: (event: unknown, eventName?: string) => { trusted: boolean; reason?: string }
}

interface BuildModule {
runBuild: () => Promise<{ uploadStatus?: string; ipfsRootCid?: string }>
}

interface UploadModule {
runUpload: (context?: Record<string, unknown>) => Promise<{ uploadStatus?: string }>
}

describe('upload action event provenance', () => {
it('blocks fork pull requests', async () => {
const { evaluateUploadProvenance } = (await import(githubModulePath)) as GitHubModule

expect(
evaluateUploadProvenance(
{
pull_request: {
head: { repo: { full_name: 'contributor/filecoin-pin' } },
base: { repo: { full_name: 'filecoin-project/filecoin-pin' } },
},
},
'pull_request'
)
).toMatchObject({ trusted: false, reason: expect.stringContaining('fork') })
})

it('blocks fork pull_request_target events', async () => {
const { evaluateUploadProvenance } = (await import(githubModulePath)) as GitHubModule

expect(
evaluateUploadProvenance(
{
pull_request: {
head: { repo: { full_name: 'contributor/filecoin-pin' } },
base: { repo: { full_name: 'filecoin-project/filecoin-pin' } },
},
},
'pull_request_target'
)
).toMatchObject({ trusted: false, reason: expect.stringContaining('fork') })
})

it('blocks workflow runs originating from fork repositories', async () => {
const { evaluateUploadProvenance } = (await import(githubModulePath)) as GitHubModule

expect(
evaluateUploadProvenance(
{
workflow_run: {
head_repository: { full_name: 'contributor/filecoin-pin' },
repository: { full_name: 'filecoin-project/filecoin-pin' },
},
},
'workflow_run'
)
).toMatchObject({ trusted: false, reason: expect.stringContaining('fork') })
})

it('allows workflow runs originating from the same repository', async () => {
const { evaluateUploadProvenance } = (await import(githubModulePath)) as GitHubModule

expect(
evaluateUploadProvenance(
{
workflow_run: {
head_repository: { full_name: 'filecoin-project/filecoin-pin' },
repository: { full_name: 'filecoin-project/filecoin-pin' },
},
},
'workflow_run'
)
).toEqual({ trusted: true })
})

it('fails closed when workflow run repository provenance is incomplete', async () => {
const { evaluateUploadProvenance } = (await import(githubModulePath)) as GitHubModule

expect(
evaluateUploadProvenance(
{ workflow_run: { repository: { full_name: 'filecoin-project/filecoin-pin' } } },
'workflow_run'
)
).toMatchObject({ trusted: false, reason: expect.stringContaining('incomplete') })
})
})

describe('upload action fork enforcement', () => {
const originalEventName = process.env.GITHUB_EVENT_NAME
const originalEventPath = process.env.GITHUB_EVENT_PATH
const originalInputsJson = process.env.INPUTS_JSON
let eventPath: string | undefined

beforeEach(() => {
vi.resetModules()
})

afterEach(async () => {
if (eventPath) await rm(eventPath, { force: true })
eventPath = undefined

if (originalEventName == null) delete process.env.GITHUB_EVENT_NAME
else process.env.GITHUB_EVENT_NAME = originalEventName

if (originalEventPath == null) delete process.env.GITHUB_EVENT_PATH
else process.env.GITHUB_EVENT_PATH = originalEventPath

if (originalInputsJson == null) delete process.env.INPUTS_JSON
else process.env.INPUTS_JSON = originalInputsJson
})

it('marks fork workflow runs as blocked during the build phase', async () => {
eventPath = join(tmpdir(), `filecoin-pin-upload-event-${randomUUID()}.json`)
await writeFile(
eventPath,
JSON.stringify({
workflow_run: {
head_repository: { full_name: 'contributor/filecoin-pin' },
repository: { full_name: 'filecoin-project/filecoin-pin' },
},
})
)
process.env.GITHUB_EVENT_NAME = 'workflow_run'
process.env.GITHUB_EVENT_PATH = eventPath
process.env.INPUTS_JSON = JSON.stringify({ path: 'dist', network: 'mainnet' })

const unixfs = await import('filecoin-pin/core/unixfs')
vi.mocked(unixfs.createUnixfsCarBuilder).mockReturnValue({
buildCar: vi.fn().mockResolvedValue({
carPath: '/tmp/fork-content.car',
rootCid: TEST_CID,
size: 3,
}),
} as never)

const logSpies = [
vi.spyOn(console, 'log').mockImplementation(() => undefined),
vi.spyOn(console, 'error').mockImplementation(() => undefined),
]

try {
const { runBuild } = (await import(buildModulePath)) as BuildModule
await expect(runBuild()).resolves.toMatchObject({
uploadStatus: 'fork-pr-blocked',
ipfsRootCid: TEST_CID,
})
} finally {
for (const spy of logSpies) spy.mockRestore()
}
})

it('returns blocked status before requiring upload inputs or a wallet key', async () => {
delete process.env.INPUTS_JSON

const logSpies = [
vi.spyOn(console, 'log').mockImplementation(() => undefined),
vi.spyOn(console, 'warn').mockImplementation(() => undefined),
vi.spyOn(console, 'error').mockImplementation(() => undefined),
]

try {
const { runUpload } = (await import(uploadModulePath)) as UploadModule
await expect(
runUpload({
uploadStatus: 'fork-pr-blocked',
pr: { number: 6 },
})
).resolves.toMatchObject({ uploadStatus: 'fork-pr-blocked' })
} finally {
for (const spy of logSpies) spy.mockRestore()
}
})
})

describe('upload action inputs', () => {
const originalInputsJson = process.env.INPUTS_JSON

Expand Down
5 changes: 2 additions & 3 deletions upload-action/FLOW.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,14 +12,14 @@ This document explains how the action works internally and why each step exists.
- Ensures `cleanupSynapse()` runs on error via try/catch blocks.

2. **Build phase (`src/build.js`)**
- Validates event provenance. Direct PRs compare head/base repositories; `workflow_run` events compare `head_repository` with `repository` and fail closed when either identity is missing.
- Parses inputs via `parseInputs('compute')`. This validates `path` and `network` but does not require the wallet key.
- Detects fork PRs (by comparing head/base repo names). When detected, it records `uploadStatus=fork-pr-blocked` in the context and emits a notice that upload will be blocked.
- Resolves `path` against the workspace and generates a CAR using `createCarFile()`.
- Returns a context object containing the CAR file path, size, IPFS root CID, and additional metadata (run id, PR details, upload status).

3. **Upload phase (`src/upload.js`)**
- If the build phase marked the run as `fork-pr-blocked`, writes outputs, posts the explanatory PR comment when possible, and exits before parsing wallet inputs or touching Filecoin.
- Parses inputs via `parseInputs('upload')`. This enforces presence of `walletPrivateKey` and confirms `network`, `minRunwayDays`, and `maxBalance` rules.
- If the build context marked the run as `fork-pr-blocked`, the upload phase writes outputs, posts the explanatory PR comment, and exits without touching Filecoin.
- If `dryRun` is enabled, validates the CAR file exists, writes outputs, posts a PR comment, and exits without uploading.
- Validates that the CAR file still exists on disk.
- Calls `initializeSynapse({ walletPrivateKey, network })`, which selects the correct RPC endpoint (`RPC_URLS[network].websocket`) and bootstraps filecoin-pin.
Expand Down Expand Up @@ -63,4 +63,3 @@ The helper supports both environment-variable fallback (`INPUT_<NAME>`) and the
- Domain-specific failures throw `FilecoinPinError` with codes for insufficient funds, invalid private keys, and balance-limit violations.
- `handleError()` surfaces guidance tailored to the inputs (e.g., advising updates to `maxBalance`).
- `run.mjs` guarantees Synapse cleanup even when build or upload throws.

3 changes: 3 additions & 0 deletions upload-action/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,9 @@ When omitted, the SDK selects or creates a dataset automatically (recommended).

**⚠️ Fork PR Support Disabled**
- Only same-repo PRs and direct pushes are supported
- Fork PRs that flow through the documented two-workflow `workflow_run` pattern are also blocked, not just direct `pull_request` triggers
- Their artifacts are rejected before wallet input validation or upload
- `workflow_run` events with incomplete repository provenance are rejected rather than allowed
- This prevents non-maintainer PR actors from draining funds

## Versioning and Updates
Expand Down
17 changes: 9 additions & 8 deletions upload-action/src/build.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { promises as fs } from 'node:fs'
import pc from 'picocolors'
import pino from 'pino'
import { createCarFile, readCarFile } from './filecoin.js'
import { readEventPayload, updateCheck } from './github.js'
import { evaluateUploadProvenance, readEventPayload, updateCheck } from './github.js'
import { parseInputs, resolveContentPath } from './inputs.js'
import { formatSize } from './outputs.js'

Expand Down Expand Up @@ -42,14 +42,15 @@ export async function runBuild() {
}
}

const isForkPR = Boolean(
event?.pull_request && event.pull_request.head?.repo?.full_name !== event.pull_request.base?.repo?.full_name
)
const provenance = evaluateUploadProvenance(event, eventName)
const uploadStatus = provenance.trusted ? 'pending-upload' : 'fork-pr-blocked'

if (isForkPR) {
if (!provenance.trusted) {
console.log('━━━ Fork PR Detected - Preparing CAR but Blocking Upload ━━━')
console.error('::error::Fork PR support is currently disabled. Only same-repo workflows are supported.')
console.log('::notice::Preparing CAR file but upload will be blocked')
console.error(
`::error::Fork PR support is currently disabled. Only same-repo workflows are supported. Provenance: ${provenance.reason || 'untrusted source'}`
)
console.log('::notice::Preparing CAR file, but upload to Filecoin will be blocked')
}

const inputs = /** @type {ParsedInputs} */ (parseInputs('compute'))
Expand Down Expand Up @@ -81,7 +82,7 @@ export async function runBuild() {
ipfsRootCid,
carSize,
carPath,
uploadStatus: isForkPR ? 'fork-pr-blocked' : 'pending-upload',
uploadStatus,
contentPath,
buildRunId,
eventName,
Expand Down
58 changes: 58 additions & 0 deletions upload-action/src/github.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,64 @@ export async function readEventPayload() {
}
}

/**
* Decide whether the current GitHub event has trusted repository provenance.
*
* Pull request and workflow_run events can refer to code or artifacts produced
* by a fork. Missing repository identity on either event is treated as
* untrusted so privileged upload workflows fail closed.
*
* @param {any} event
* @param {string} [eventName]
* @returns {{ trusted: boolean, reason?: string }}
*/
export function evaluateUploadProvenance(event, eventName = process.env.GITHUB_EVENT_NAME || '') {
const isWorkflowRun = eventName === 'workflow_run' || event?.workflow_run != null
if (isWorkflowRun) {
const headRepository = event?.workflow_run?.head_repository?.full_name
const baseRepository = event?.workflow_run?.repository?.full_name

if (!headRepository || !baseRepository) {
return {
trusted: false,
reason: 'workflow_run repository provenance is incomplete',
}
}

if (headRepository !== baseRepository) {
return {
trusted: false,
reason: `workflow_run originated from fork ${headRepository}`,
}
}

return { trusted: true }
}

const isPullRequest =
eventName === 'pull_request' || eventName === 'pull_request_target' || event?.pull_request != null
if (isPullRequest) {
const headRepository = event?.pull_request?.head?.repo?.full_name
const baseRepository = event?.pull_request?.base?.repo?.full_name

if (!headRepository || !baseRepository) {
return {
trusted: false,
reason: 'pull request repository provenance is incomplete',
}
}

if (headRepository !== baseRepository) {
return {
trusted: false,
reason: `pull request originated from fork ${headRepository}`,
}
}
}

return { trusted: true }
}

/**
* Convert arbitrary numeric value to number when possible
* @param {unknown} value
Expand Down
38 changes: 20 additions & 18 deletions upload-action/src/upload.js
Original file line number Diff line number Diff line change
Expand Up @@ -31,25 +31,8 @@ export async function runUpload(buildContext = {}) {
summary: 'Preparing to upload to Filecoin...',
})

// Parse inputs (upload phase needs wallet)
/** @type {ParsedInputs} */
const inputs = parseInputs('upload')
const {
walletPrivateKey,
contentPath,
network: inputNetwork,
minStorageDays,
filecoinPayBalanceLimit,
withCDN,
providerIds,
dataSetIds,
dryRun,
} = inputs

/** @type {Partial<CombinedContext>} */
const context = { ...buildContext, contentPath }

context.dryRun = dryRun
const context = { ...buildContext }

const resolvedPr = await ensurePullRequestContext(context.pr)
if (resolvedPr) {
Expand All @@ -59,6 +42,7 @@ export async function runUpload(buildContext = {}) {
console.log('[context-debug] Loaded context from build phase:', context)

// Check if this was a fork PR that was blocked
// this gate is only safe because we share a process with runBuild; do not rehydrate uploadStatus from an external source
if (context.uploadStatus === 'fork-pr-blocked') {
console.log('━━━ Fork PR Upload Blocked ━━━')
console.log('::notice::Fork PR detected - content built but not uploaded to Filecoin, will comment on PR')
Expand Down Expand Up @@ -91,6 +75,24 @@ export async function runUpload(buildContext = {}) {
return context
}

// Parse inputs only after the entry point has accepted the event provenance.
/** @type {ParsedInputs} */
const inputs = parseInputs('upload')
const {
walletPrivateKey,
contentPath,
network: inputNetwork,
minStorageDays,
filecoinPayBalanceLimit,
withCDN,
providerIds,
dataSetIds,
dryRun,
} = inputs

context.contentPath = contentPath
context.dryRun = dryRun

if (!context.ipfsRootCid) {
throw new Error('No IPFS Root CID found in context. Build phase may have failed.')
}
Expand Down
Loading