Skip to content
Closed
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
25 changes: 18 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,10 @@

[![CI](https://github.com/backblaze-labs/b2-action/actions/workflows/ci.yml/badge.svg)](https://github.com/backblaze-labs/b2-action/actions/workflows/ci.yml) [![Release](https://github.com/backblaze-labs/b2-action/actions/workflows/release.yml/badge.svg)](https://github.com/backblaze-labs/b2-action/actions/workflows/release.yml) [![Marketplace](https://img.shields.io/github/v/release/backblaze-labs/b2-action?label=marketplace&color=red&logo=githubactions&logoColor=white)](https://github.com/marketplace/actions/backblaze-b2-cloud-storage-action) [![Latest release](https://img.shields.io/github/v/release/backblaze-labs/b2-action?display_name=tag&sort=semver&color=blue)](https://github.com/backblaze-labs/b2-action/releases/latest) [![License: MIT](https://img.shields.io/badge/license-MIT-yellow.svg)](./LICENSE) [![Coverage](https://img.shields.io/badge/coverage-100%25-brightgreen.svg)](./vitest.config.ts) [![Docs](https://img.shields.io/github/deployments/backblaze-labs/b2-action/github-pages?label=docs&logo=readthedocs&logoColor=white)](https://backblaze-labs.github.io/b2-action/)

The **official** Backblaze B2 GitHub Action. TypeScript-native, built on [`@backblaze-labs/b2-sdk`](https://github.com/backblaze-labs/b2-sdk-typescript). Thirteen verbs covering every B2 operation a CI workflow needs.
The **official** Backblaze B2 GitHub Action. TypeScript-native, built on [`@backblaze-labs/b2-sdk`](https://github.com/backblaze-labs/b2-sdk-typescript). Fourteen verbs covering every B2 operation a CI workflow needs.

- **Node 24 action.** No Docker. Sub-second cold start.
- **Thirteen verbs.** `upload`, `download`, `sync`, `copy`, `delete`, `list`, `hide`, `unhide`, `verify`, `presign`, `retention`, `head`, `purge`: pick via the `action` input.
- **Fourteen verbs.** `upload`, `download`, `sync`, `copy`, `delete`, `list`, `list-versions`, `hide`, `unhide`, `verify`, `presign`, `retention`, `head`, `purge`: pick via the `action` input.
- **Resumable multipart uploads** for any file size; streaming I/O so multi-GB payloads don't buffer in RAM.
- **Server-side everything.** `copy` (same-bucket or cross-bucket) and `delete` operations stay server-side; bytes never traverse the runner.
- **Server-side encryption.** SSE-B2 (managed) and SSE-C (customer key, base64).
Expand Down Expand Up @@ -60,7 +60,7 @@ The **official** Backblaze B2 GitHub Action. TypeScript-native, built on [`@back
destination: releases/${{ github.ref_name }}/app.tar.gz
```

For one self-contained example per verb (each is also a live integration test), see [.github/workflows/](./.github/workflows/README.md). Below is the full reference.
For self-contained workflow examples that also run as live integration tests, see [.github/workflows/](./.github/workflows/README.md). Below is the full reference.

---

Expand Down Expand Up @@ -94,6 +94,7 @@ Exact-version releases publish an attested `dist/index.js` asset for provenance
| `copy` | Server-side copy. Same bucket by default; cross-bucket with `source-bucket`. | `source`, `destination`, `bucket` |
| `delete` | Single file by name, or prefix-bulk via `b2_list_file_versions`. Supports `dry-run` and `bypass-governance` for governance-retained versions. | `source`, `bucket` |
| `list` | List files under a prefix; emits JSON for downstream steps. | `bucket` (and usually `source`) |
| `list-versions` | List every file version under a prefix, including historical versions and hide markers; emits `fileId`, `action`, `uploadTimestamp`, and `contentLength` for each version. | `bucket` (and usually `source`) |
| `hide` | Soft-delete via hide marker. Underlying data preserved until lifecycle. | `source`, `bucket` |
| `unhide` | Restore a hidden file by deleting its top hide marker. | `source`, `bucket` |
| `verify` | HEAD-request the remote whole-file SHA-1 and compare to `expected-sha1` or `destination` (local file). No body transfer; multipart objects cannot be verified when B2 does not expose a whole-file SHA-1. | `source`, `bucket`, plus one of `expected-sha1` / `destination` |
Expand Down Expand Up @@ -211,6 +212,14 @@ Exact-name `copy`, single-file `delete`, and `retention` operate only when the l
source: tmp/
max-results: 5000

- id: history
uses: backblaze-labs/b2-action@v1
with:
action: list-versions
bucket: my-bucket
source: tmp/
max-results: 5000

- uses: backblaze-labs/b2-action@v1
with:
action: delete
Expand Down Expand Up @@ -348,12 +357,12 @@ Set `bypass-governance: true` to shorten governance-mode retention or to remove

| Input | Required | Default | Description |
| --- | --- | --- | --- |
| `action` | yes | | One of 13: `upload`, `download`, `sync`, `copy`, `delete`, `presign`, `list`, `hide`, `unhide`, `verify`, `retention`, `head`, `purge` |
| `action` | yes | | One of 14: `upload`, `download`, `sync`, `copy`, `delete`, `presign`, `list`, `list-versions`, `hide`, `unhide`, `verify`, `retention`, `head`, `purge` |
| `application-key-id` | no\* | | B2 application key ID. Falls back to `$B2_APPLICATION_KEY_ID`. |
| `application-key` | no\* | | B2 application key. Falls back to `$B2_APPLICATION_KEY`. |
| `bucket` | yes | | Destination bucket name. |
| `source-bucket` | copy only | `bucket` | Source bucket for cross-bucket copy. |
| `source` | command-dependent | | Local path/glob (upload/sync up); B2 file name or prefix (everything else). Prefix downloads reject keys with empty, `.`, `..`, or control-character path segments. For whole-bucket purge, omit `source` or set `/` and set `allow-bucket-purge: true`. |
| `source` | command-dependent | | Local path/glob (upload/sync up); B2 file name or prefix (download/sync down/copy/delete/presign/list/list-versions/hide/unhide/verify/retention/head/purge). Prefix downloads reject keys with empty, `.`, `..`, or control-character path segments. For whole-bucket purge, omit `source` or set `/` and set `allow-bucket-purge: true`. |
| `destination` | command-dependent | | B2 file/prefix (upload/sync up/copy); local path (download/sync down/verify). Upload destinations are not normalized by the action; SDK/B2 key validation errors are surfaced rather than silently rewriting `/` characters. |
| `include` | no | | CSV of glob patterns to include (upload). |
| `exclude` | no | `.git/**` | CSV of glob patterns to exclude (upload). |
Expand All @@ -370,7 +379,7 @@ Set `bypass-governance: true` to shorten governance-mode retention or to remove
| `compare-mode` | no | `modtime` | Sync comparison: `modtime` \| `size` \| `none`. |
| `keep-mode` | no | `no-delete` | Sync deletion of orphans: `no-delete` \| `delete` \| `keep-days`. |
| `direction` | no | `auto` | Sync direction: `auto` \| `up` (local→B2) \| `down` (B2→local). |
| `max-results` | no | `1000` | `list` upper bound. Must be a positive decimal integer. Truncation is reported in the step summary. |
| `max-results` | no | `1000` | `list`, `list-versions`, and prefix `presign` upper bound. Must be a positive decimal integer. `list-versions` rejects values above 10000 before listing. Truncation is reported in the step summary. |
| `expected-sha1` | no | | `verify` literal 40-character hexadecimal SHA-1 to compare against; malformed values fail the action before comparison. Non-comparable remote SHA-1 headers such as `none` or `unverified:<sha1>` publish `verified=false` outputs before failing the step. |
| `retention-mode` | no | | `retention` mode: `compliance` \| `governance` \| `none`. |
| `retention-until` | no | | `retention` ISO 8601 expiry (required when mode is compliance/governance). |
Expand All @@ -391,7 +400,7 @@ Set `bypass-governance: true` to shorten governance-mode retention or to remove
| `files-uploaded` | upload / sync up | Count. |
| `files-downloaded` | download / sync down | Count. |
| `files-deleted` | delete / purge / sync | Count. |
| `files-listed` | list / prefix presign | Count returned (capped by `max-results`). |
| `files-listed` | list / list-versions / prefix presign | Count returned (capped by `max-results`). |
| `presigned-url` | presign | Time-limited download URL. Masked as a secret. This is the only structured output that carries the live presigned URL. |
| `verified` | verify | `true` / `false`. |
| `remote-sha1` | verify | Normalized comparable remote SHA-1, raw non-comparable B2 value such as `none` or `unverified:<sha1>`, or empty when B2 does not expose one. |
Expand All @@ -407,6 +416,8 @@ Set `bypass-governance: true` to shorten governance-mode retention or to remove

When truncated, `summary-json-notice` contains `{ "truncated": true, "reason": string, "totalCount": number, "previewCount": number, "previewOutput": "summary-json-preview" }`.

For `list-versions`, each `summary-json` entry represents one exact B2 file version and includes `fileName`, `fileId`, `action`, `uploadTimestamp`, `contentLength`, `contentSha1`, `contentType`, and `fileInfo`.

For every command, `summary-json` and `summary-json-preview` omit fields with credential-bearing names (`url`, fields ending in `url`, and fields containing `authorization`, `signature`, or `token`, ignoring case, underscores, and hyphens). If a future command needs to expose a similarly named non-secret value, it must project it to an explicit safe field name before emitting the summary.

For `presign`, `summary-json` and `summary-json-preview` contain only non-secret manifest fields such as `fileName` and `expiresAt`; the dedicated `presigned-url` output is the only structured output that contains a bearer URL. In prefix mode, only the first generated URL is exposed through `presigned-url`; bulk URL fan-out through `summary-json` is intentionally unsupported because those URLs are credentials. Generate or handle additional URLs in a trusted step that treats them as secrets.
Expand Down
133 changes: 131 additions & 2 deletions __tests__/commands/list-hide-verify.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,13 @@
import { rm, writeFile } from 'node:fs/promises'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import type { Bucket, FileVersion } from '@backblaze-labs/b2-sdk'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { hideCommand } from '../../src/commands/hide.ts'
import { listCommand } from '../../src/commands/list.ts'
import {
LIST_VERSIONS_MAX_RESULTS,
listCommand,
listVersionsCommand,
} from '../../src/commands/list.ts'
import { unhideCommand } from '../../src/commands/unhide.ts'
import { uploadCommand } from '../../src/commands/upload.ts'
import { verifyCommand } from '../../src/commands/verify.ts'
Expand All @@ -12,6 +17,29 @@ function inputs(action: Parameters<typeof makeInputs>[0], over: Record<string, u
return makeInputs(action, { bucket: 'gh-action-listhide', ...over })
}

type PaginateFileVersionsOptions = Parameters<Bucket['paginateFileVersions']>[0]

function fileVersion(over: Partial<FileVersion> = {}): FileVersion {
return {
accountId: 'account' as FileVersion['accountId'],
action: 'upload',
bucketId: 'bucket' as FileVersion['bucketId'],
contentLength: 1,
contentMd5: null,
contentSha1: 'sha1',
contentType: 'text/plain',
fileId: 'id-default' as FileVersion['fileId'],
fileInfo: {},
fileName: 'version.txt',
fileRetention: { isClientAuthorizedToRead: true, value: null },
legalHold: { isClientAuthorizedToRead: true, value: null },
replicationStatus: null,
serverSideEncryption: { mode: 'none' },
uploadTimestamp: 1,
...over,
}
}

describe('list command', () => {
let fx: TestFixture
beforeEach(async () => {
Expand Down Expand Up @@ -60,6 +88,107 @@ describe('list command', () => {
})
})

describe('list-versions command', () => {
let fx: TestFixture
beforeEach(async () => {
fx = await makeFixture('gh-action-list-versions')
})
afterEach(async () => {
await rm(fx.workDir, { recursive: true, force: true })
})

it('returns all versions under a prefix including hide markers', async () => {
await seedFile(fx, 'logs/a.txt', 'first')
await seedFile(fx, 'logs/a.txt', 'second')
await fx.bucket.hideFile('logs/a.txt')
await seedFile(fx, 'logs/b.txt', 'third')
await seedFile(fx, 'cache/c.txt', 'outside')

const result = await listVersionsCommand(
fx.bucket,
inputs('list-versions', { source: 'logs/' }),
)

expect(result.truncated).toBe(false)
expect(result.files).toHaveLength(4)
expect(result.files.every((f) => f.fileName.startsWith('logs/'))).toBe(true)
expect(result.files.map((f) => f.action)).toEqual(expect.arrayContaining(['hide', 'upload']))

const aVersions = result.files.filter((f) => f.fileName === 'logs/a.txt')
expect(aVersions).toHaveLength(3)
expect(aVersions.map((f) => f.action)).toEqual(expect.arrayContaining(['hide', 'upload']))
expect(
result.files.every(
(f) =>
f.fileId !== '' &&
Number.isInteger(f.uploadTimestamp) &&
Number.isInteger(f.contentLength),
),
).toBe(true)
})

it('caps versions at max-results and reports truncation', async () => {
for (let i = 0; i < 3; i++) {
await seedFile(fx, `history/f${i}.txt`, `body-${i}`)
}

const result = await listVersionsCommand(
fx.bucket,
inputs('list-versions', { source: 'history/', maxResults: 2 }),
)

expect(result.files).toHaveLength(2)
expect(result.truncated).toBe(true)
})

it('rejects oversized max-results before starting pagination', async () => {
const paginateFileVersions = vi.fn()
const bucket = { name: 'gh-action-list-versions', paginateFileVersions } as unknown as Bucket

await expect(
listVersionsCommand(
bucket,
inputs('list-versions', { maxResults: LIST_VERSIONS_MAX_RESULTS + 1 }),
),
).rejects.toThrow(`max-results for list-versions must be <= ${LIST_VERSIONS_MAX_RESULTS}`)

expect(paginateFileVersions).not.toHaveBeenCalled()
})

it('honors cancellation during a multi-page version scan', async () => {
const controller = new AbortController()
const abortReason = new Error('list cancelled')
const paginateFileVersions = vi.fn(async function* (options?: PaginateFileVersionsOptions) {
expect(options?.signal).toBe(controller.signal)
yield fileVersion({
fileName: 'history/page-1.txt',
fileId: 'id-page-1' as FileVersion['fileId'],
})
await Promise.resolve()
controller.abort(abortReason)
yield fileVersion({
fileName: 'history/page-2.txt',
fileId: 'id-page-2' as FileVersion['fileId'],
})
})
const bucket = { name: 'gh-action-list-versions', paginateFileVersions } as unknown as Bucket

await expect(
listVersionsCommand(
bucket,
inputs('list-versions', { source: 'history/', maxResults: 10 }),
controller.signal,
),
).rejects.toThrow('list cancelled')

expect(paginateFileVersions).toHaveBeenCalledWith({
prefix: 'history/',
pageSize: 11,
signal: controller.signal,
})
})
})

describe('hide + unhide commands', () => {
let fx: TestFixture
beforeEach(async () => {
Expand Down
13 changes: 12 additions & 1 deletion __tests__/main-output-contract.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ const EXPECTED_OUTPUT_KEYS = {
'summary-json-truncated',
],
list: ['file-count', 'files-listed', 'summary-json', 'summary-json-truncated'],
'list-versions': ['file-count', 'files-listed', 'summary-json', 'summary-json-truncated'],
hide: ['file-count', 'file-id', 'file-name', 'summary-json', 'summary-json-truncated'],
unhide: ['file-count', 'file-id', 'file-name', 'summary-json', 'summary-json-truncated'],
verify: [
Expand Down Expand Up @@ -154,7 +155,10 @@ function mockDispatcherPath(action: ActionName) {
vi.doMock('../src/commands/copy.ts', () => ({ copyCommand: commands.copyCommand }))
vi.doMock('../src/commands/delete.ts', () => ({ deleteCommand: commands.deleteCommand }))
vi.doMock('../src/commands/presign.ts', () => ({ presignCommand: commands.presignCommand }))
vi.doMock('../src/commands/list.ts', () => ({ listCommand: commands.listCommand }))
vi.doMock('../src/commands/list.ts', () => ({
listCommand: commands.listCommand,
listVersionsCommand: commands.listVersionsCommand,
}))
vi.doMock('../src/commands/hide.ts', () => ({ hideCommand: commands.hideCommand }))
vi.doMock('../src/commands/unhide.ts', () => ({ unhideCommand: commands.unhideCommand }))
vi.doMock('../src/commands/verify.ts', () => ({ verifyCommand: commands.verifyCommand }))
Expand All @@ -177,6 +181,7 @@ function commandMocks() {
deleteCommand: vi.fn(),
presignCommand: vi.fn(),
listCommand: vi.fn(),
listVersionsCommand: vi.fn(),
hideCommand: vi.fn(),
unhideCommand: vi.fn(),
verifyCommand: vi.fn(),
Expand Down Expand Up @@ -234,6 +239,12 @@ function applyCommandResult(commands: ReturnType<typeof commandMocks>, action: A
case 'list':
commands.listCommand.mockResolvedValue({ files: [file], truncated: false })
return
case 'list-versions':
commands.listVersionsCommand.mockResolvedValue({
files: [{ ...file, action: 'hide', contentLength: file.size }],
truncated: false,
})
return
case 'hide':
commands.hideCommand.mockResolvedValue(file)
return
Expand Down
Loading
Loading