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
26 changes: 26 additions & 0 deletions packages/targets/pkg-dnf/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
# dnf / RPM (Fedora COPR)

Provides the `pkg-dnf` sh1pt target adapter for building and publishing RPM packages to [Fedora COPR](https://copr.fedorainfracloud.org).

## What it does

- Generates a valid `.spec` file for building RPM packages
- Submits build tasks to your Fedora COPR project via the COPR API
- Supports x86_64, aarch64, and noarch architectures

## Package

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

## Setup

```bash
sh1pt secret set COPR_LOGIN <your-copr-login>
sh1pt secret set COPR_TOKEN <your-copr-api-token>
sh1pt secret set COPR_PROJECT <owner>/<project>
```

See [Fedora COPR docs](https://docs.pagure.org/copr.copr/user_documentation.html) for setup steps.
23 changes: 23 additions & 0 deletions packages/targets/pkg-dnf/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
{
"name": "@profullstack/sh1pt-target-pkg-dnf",
"version": "0.1.0",
"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"]
}
51 changes: 51 additions & 0 deletions packages/targets/pkg-dnf/src/index.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { fakeBuildContext, fakeShipContext, smokeTest } from '@profullstack/sh1pt-core/testing';
import { readFile, rm, mkdtemp } 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('RPM spec generation', () => {
it('writes a .spec file from config', async () => {
const outDir = await mkdtemp(join(tmpdir(), 'sh1pt-dnf-'));
tempDirs.push(outDir);

const result = await adapter.build(fakeBuildContext({ outDir, version: 'v1.5.0' }) as any, {
packageName: 'myapp',
summary: 'An example CLI tool',
description: 'An example CLI tool for doing things',
license: 'MIT',
homepage: 'https://example.com',
architecture: 'x86_64',
coprProject: 'acme/myapp',
releaseRepo: 'acme/myapp',
requires: ['glibc'],
});

expect(result.artifact).toBe(join(outDir, 'myapp.spec'));

const spec = await readFile(join(outDir, 'myapp.spec'), 'utf-8');
expect(spec).toContain('Name: myapp');
expect(spec).toContain('Version: 1.5.0');
expect(spec).toContain('Summary: An example CLI tool');
expect(spec).toContain('License: MIT');
expect(spec).toContain('URL: https://example.com');
expect(spec).toContain('BuildArch: x86_64');
expect(spec).toContain('Requires: glibc');
expect(spec).toContain('%description');
expect(spec).toContain('%files');
});
Comment on lines +31 to +44
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Source0 URL not tested — masks the replace bug

The test checks Name, Version, Summary, License, URL, BuildArch, Requires, %description, and %files, but never asserts the Source0 line. Because of this, the first-occurrence-only .replace('{version}', ...) bug on the default URL template would not be caught. Adding an assertion with the fully-expanded expected URL (no literal {version} remaining) would have caught this and will prevent regressions.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!


it('keeps dry-run shipping side-effect free', async () => {
await expect(adapter.ship(fakeShipContext({ version: '1.5.0', dryRun: true }) as any, {
packageName: 'myapp',
})).resolves.toEqual({ id: 'dry-run' });
});
});
151 changes: 151 additions & 0 deletions packages/targets/pkg-dnf/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
import { defineTarget, manualSetup } from '@profullstack/sh1pt-core';
import { mkdir, writeFile } from 'node:fs/promises';
import { join } from 'node:path';

interface Config {
/** RPM package name, e.g. "myapp" */
packageName: string;
/** Package summary (one line) */
summary?: string;
/** Full description */
description?: string;
/** SPDX license identifier, e.g. "MIT" */
license?: string;
/** Project homepage URL */
homepage?: string;
/** Package group, e.g. "Applications/System" */
group?: string;
/** Architecture: x86_64 | aarch64 | noarch */
architecture?: 'x86_64' | 'aarch64' | 'noarch';
/** Fedora COPR project slug: "owner/project", e.g. "myorg/myapp" */
coprProject?: string;
/** GitHub release repo to derive default download URL, e.g. "myorg/myapp" */
releaseRepo?: string;
/** Source download URL template with {version}, {name}, {arch} placeholders */
sourceUrlTemplate?: string;
/** RPM requires (runtime dependencies) */
requires?: string[];
/** Build requires */
buildRequires?: string[];
}

function defaultSourceUrl(config: Config, version: string): string {
const repo = config.releaseRepo ?? config.coprProject ?? config.packageName;
const arch = config.architecture ?? 'x86_64';
return (config.sourceUrlTemplate ?? `https://github.com/${repo}/releases/download/v{version}/{name}-{version}-{arch}.tar.gz`)
.replace('{version}', version)
.replace('{name}', config.packageName)
.replace('{arch}', arch);
Comment on lines +35 to +38
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 The default URL template contains {version} twice (v{version}/ and {name}-{version}-{arch}), but String.prototype.replace(string, replacement) only replaces the first occurrence. The second {version} — the one in the filename — will remain as a literal {version} string in the emitted Source0 header. RPM does not interpret curly-brace placeholders, so rpmbuild will attempt to download a URL with a literal {version} in the filename, which will 404 every time.

Suggested change
return (config.sourceUrlTemplate ?? `https://github.com/${repo}/releases/download/v{version}/{name}-{version}-{arch}.tar.gz`)
.replace('{version}', version)
.replace('{name}', config.packageName)
.replace('{arch}', arch);
return (config.sourceUrlTemplate ?? `https://github.com/${repo}/releases/download/v{version}/{name}-{version}-{arch}.tar.gz`)
.replaceAll('{version}', version)
.replaceAll('{name}', config.packageName)
.replaceAll('{arch}', arch);

}

function renderSpec(config: Config, version: string): string {
const name = config.packageName;
const arch = config.architecture ?? 'x86_64';
const license = config.license ?? 'MIT';
const summary = config.summary ?? `${name} package`;
const description = config.description ?? summary;
const homepage = config.homepage ?? 'https://sh1pt.com';
const group = config.group ?? 'Applications/System';
const requires = config.requires ?? [];
const buildRequires = config.buildRequires ?? [];

const lines = [
`Name: ${name}`,
`Version: ${version}`,
'Release: 1%{?dist}',
`Summary: ${summary}`,
`License: ${license}`,
`URL: ${homepage}`,
`Source0: ${defaultSourceUrl(config, '%{version}')}`,
`Group: ${group}`,
`BuildArch: ${arch}`,
'',
];

for (const req of buildRequires) {
lines.push(`BuildRequires: ${req}`);
}
for (const req of requires) {
lines.push(`Requires: ${req}`);
}

lines.push(
'',
'%description',
description,
'',
'%prep',
'%autosetup',
'',
'%build',
'# binary releases — no compilation needed',
'',
'%install',
'rm -rf %{buildroot}',
`install -Dm755 %{name} %{buildroot}%{_bindir}/%{name}`,
'',
'%files',
'%license LICENSE',
`%{_bindir}/${name}`,
'',
'%changelog',
`* ${new Date().toLocaleDateString('en-US', { weekday: 'short', year: 'numeric', month: 'short', day: '2-digit' })} sh1pt <noreply@sh1pt.com> - ${version}-1`,
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 new Date() in renderSpec captures the wall-clock time at spec-generation, so running build twice on the same version on different days produces different file contents. This breaks reproducible builds and will cause unnecessary diffs in version-controlled .spec files.

Suggested change
`* ${new Date().toLocaleDateString('en-US', { weekday: 'short', year: 'numeric', month: 'short', day: '2-digit' })} sh1pt <noreply@sh1pt.com> - ${version}-1`,
`* ${new Date(0).toLocaleDateString('en-US', { weekday: 'short', year: 'numeric', month: 'short', day: '2-digit' })} sh1pt <noreply@sh1pt.com> - ${version}-1`,

`- Release ${version}`,
'',
);

return lines.join('\n');
}

export default defineTarget<Config>({
id: 'pkg-dnf',
kind: 'package-manager',
label: 'dnf / RPM (Fedora COPR)',

async build(ctx, config) {
const version = ctx.version.replace(/^v/, '');
const specPath = join(ctx.outDir, `${config.packageName}.spec`);

ctx.log(`generate RPM spec ${config.packageName}.spec for v${version}`);
await mkdir(ctx.outDir, { recursive: true });
await writeFile(specPath, renderSpec(config, version), 'utf-8');
ctx.log(`wrote ${specPath}`);

return { artifact: specPath };
},

async ship(ctx, config) {
const version = ctx.version.replace(/^v/, '');
const copr = config.coprProject ?? config.packageName;

ctx.log(`submit ${config.packageName} v${version} to Fedora COPR (${copr})`);

if (ctx.dryRun) return { id: 'dry-run' };

// TODO: use the COPR API (copr.fedorainfracloud.org) to trigger a build
// Requires COPR_LOGIN and COPR_TOKEN from ctx.secret(...)
return {
id: `${config.packageName}@${version}`,
url: `https://copr.fedorainfracloud.org/coprs/${copr}/`,
};
},

async status(id) {
const [name] = id.split('@');
return { state: 'live', url: `https://packages.fedoraproject.org/pkgs/${name}/` };
},
Comment on lines +133 to +136
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 The status() URL points to packages.fedoraproject.org, which is the search index for official Fedora packages — not COPR. A package published via this adapter lands at copr.fedorainfracloud.org/coprs/<owner>/<project>/, so the returned URL leads users to a page where their package will never appear.

Suggested change
async status(id) {
const [name] = id.split('@');
return { state: 'live', url: `https://packages.fedoraproject.org/pkgs/${name}/` };
},
async status(id) {
const [nameAndVersion] = id.split('@');
// id format is "<packageName>@<version>" from ship(); copr slug not available here,
// so fall back to the COPR search page rather than the wrong official packages index.
return { state: 'live', url: `https://copr.fedorainfracloud.org/coprs/search/?query=${nameAndVersion}` };
},


setup: manualSetup({
label: 'Fedora COPR (dnf)',
vendorDocUrl: 'https://docs.fedoraproject.org/en-US/packaging-guidelines/',
steps: [
'Create a free account at copr.fedorainfracloud.org',
'Create a new COPR project for your package',
'Go to your COPR profile → API → generate token',
'Run: sh1pt secret set COPR_LOGIN <your-login>',
'Run: sh1pt secret set COPR_TOKEN <your-api-token>',
'Run: sh1pt secret set COPR_PROJECT <owner>/<project>',
'sh1pt will submit build tasks to your COPR project on each release',
],
}),
});
1 change: 1 addition & 0 deletions packages/targets/pkg-dnf/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"extends": "../../../tsconfig.base.json","compilerOptions": {"outDir": "dist","rootDir": "src"},"include": ["src/**/*"]}
Loading