Skip to content
Open
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
1 change: 1 addition & 0 deletions TARGETS.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ Each row is a **target adapter** — one plugin in `packages/targets/*`. Status
| `pkg-winget` | Microsoft winget | 🚧 |
| `pkg-scoop` | Scoop bucket | 🚧 |
| `pkg-apt` | apt repo / PPA | 🚧 |
| `pkg-dnf` | dnf repo / Fedora COPR | ✅ |
| `pkg-snap` | Snapcraft | 🚧 |
| `pkg-flatpak` | Flathub | ✅ |
| `pkg-aur` | Arch User Repo | 🚧 |
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/src/adapter-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -162,7 +162,7 @@ export const CATEGORIES: readonly AdapterCategory[] = [
id: 'targets',
pkgPrefix: '@profullstack/sh1pt-target',
description: 'Distribution targets — stores, registries, CDNs, deploy platforms',
adapters: ['browser-chrome', 'browser-edge', 'browser-firefox', 'browser-safari', 'chat-discord', 'chat-signal', 'chat-slack', 'chat-telegram', 'chat-whatsapp', 'console-steam', 'deploy-coolify', 'deploy-denodeploy', 'deploy-firebase', 'deploy-fly', 'deploy-lambda', 'deploy-netlify', 'deploy-railway', 'deploy-render', 'deploy-vercel', 'deploy-workers', 'desktop-linux', 'desktop-mac', 'desktop-steamos', 'desktop-win', 'exe-dev', 'mobile-android', 'mobile-expo', 'mobile-ios', 'payment-adyen', 'payment-coinpay', 'payment-paypal', 'payment-square', 'payment-stripe', 'pkg-apt', 'pkg-aube', 'pkg-aur', 'pkg-cdn', 'pkg-deno', 'pkg-docker', 'pkg-fdroid', 'pkg-flatpak', 'pkg-ghpackages', 'pkg-homebrew', 'pkg-jsr', 'pkg-nix', 'pkg-npm', 'pkg-perry', 'pkg-scoop', 'pkg-snap', 'pkg-winget', 'plugin-jetbrains', 'plugin-vscode', 'qa-geisterhand', 'sdk-pypi', 'tv-androidtv', 'tv-firetv', 'tv-roku', 'tv-tvos', 'tv-webos', 'web-static', 'xr-meta-quest', 'xr-pico', 'xr-sidequest', 'xr-steamvr', 'xr-visionos', 'xr-webxr'],
adapters: ['browser-chrome', 'browser-edge', 'browser-firefox', 'browser-safari', 'chat-discord', 'chat-signal', 'chat-slack', 'chat-telegram', 'chat-whatsapp', 'console-steam', 'deploy-coolify', 'deploy-denodeploy', 'deploy-firebase', 'deploy-fly', 'deploy-lambda', 'deploy-netlify', 'deploy-railway', 'deploy-render', 'deploy-vercel', 'deploy-workers', 'desktop-linux', 'desktop-mac', 'desktop-steamos', 'desktop-win', 'exe-dev', 'mobile-android', 'mobile-expo', 'mobile-ios', 'payment-adyen', 'payment-coinpay', 'payment-paypal', 'payment-square', 'payment-stripe', 'pkg-apt', 'pkg-aube', 'pkg-aur', 'pkg-cdn', 'pkg-deno', 'pkg-dnf', 'pkg-docker', 'pkg-fdroid', 'pkg-flatpak', 'pkg-ghpackages', 'pkg-homebrew', 'pkg-jsr', 'pkg-nix', 'pkg-npm', 'pkg-perry', 'pkg-scoop', 'pkg-snap', 'pkg-winget', 'plugin-jetbrains', 'plugin-vscode', 'qa-geisterhand', 'sdk-pypi', 'tv-androidtv', 'tv-firetv', 'tv-roku', 'tv-tvos', 'tv-webos', 'web-static', 'xr-meta-quest', 'xr-pico', 'xr-sidequest', 'xr-steamvr', 'xr-visionos', 'xr-webxr'],
},
{
id: 'vcs',
Expand Down
43 changes: 43 additions & 0 deletions packages/targets/pkg-dnf/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
# dnf repo / Fedora COPR

Provides the dnf repo / Fedora COPR sh1pt target adapter for build, publish, or deployment workflows.

## What it does

- Registers a target surface that sh1pt can build, publish, or deploy to.
- Provides adapter metadata and lifecycle hooks used by the CLI.
- Includes setup guidance for required credentials or provider configuration.
- Implements build behavior used by sh1pt target workflows.

## Package

- Name: `@profullstack/sh1pt-target-pkg-dnf`
- Path: `packages/targets/pkg-dnf`
- Adapter ID: `pkg-dnf`
- Homepage: https://sh1pt.com

## Scripts

- `build`: `tsc -p tsconfig.json`
- `prepublishOnly`: `pnpm build`
- `typecheck`: `tsc -p tsconfig.json --noEmit`

## Usage

```bash
pnpm add @profullstack/sh1pt-target-pkg-dnf
```

## Development

```bash
pnpm --filter @profullstack/sh1pt-target-pkg-dnf typecheck
```

Run tests from the repository root when this module includes a test file:

```bash
pnpm vitest run packages/targets/pkg-dnf/src/index.test.ts
```

<!-- Generated by scripts/gen-module-readmes.mjs -->
17 changes: 17 additions & 0 deletions packages/targets/pkg-dnf/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
{
"name": "@profullstack/sh1pt-target-pkg-dnf",
"version": "0.1.15",
"type": "module",
"main": "./src/index.ts",
"scripts": {
"build": "tsc -p tsconfig.json",
"typecheck": "tsc -p tsconfig.json --noEmit",
"prepublishOnly": "pnpm build"
},
"dependencies": { "@profullstack/sh1pt-core": "workspace:*" },
"license": "MIT",
"repository": { "type": "git", "url": "git+https://github.com/profullstack/sh1pt.git", "directory": "packages/targets/pkg-dnf" },
"homepage": "https://sh1pt.com",
"bugs": "https://github.com/profullstack/sh1pt/issues",
"files": ["dist"]
}
81 changes: 81 additions & 0 deletions packages/targets/pkg-dnf/src/index.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import { fakeBuildContext, fakeShipContext, smokeTest } from '@profullstack/sh1pt-core/testing';
import { mkdtemp, readFile, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, describe, expect, it } from 'vitest';
import adapter from './index.js';

smokeTest(adapter, { idPrefix: 'pkg', requireKind: true });

const tempDirs: string[] = [];

afterEach(async () => {
await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true })));
});

