Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
73a99dd
feat: add PublisherR2Config interface for Cloudflare R2 configuration
matos-ed Jan 8, 2026
c33b88b
feat: implement PublisherR2 class for Cloudflare R2 publishing
matos-ed Jan 8, 2026
a45d874
feat: add README for publisher-r2 with configuration and usage details
matos-ed Jan 8, 2026
cb66acb
feat: add package.json for Cloudflare R2 publisher
matos-ed Jan 8, 2026
7e92354
fix: update README to reflect correct R2 API credentials and configur…
matos-ed Jan 8, 2026
303d452
fix: clean up and add aws deps at package.json for Cloudflare R2 publ…
matos-ed Jan 8, 2026
f5ef7fc
fix: update PublisherR2 tests to use accessKeyId and secretAccessKey …
matos-ed Jan 8, 2026
4632412
fix: update PublisherR2Config to replace apiToken with accessKeyId an…
matos-ed Jan 8, 2026
544b39d
fix: refactor PublisherR2 to use S3Client for uploads and replace api…
matos-ed Jan 8, 2026
08ab035
fix: remove PublisherR2 implementation and related files
matos-ed Jan 9, 2026
a2bcb06
fix: update description and add mime-types dependency in package.json
matos-ed Jan 9, 2026
c943712
fix: update PublisherS3Config to include provider and accountId field…
matos-ed Jan 9, 2026
c1d800f
fix: enhance PublisherS3 to support R2 validation and content type ha…
matos-ed Jan 9, 2026
b9d51f7
fix: update README to include Cloudflare R2 support and enhance AWS S…
matos-ed Jan 9, 2026
3742fd3
test: add R2 provider tests for PublisherS3 with validation and publi…
matos-ed Jan 9, 2026
fcf89ea
Merge remote-tracking branch 'upstream/main'
matos-ed Jan 9, 2026
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
63 changes: 58 additions & 5 deletions packages/publisher/s3/README.md
Original file line number Diff line number Diff line change
@@ -1,20 +1,23 @@
## publisher-s3

`@electron-forge/publisher-s3` publishes all your artifacts to an Amazon S3 bucket where users will be able to download them.
`@electron-forge/publisher-s3` publishes all your artifacts to Amazon S3 or Cloudflare R2 buckets where users will be able to download them.

By default, all files are positioned at the following key:

${config.folder || appVersion}/${artifactName}
${config.folder || appName}/${platform}/${arch}/${artifactName}

