-
Notifications
You must be signed in to change notification settings - Fork 50
feat(targets): add pkg-dnf target (dnf repo / Fedora COPR) #476
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
Open
Alexander-Sorrell-IT
wants to merge
2
commits into
profullstack:master
Choose a base branch
from
Alexander-Sorrell-IT:feat/target-pkg-dnf
base: master
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 --> |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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"] | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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); | ||
| }); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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}`); | ||
| 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'); | ||
|
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.', | ||
| ], | ||
| }), | ||
| }); | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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/**/*"] | ||
| } |
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.