describe('dnf / RPM spec generation', () => {
it('writes an RPM spec and a self-hosted .repo file', async () => {
const outDir = await mkdtemp(join(tmpdir(), 'sh1pt-dnf-'));
tempDirs.push(outDir);

const result = await adapter.build(fakeBuildContext({ outDir, version: 'v1.5.0-rc1' }) as any, {
packageName: 'myapp',
summary: 'My App',
license: 'Apache-2.0',
url: 'https://example.com/myapp',
requires: ['glibc', 'openssl'],
});

const specPath = join(outDir, 'rpm', 'myapp.spec');
expect(result.artifact).toBe(specPath);

const spec = await readFile(specPath, 'utf-8');
expect(spec).toContain('Name: myapp');
// 'v' stripped and '-' replaced so the RPM version is valid.
expect(spec).toContain('Version: 1.5.0.rc1');
expect(spec).toContain('Release: 1%{?dist}');
expect(spec).toContain('License: Apache-2.0');
expect(spec).toContain('Requires: glibc');
expect(spec).toContain('Requires: openssl');
expect(spec).toContain('%description');
expect(spec).toContain('%changelog');
// %changelog header must be valid RPM form: "* Wed May 29 2026 Name <email> - ver-rel"
expect(spec).toMatch(/^\* (Sun|Mon|Tue|Wed|Thu|Fri|Sat) [A-Z][a-z]{2} \d{2} \d{4} sh1pt <release@sh1pt\.com> - .+-.+$/m);

const repo = await readFile(join(outDir, 'rpm', 'myapp.repo'), 'utf-8');
expect(repo).toContain('[myapp]');
expect(repo).toContain('gpgcheck=1');
expect(repo).toContain('dnf.sh1pt.com');
});

it('targets Fedora COPR when coprProject is set', async () => {
const outDir = await mkdtemp(join(tmpdir(), 'sh1pt-dnf-'));
tempDirs.push(outDir);

const result = await adapter.build(fakeBuildContext({ outDir, version: '2.0.0' }) as any, {
packageName: 'myapp',
coprProject: 'acme/myapp',
});

const repo = await readFile(join(outDir, 'rpm', 'myapp.repo'), 'utf-8');
expect(repo).toContain('download.copr.fedorainfracloud.org/results/acme/myapp');
// gpgkey must point at the COPR project's pubkey, not the self-hosted fallback.
expect(repo).toContain('gpgkey=https://download.copr.fedorainfracloud.org/results/acme/myapp/pubkey.gpg');

// COPR publish path uses copr-cli with the documented `status <buildId>` form.
expect(result.meta?.commands).toContain('copr-cli status <buildId>');
expect((result.meta?.commands as string[])?.some((c: string) => c.startsWith('copr-cli build acme/myapp'))).toBe(true);
});

it('keeps dry-run shipping side-effect free and surfaces build commands', async () => {
const ship = await adapter.ship(fakeShipContext({ version: '1.5.0', dryRun: true }) as any, {
packageName: 'myapp',
});
expect(ship.id).toBe('dry-run');
expect((ship.meta?.commands as string[])?.some((c: string) => c.startsWith('rpmbuild'))).toBe(true);

await expect(adapter.ship(fakeShipContext({ version: '1.5.0', dryRun: false }) as any, {
packageName: 'myapp',
})).rejects.toThrow(/not implemented/i);
});
});
159 changes: 159 additions & 0 deletions packages/targets/pkg-dnf/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
import { defineTarget, manualSetup } from '@profullstack/sh1pt-core';
import { mkdir, writeFile } from 'node:fs/promises';
import { dirname, join } from 'node:path';