Configuration options are documented in [PublisherS3Config](https://js.electronforge.io/interfaces/_electron_forge_publisher_s3.PublisherS3Config.html).

### AWS S3 Usage

```javascript title=forge.config.js
module.exports = {
// ...
publishers: [
{
name: '@electron-forge/publisher-s3',
config: {
provider: 's3', // optional, 's3' is the default
bucket: 'my-bucket',
public: true
}
Expand All @@ -23,8 +26,58 @@ module.exports = {
};
```

If you run publish twice with the same version on the same platform, it is possible for your old artifacts to get overwritten in S3. It is your responsibility to ensure that you don't overwrite your own releases.

### Authentication
#### Authentication

It is recommended to follow the [Amazon AWS guide](https://docs.aws.amazon.com/sdk-for-javascript/v3/developer-guide/setting-credentials-node.html) and set either a shared credentials guide or the proper environment variables. However, if that is not possible, the publisher config allows the setting of the accessKeyId and secretAccessKey configuration options.

### Cloudflare R2 Usage

```javascript title=forge.config.js
module.exports = {
// ...
publishers: [
{
name: '@electron-forge/publisher-s3',
config: {
provider: 'r2',
bucket: 'my-bucket',
accountId: 'your-cloudflare-account-id',
accessKeyId: 'your-r2-access-key-id',
secretAccessKey: 'your-r2-secret-access-key'
}
}
]
};
```

#### Authentication

This publisher uses Cloudflare R2's S3-compatible API. You need to provide R2 API credentials:

1. **Account ID**: Found in the Cloudflare dashboard URL or on the R2 overview page
2. **API Tokens**: Create R2 API tokens from the R2 dashboard:
- Go to R2 → Manage R2 API Tokens
- Click "Create API token"
- Select permissions (Read & Write)
- Copy the `Access Key ID` and `Secret Access Key`

#### Public Access

To make your artifacts publicly accessible, configure a custom domain for your R2 bucket in the Cloudflare dashboard under R2 → Settings → Public Access.

### Custom Key Resolver

You can customize the key for uploaded artifacts by providing a `keyResolver` function:

```javascript
config: {
bucket: 'my-bucket',
keyResolver: (fileName, platform, arch) => {
return `releases/${platform}/${arch}/${fileName}`;
}
}
```

### Notes

If you run publish twice with the same version on the same platform, it is possible for your old artifacts to get overwritten. It is your responsibility to ensure that you don't overwrite your own releases.
5 changes: 3 additions & 2 deletions packages/publisher/s3/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "@electron-forge/publisher-s3",
"version": "7.11.0",
"description": "S3 publisher for Electron Forge",
"description": "S3 and R2 publisher for Electron Forge",
"repository": "https://github.com/electron/forge",
"author": "Samuel Attard",
"license": "MIT",
Expand All @@ -16,7 +16,8 @@
"@aws-sdk/types": "^3.654.0",
"@electron-forge/publisher-static": "7.11.0",
"@electron-forge/shared-types": "7.11.0",
"debug": "^4.3.1"
"debug": "^4.3.1",
"mime-types": "^2.1.25"
},
"publishConfig": {
"access": "public"
Expand Down
182 changes: 182 additions & 0 deletions packages/publisher/s3/spec/PublisherS3.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,20 @@ import { PublisherS3, PublisherS3Config } from '../src/PublisherS3';
vi.mock('@aws-sdk/client-s3');
vi.mock('@aws-sdk/lib-storage');
vi.mock('node:fs');
vi.mock('mime-types', () => ({
default: {
lookup: vi.fn((filePath: string) => {
if (filePath.endsWith('.dmg')) return 'application/x-apple-diskimage';
if (filePath.endsWith('.exe')) return 'application/x-msdownload';
return 'application/octet-stream';
}),
},
lookup: vi.fn((filePath: string) => {
if (filePath.endsWith('.dmg')) return 'application/x-apple-diskimage';
if (filePath.endsWith('.exe')) return 'application/x-msdownload';
return 'application/octet-stream';
}),
}));

describe('PublisherS3', () => {
let publisher: PublisherS3;
Expand Down Expand Up @@ -494,4 +508,172 @@ describe('PublisherS3', () => {
expect(result).toBe('test_example.com_path_to_file');
});
});

describe('R2 Provider', () => {
let mockMakeResults: ForgeMakeResult[];
const mockForgeConfig = {} as ResolvedForgeConfig;
const mockSetStatusLine = vi.fn();

beforeEach(() => {
mockMakeResults = [
{
artifacts: [path.join(tmpDir, 'test-app-1.0.0.dmg')],
packageJSON: {
name: 'test-app',
version: '1.0.0',
},
platform: 'darwin',
arch: 'arm64',
},
];
});

it('should throw error when accountId is not provided for R2', async () => {
const config: PublisherS3Config = {
provider: 'r2',
bucket: 'test-bucket',
};
publisher = new PublisherS3(config);

await expect(
publisher.publish({
makeResults: mockMakeResults,
dir: tmpDir,
forgeConfig: mockForgeConfig,
setStatusLine: mockSetStatusLine,
}),
).rejects.toThrow(
'In order to publish to R2, you must set the "accountId" property',
);
});

it('should throw error when accessKeyId is not provided for R2', async () => {
const config: PublisherS3Config = {
provider: 'r2',
bucket: 'test-bucket',
accountId: 'test-account-id',
};
publisher = new PublisherS3(config);

await expect(
publisher.publish({
makeResults: mockMakeResults,
dir: tmpDir,
forgeConfig: mockForgeConfig,
setStatusLine: mockSetStatusLine,
}),
).rejects.toThrow(
'In order to publish to R2, you must set the "accessKeyId" property',
);
});

it('should throw error when secretAccessKey is not provided for R2', async () => {
const config: PublisherS3Config = {
provider: 'r2',
bucket: 'test-bucket',
accountId: 'test-account-id',
accessKeyId: 'test-access-key',
};
publisher = new PublisherS3(config);

await expect(
publisher.publish({
makeResults: mockMakeResults,
dir: tmpDir,
forgeConfig: mockForgeConfig,
setStatusLine: mockSetStatusLine,
}),
).rejects.toThrow(
'In order to publish to R2, you must set the "secretAccessKey" property',
);
});

it('should successfully publish to R2 with all required config', async () => {
const config: PublisherS3Config = {
provider: 'r2',
bucket: 'test-bucket',
accountId: 'test-account-id',
accessKeyId: 'test-access-key',
secretAccessKey: 'test-secret-key',
};
publisher = new PublisherS3(config);

await publisher.publish({
makeResults: mockMakeResults,
dir: tmpDir,
forgeConfig: mockForgeConfig,
setStatusLine: mockSetStatusLine,
});

// Verify S3Client was called with R2 endpoint
expect(S3Client).toHaveBeenCalledWith(
expect.objectContaining({
endpoint: 'https://test-account-id.r2.cloudflarestorage.com',
region: 'auto',
credentials: {
accessKeyId: 'test-access-key',
secretAccessKey: 'test-secret-key',
},
}),
);

// Verify Upload was called with ContentType for R2
expect(Upload).toHaveBeenCalledWith(
expect.objectContaining({
params: expect.objectContaining({
ContentType: expect.any(String),
}),
}),
);
});

it('should use custom endpoint for R2 if provided', async () => {
const config: PublisherS3Config = {
provider: 'r2',
bucket: 'test-bucket',
accountId: 'test-account-id',
accessKeyId: 'test-access-key',
secretAccessKey: 'test-secret-key',
endpoint: 'https://custom-r2-endpoint.com',
};
publisher = new PublisherS3(config);

await publisher.publish({
makeResults: mockMakeResults,
dir: tmpDir,
forgeConfig: mockForgeConfig,
setStatusLine: mockSetStatusLine,
});

// Verify S3Client was called with custom endpoint
expect(S3Client).toHaveBeenCalledWith(
expect.objectContaining({
endpoint: 'https://custom-r2-endpoint.com',
}),
);
});

it('should not set ACL for R2 provider', async () => {
const config: PublisherS3Config = {
provider: 'r2',
bucket: 'test-bucket',
accountId: 'test-account-id',
accessKeyId: 'test-access-key',
secretAccessKey: 'test-secret-key',
public: true, // This should be ignored for R2
};
publisher = new PublisherS3(config);

await publisher.publish({
makeResults: mockMakeResults,
dir: tmpDir,
forgeConfig: mockForgeConfig,
setStatusLine: mockSetStatusLine,
});

// Verify Upload params do not contain ACL
const uploadCall = vi.mocked(Upload).mock.calls[0][0];
expect(uploadCall.params).not.toHaveProperty('ACL');
});
});
});
Loading