-
Notifications
You must be signed in to change notification settings - Fork 51
feat(targets): add pkg-dnf target (dnf / RPM / Fedora COPR) #485
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
0e54d34
adbc28d
57b86f7
92f9545
991ef6e
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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. |
| 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"] | ||
| } |
| 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'); | ||
| }); | ||
|
|
||
| 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' }); | ||
| }); | ||
| }); | ||
| 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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||||||||||||||||||
| } | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| 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`, | ||||||||||||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||||||||||||||||||
| `- 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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||||||||||||||||||
|
|
||||||||||||||||||||||
| 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', | ||||||||||||||||||||||
| ], | ||||||||||||||||||||||
| }), | ||||||||||||||||||||||
| }); | ||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| {"extends": "../../../tsconfig.base.json","compilerOptions": {"outDir": "dist","rootDir": "src"},"include": ["src/**/*"]} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Source0URL not tested — masks thereplacebugThe test checks
Name,Version,Summary,License,URL,BuildArch,Requires,%description, and%files, but never asserts theSource0line. 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!