type RpmArch = 'x86_64' | 'aarch64' | 'noarch';

interface Config {
packageName: string; // e.g. "myapp"
summary?: string;
description?: string;
license?: string; // SPDX, e.g. "MIT"
url?: string; // homepage
release?: string; // RPM release field, default "1"
arch?: RpmArch[]; // default ["x86_64"]
requires?: string[]; // runtime Requires
sourceUrl?: string; // Source0 tarball URL
coprProject?: string; // "owner/project" — publish via Fedora COPR
repoBaseUrl?: string; // self-hosted dnf repo base URL (for the .repo file)
}

// RPM versions may not contain '-' (it separates version from release).
function rpmVersion(version: string): string {
return version.replace(/^v/, '').replace(/-/g, '.');
}

function arches(config: Config): RpmArch[] {
return config.arch ?? ['x86_64'];
}

const WEEKDAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];

// RPM %changelog requires the exact header form: "* Wed May 29 2026 Name <email> - ver-rel".
// Built locale-independently so the .spec is accepted by rpmbuild.
function rpmChangelogDate(d: Date): string {
return `${WEEKDAYS[d.getUTCDay()]} ${MONTHS[d.getUTCMonth()]} ${String(d.getUTCDate()).padStart(2, '0')} ${d.getUTCFullYear()}`;
}

function renderSpec(ctx: { version: string }, config: Config): string {
const version = rpmVersion(ctx.version);
const release = config.release ?? '1';
const summary = config.summary ?? `Release package for ${config.packageName}`;
const license = config.license ?? 'MIT';
const buildArch = arches(config).includes('noarch') ? 'noarch' : undefined;
const lines = [
`Name: ${config.packageName}`,
`Version: ${version}`,
`Release: ${release}%{?dist}`,
`Summary: ${summary}`,
`License: ${license}`,
];
if (config.url) lines.push(`URL: ${config.url}`);
if (config.sourceUrl) lines.push(`Source0: ${config.sourceUrl}`);
if (buildArch) lines.push(`BuildArch: ${buildArch}`);
for (const req of config.requires ?? []) lines.push(`Requires: ${req}`);
lines.push('');
lines.push('%description');
lines.push(config.description ?? `${summary}. Generated by sh1pt for dnf/RPM publishing.`);
lines.push('');
lines.push('%prep');
lines.push('%autosetup -n %{name}-%{version}');
lines.push('');
lines.push('%build');
lines.push('# TODO: project build steps (make, cargo, go build, …)');
lines.push('');
lines.push('%install');
lines.push('# TODO: install built artifacts into %{buildroot}');
lines.push('');
lines.push('%files');
lines.push('# TODO: list packaged files');
lines.push('');
lines.push('%changelog');
lines.push(`* ${rpmChangelogDate(new Date())} sh1pt <release@sh1pt.com> - ${version}-${release}`);
lines.push(`- Automated release ${version}`);
Comment thread
greptile-apps[bot] marked this conversation as resolved.
lines.push('');
return lines.join('\n');
}

