diff --git a/packages/targets/pkg-dnf/README.md b/packages/targets/pkg-dnf/README.md new file mode 100644 index 00000000..392b8325 --- /dev/null +++ b/packages/targets/pkg-dnf/README.md @@ -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 +sh1pt secret set COPR_TOKEN +sh1pt secret set COPR_PROJECT / +``` + +See [Fedora COPR docs](https://docs.pagure.org/copr.copr/user_documentation.html) for setup steps. diff --git a/packages/targets/pkg-dnf/package.json b/packages/targets/pkg-dnf/package.json new file mode 100644 index 00000000..c0dc2022 --- /dev/null +++ b/packages/targets/pkg-dnf/package.json @@ -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"] +} diff --git a/packages/targets/pkg-dnf/src/index.test.ts b/packages/targets/pkg-dnf/src/index.test.ts new file mode 100644 index 00000000..41ae6d7c --- /dev/null +++ b/packages/targets/pkg-dnf/src/index.test.ts @@ -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'); + }); + + 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' }); + }); +}); diff --git a/packages/targets/pkg-dnf/src/index.ts b/packages/targets/pkg-dnf/src/index.ts new file mode 100644 index 00000000..23a412b8 --- /dev/null +++ b/packages/targets/pkg-dnf/src/index.ts @@ -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); +} + +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 - ${version}-1`, + `- Release ${version}`, + '', + ); + + return lines.join('\n'); +} + +export default defineTarget({ + 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}/` }; + }, + + 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 ', + 'Run: sh1pt secret set COPR_TOKEN ', + 'Run: sh1pt secret set COPR_PROJECT /', + 'sh1pt will submit build tasks to your COPR project on each release', + ], + }), +}); diff --git a/packages/targets/pkg-dnf/tsconfig.json b/packages/targets/pkg-dnf/tsconfig.json new file mode 100644 index 00000000..12eec0d9 --- /dev/null +++ b/packages/targets/pkg-dnf/tsconfig.json @@ -0,0 +1 @@ +{"extends": "../../../tsconfig.base.json","compilerOptions": {"outDir": "dist","rootDir": "src"},"include": ["src/**/*"]}