function renderRepoFile(config: Config): string {
const baseUrl = config.coprProject
? `https://download.copr.fedorainfracloud.org/results/${config.coprProject}/fedora-$releasever-$basearch/`
: (config.repoBaseUrl ?? `https://dnf.sh1pt.com/${config.packageName}/fedora-$releasever-$basearch/`);
return [
`[${config.packageName}]`,
`name=${config.packageName}`,
`baseurl=${baseUrl}`,
'enabled=1',
'gpgcheck=1',
`gpgkey=${config.coprProject
? `https://download.copr.fedorainfracloud.org/results/${config.coprProject}/pubkey.gpg`
: `${config.repoBaseUrl ?? `https://dnf.sh1pt.com/${config.packageName}`}/RPM-GPG-KEY-${config.packageName}`}`,
'',
].join('\n');
Comment thread
greptile-apps[bot] marked this conversation as resolved.
}

function publishCommands(ctx: { version: string }, config: Config): string[] {
const specFile = `${config.packageName}.spec`;
if (config.coprProject) {
return [
`rpmbuild -bs ${specFile}`,
`copr-cli build ${config.coprProject} ~/rpmbuild/SRPMS/${config.packageName}-${rpmVersion(ctx.version)}-${config.release ?? '1'}*.src.rpm`,
`copr-cli status <buildId>`,
];
}
return [
`rpmbuild -bb ${specFile}`,
`createrepo_c <repo-dir>`,
`gpg --detach-sign --armor <repo-dir>/repodata/repomd.xml`,
`rsync <repo-dir>/ ${config.repoBaseUrl ?? 'dnf.sh1pt.com:/var/www/dnf'}/`,
];
}

export default defineTarget<Config>({
id: 'pkg-dnf',
kind: 'package-manager',
label: 'dnf repo / Fedora COPR',
async build(ctx, config) {
const specPath = join(ctx.outDir, 'rpm', `${config.packageName}.spec`);
const repoPath = join(ctx.outDir, 'rpm', `${config.packageName}.repo`);
ctx.log(`generate RPM spec for ${config.packageName} v${ctx.version} [${arches(config).join(', ')}]`);
await mkdir(dirname(specPath), { recursive: true });
await Promise.all([
writeFile(specPath, renderSpec(ctx, config), 'utf-8'),
writeFile(repoPath, renderRepoFile(config), 'utf-8'),
]);
return {
artifact: specPath,
meta: { specFile: specPath, repoFile: repoPath, commands: publishCommands(ctx, config) },
};
},
async ship(ctx, config) {
const via = config.coprProject ? `COPR ${config.coprProject}` : (config.repoBaseUrl ?? 'dnf.sh1pt.com');
ctx.log(`publish ${config.packageName}@${ctx.version} to ${via}`);
if (ctx.dryRun) return { id: 'dry-run', meta: { commands: publishCommands(ctx, config) } };
// Live publish (rpmbuild + copr-cli build | createrepo_c + GPG-sign + upload)
// is not implemented yet — fail loudly rather than report a false success.
// (Required secrets are documented in setup(); not checked here since the
// path is unimplemented.)
throw new Error(
`pkg-dnf live publish for ${config.packageName} is not implemented yet — ` +
`use dryRun to preview the ${config.coprProject ? 'rpmbuild + copr-cli' : 'rpmbuild + createrepo_c'} commands.`,
);
},
async status(id) {
const name = id.split('@')[0] ?? id;
return { state: 'in-review', url: `https://dnf.sh1pt.com/${name}/` };
},
setup: manualSetup({
label: 'dnf repo / Fedora COPR',
vendorDocUrl: 'https://rpm-packaging-guide.github.io/',
steps: [
'COPR (hosted): create an account at https://copr.fedorainfracloud.org and a project.',
'COPR: copy the API token from https://copr.fedorainfracloud.org/api/ and run: sh1pt secret set COPR_API_TOKEN <token>',
'Self-hosted: generate a signing key (gpg --full-generate-key) and run: sh1pt secret set DNF_GPG_KEY "$(gpg --export-secret-keys --armor <key-id>)"',
'Self-hosted: run: sh1pt secret set DNF_GPG_PASSPHRASE <passphrase>',
'Build needs rpmbuild + createrepo_c (or copr-cli) installed on a Fedora/RHEL-like host.',
],
}),
});
5 changes: 5 additions & 0 deletions packages/targets/pkg-dnf/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": { "outDir": "dist", "rootDir": "src" },
"include": ["src/**/*"]
}
6 changes: 6 additions & 0 deletions pnpm-lock.yaml

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