-
Notifications
You must be signed in to change notification settings - Fork 49
feat(targets): add pkg-pacman target (Arch custom repository) #477
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
3
commits into
profullstack:master
Choose a base branch
from
Alexander-Sorrell-IT:feat/target-pkg-pacman
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
Show all changes
3 commits
Select commit
Hold shift + click to select a range
a523cb0
feat(targets): add pkg-pacman target (Arch custom repository)
Alexander-Sorrell-IT 81d21af
fix(pkg-pacman): validate names + quote commands (adversarial review)
Alexander-Sorrell-IT 8ee5b45
fix(pkg-pacman): validate repoBaseUrl + pkgrel (Greptile P1/P2)
Alexander-Sorrell-IT 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,44 @@ | ||
| # Pacman / Arch custom repository | ||
|
|
||
| Provides the Pacman / Arch custom repository 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. | ||
| - Implements publish behavior used by sh1pt promotion or release workflows. | ||
|
|
||
| ## Package | ||
|
|
||
| - Name: `@profullstack/sh1pt-target-pkg-pacman` | ||
| - Path: `packages/targets/pkg-pacman` | ||
| - Adapter ID: `pkg-pacman` | ||
| - 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-pacman | ||
| ``` | ||
|
|
||
| ## Development | ||
|
|
||
| ```bash | ||
| pnpm --filter @profullstack/sh1pt-target-pkg-pacman typecheck | ||
| ``` | ||
|
|
||
| Run tests from the repository root when this module includes a test file: | ||
|
|
||
| ```bash | ||
| pnpm vitest run packages/targets/pkg-pacman/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-pacman", | ||
| "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-pacman" }, | ||
| "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,125 @@ | ||
| import { fakeBuildContext, fakeShipContext, smokeTest } from '@profullstack/sh1pt-core/testing'; | ||
| import { execFileSync } from 'node:child_process'; | ||
| 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('pacman PKGBUILD generation', () => { | ||
| it('writes a valid PKGBUILD with normalized pkgver and a pacman.conf snippet', async () => { | ||
| const outDir = await mkdtemp(join(tmpdir(), 'sh1pt-pacman-')); | ||
| tempDirs.push(outDir); | ||
|
|
||
| const result = await adapter.build(fakeBuildContext({ outDir, version: 'v1.5.0-rc1' }) as any, { | ||
| pkgname: 'myapp', | ||
| pkgdesc: 'My App', | ||
| url: 'https://example.com/myapp', | ||
| license: ['MIT'], | ||
| depends: ['glibc'], | ||
| makedepends: ['git'], | ||
| sourceUrl: 'https://example.com/myapp-1.5.0.tar.gz', | ||
| sha256sum: 'a'.repeat(64), | ||
| repoName: 'sovereign', | ||
| }); | ||
|
|
||
| const pkgbuildPath = join(outDir, 'pacman', 'PKGBUILD'); | ||
| expect(result.artifact).toBe(pkgbuildPath); | ||
|
|
||
| const pkgbuild = await readFile(pkgbuildPath, 'utf-8'); | ||
| expect(pkgbuild).toContain('pkgname=myapp'); | ||
| // pacman pkgver may not contain '-' or ':' — normalized to '_'. | ||
| expect(pkgbuild).toContain('pkgver=1.5.0_rc1'); | ||
| expect(pkgbuild).toContain('pkgrel=1'); | ||
| expect(pkgbuild).toContain("arch=('x86_64')"); | ||
| expect(pkgbuild).toContain("license=('MIT')"); | ||
| expect(pkgbuild).toContain("depends=('glibc')"); | ||
| expect(pkgbuild).toContain('source=("$pkgname-$pkgver.tar.gz::https://example.com/myapp-1.5.0.tar.gz")'); | ||
| expect(pkgbuild).toContain(`sha256sums=('${'a'.repeat(64)}')`); | ||
| expect(pkgbuild).toContain('package() {'); | ||
|
|
||
| const conf = await readFile(join(outDir, 'pacman', 'sovereign.pacman.conf'), 'utf-8'); | ||
| expect(conf).toContain('[sovereign]'); | ||
| expect(conf).toContain('Server = '); | ||
| }); | ||
|
|
||
| it('escapes shell metacharacters in pkgdesc and defaults source to empty arrays', async () => { | ||
| const outDir = await mkdtemp(join(tmpdir(), 'sh1pt-pacman-')); | ||
| tempDirs.push(outDir); | ||
|
|
||
| await adapter.build(fakeBuildContext({ outDir, version: '2.0.0' }) as any, { | ||
| pkgname: 'myapp', | ||
| pkgdesc: 'Has "quotes" and $VARS and `backticks`', | ||
| }); | ||
|
|
||
| const pkgbuild = await readFile(join(outDir, 'pacman', 'PKGBUILD'), 'utf-8'); | ||
| // Double-quote, $ and backtick are escaped so the bash PKGBUILD stays valid. | ||
| expect(pkgbuild).toContain('pkgdesc="Has \\"quotes\\" and \\$VARS and \\`backticks\\`"'); | ||
| // No source provided -> empty arrays, not a broken reference. | ||
| expect(pkgbuild).toContain('source=()'); | ||
| expect(pkgbuild).toContain('sha256sums=()'); | ||
| }); | ||
|
|
||
| it('keeps dry-run shipping side-effect free; live publish throws not-implemented', async () => { | ||
| const ship = await adapter.ship(fakeShipContext({ version: '1.5.0', dryRun: true }) as any, { | ||
| pkgname: 'myapp', | ||
| repoName: 'sovereign', | ||
| }); | ||
| expect(ship.id).toBe('dry-run'); | ||
| expect((ship.meta?.commands as string[])?.some((c) => c.startsWith('repo-add'))).toBe(true); | ||
|
|
||
| await expect(adapter.ship(fakeShipContext({ version: '1.5.0', dryRun: false }) as any, { | ||
| pkgname: 'myapp', | ||
| })).rejects.toThrow(/PACMAN_GPG_KEY|not implemented/i); | ||
| }); | ||
|
|
||
| it('generates syntactically valid bash (bash -n) even with hostile field values', async () => { | ||
| const outDir = await mkdtemp(join(tmpdir(), 'sh1pt-pacman-')); | ||
| tempDirs.push(outDir); | ||
|
|
||
| await adapter.build(fakeBuildContext({ outDir, version: '1.0.0' }) as any, { | ||
| pkgname: 'myapp', | ||
| pkgdesc: 'evil"; rm -rf /; echo "$(whoami)` `', | ||
| url: 'https://x.test/$(id)`uname`', | ||
| sha256sum: "ab'cd", | ||
| }); | ||
|
|
||
| // bash -n parses without executing — proves the generated PKGBUILD is valid bash. | ||
| const out = execFileSync('bash', ['-n', join(outDir, 'pacman', 'PKGBUILD')], { encoding: 'utf-8', stdio: 'pipe' }); | ||
| expect(out).toBe(''); | ||
| }); | ||
|
|
||
| it('rejects pkgname / repoName that would break the PKGBUILD or pacman.conf', async () => { | ||
| const outDir = await mkdtemp(join(tmpdir(), 'sh1pt-pacman-')); | ||
| tempDirs.push(outDir); | ||
|
|
||
| await expect(adapter.build(fakeBuildContext({ outDir, version: '1.0.0' }) as any, { | ||
| pkgname: 'bad name; rm -rf /', | ||
| })).rejects.toThrow(/invalid pkgname/i); | ||
|
|
||
| await expect(adapter.build(fakeBuildContext({ outDir, version: '1.0.0' }) as any, { | ||
| pkgname: 'myapp', | ||
| repoName: 'evil]\n[core', | ||
| })).rejects.toThrow(/invalid repoName/i); | ||
|
|
||
| // repoBaseUrl with a newline would inject a fake [section] into pacman.conf. | ||
| await expect(adapter.build(fakeBuildContext({ outDir, version: '1.0.0' }) as any, { | ||
| pkgname: 'myapp', | ||
| repoBaseUrl: 'https://x.test\n[core]\nServer = https://evil.test', | ||
| })).rejects.toThrow(/invalid repoBaseUrl/i); | ||
|
|
||
| // pkgrel must be a positive integer (it's embedded unquoted in the PKGBUILD). | ||
| await expect(adapter.build(fakeBuildContext({ outDir, version: '1.0.0' }) as any, { | ||
| pkgname: 'myapp', | ||
| pkgrel: '1\nextrafield=injected', | ||
| })).rejects.toThrow(/invalid pkgrel/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,181 @@ | ||
| import { defineTarget, manualSetup } from '@profullstack/sh1pt-core'; | ||
| import { mkdir, writeFile } from 'node:fs/promises'; | ||
| import { dirname, join } from 'node:path'; | ||
|
|
||
| type PacmanArch = 'x86_64' | 'aarch64' | 'any'; | ||
|
|
||
| interface Config { | ||
| pkgname: string; // e.g. "myapp" | ||
| pkgdesc?: string; | ||
| url?: string; // homepage | ||
| license?: string[]; // e.g. ["MIT"] | ||
| pkgrel?: string; // default "1" | ||
| arch?: PacmanArch[]; // default ["x86_64"] | ||
| depends?: string[]; | ||
| makedepends?: string[]; | ||
| sourceUrl?: string; // upstream source tarball | ||
| sha256sum?: string; // checksum of the source ("SKIP" if none) | ||
| repoName?: string; // custom pacman repo db name (NOT the AUR) | ||
| repoBaseUrl?: string; // where the repo db is hosted (Server= in pacman.conf) | ||
| } | ||
|
|
||
| // pacman pkgver may not contain '-' or ':'. Strip leading v, map '-'/':' to '_'. | ||
| function pkgver(version: string): string { | ||
| return version.replace(/^v/, '').replace(/[-:]/g, '_'); | ||
| } | ||
|
|
||
| function arches(config: Config): PacmanArch[] { | ||
| return config.arch ?? ['x86_64']; | ||
| } | ||
|
|
||
| // Escape a value for a double-quoted bash string in the PKGBUILD. | ||
| function shEscape(value: string): string { | ||
| return value.replace(/(["`$\\])/g, '\\$1'); | ||
| } | ||
|
|
||
| function bashArray(values: string[]): string { | ||
| return values.map((v) => `'${v.replace(/'/g, "'\\''")}'`).join(' '); | ||
| } | ||
|
|
||
| // Reject inputs that could produce an invalid PKGBUILD or inject into the | ||
| // pacman.conf [section] header / shell commands. pkgname follows Arch rules; | ||
| // the repo db/section name allows only [A-Za-z0-9_-]. | ||
| function assertValidNames(config: Config): void { | ||
| if (!/^[a-z0-9][a-z0-9@._+-]*$/.test(config.pkgname)) { | ||
| throw new Error( | ||
| `pkg-pacman: invalid pkgname '${config.pkgname}' — Arch pkgnames are lowercase ` + | ||
| 'alphanumerics plus @ . _ + -, and may not start with "-" or "."', | ||
| ); | ||
| } | ||
| const repo = config.repoName ?? config.pkgname; | ||
| if (!/^[A-Za-z0-9_-]+$/.test(repo)) { | ||
| throw new Error( | ||
| `pkg-pacman: invalid repoName '${repo}' — only letters, digits, "-" and "_" ` + | ||
| 'are allowed (it becomes a pacman.conf [section] header and a repo db filename)', | ||
| ); | ||
| } | ||
| // repoBaseUrl flows verbatim into the .pacman.conf "Server =" line and into the | ||
| // single-quoted rsync command — a newline injects a fake [section] and a single | ||
| // quote breaks out of the rsync quoting. | ||
| if (config.repoBaseUrl !== undefined && /[\r\n']/.test(config.repoBaseUrl)) { | ||
| throw new Error('pkg-pacman: invalid repoBaseUrl — must not contain newlines or single-quote characters'); | ||
| } | ||
| // pkgrel is embedded unquoted in the PKGBUILD; Arch requires a positive integer. | ||
| const rel = config.pkgrel ?? '1'; | ||
| if (!/^[1-9][0-9]*$/.test(rel)) { | ||
| throw new Error(`pkg-pacman: invalid pkgrel '${rel}' — must be a positive integer`); | ||
| } | ||
| } | ||
|
|
||
| function renderPkgbuild(ctx: { version: string }, config: Config): string { | ||
| const ver = pkgver(ctx.version); | ||
| const rel = config.pkgrel ?? '1'; | ||
| const desc = config.pkgdesc ?? `Release package for ${config.pkgname}`; | ||
| const license = config.license ?? ['MIT']; | ||
| const lines = [ | ||
| '# Maintainer: sh1pt <release@sh1pt.com>', | ||
| `pkgname=${config.pkgname}`, | ||
| `pkgver=${ver}`, | ||
| `pkgrel=${rel}`, | ||
|
greptile-apps[bot] marked this conversation as resolved.
|
||
| `pkgdesc="${shEscape(desc)}"`, | ||
| `arch=(${bashArray(arches(config))})`, | ||
| ]; | ||
| if (config.url) lines.push(`url="${shEscape(config.url)}"`); | ||
| lines.push(`license=(${bashArray(license)})`); | ||
| if (config.depends?.length) lines.push(`depends=(${bashArray(config.depends)})`); | ||
| if (config.makedepends?.length) lines.push(`makedepends=(${bashArray(config.makedepends)})`); | ||
| if (config.sourceUrl) { | ||
| lines.push(`source=("$pkgname-$pkgver.tar.gz::${shEscape(config.sourceUrl)}")`); | ||
| lines.push(`sha256sums=(${bashArray([config.sha256sum ?? 'SKIP'])})`); | ||
| } else { | ||
| lines.push('source=()'); | ||
| lines.push('sha256sums=()'); | ||
| } | ||
| lines.push(''); | ||
| lines.push('build() {'); | ||
| lines.push(' # TODO: project build steps (make, cargo build --release, go build, …)'); | ||
| lines.push(' :'); | ||
| lines.push('}'); | ||
| lines.push(''); | ||
| lines.push('package() {'); | ||
| lines.push(' # TODO: install built artifacts into "$pkgdir"'); | ||
| lines.push(' :'); | ||
| lines.push('}'); | ||
| lines.push(''); | ||
| return lines.join('\n'); | ||
| } | ||
|
|
||
| function renderRepoConf(config: Config): string { | ||
| const repo = config.repoName ?? config.pkgname; | ||
| const server = config.repoBaseUrl ?? `https://pacman.sh1pt.com/${repo}/$arch`; | ||
| return [ | ||
| `# Add to /etc/pacman.conf to consume the custom ${repo} repository:`, | ||
| `[${repo}]`, | ||
| `Server = ${server}`, | ||
| '', | ||
| ].join('\n'); | ||
| } | ||
|
|
||
| function publishCommands(ctx: { version: string }, config: Config): string[] { | ||
| const repo = config.repoName ?? config.pkgname; | ||
| const ver = pkgver(ctx.version); | ||
| const rel = config.pkgrel ?? '1'; | ||
| const arch = arches(config)[0] ?? 'x86_64'; | ||
| const pkgfile = `${config.pkgname}-${ver}-${rel}-${arch}.pkg.tar.zst`; | ||
| const dest = config.repoBaseUrl ?? `pacman.sh1pt.com:/var/www/pacman/${repo}`; | ||
| return [ | ||
| 'makepkg -s --sign', | ||
| `repo-add --sign '${repo}.db.tar.zst' '${pkgfile}'`, | ||
| `rsync ./ '${dest}/'`, | ||
| ]; | ||
|
greptile-apps[bot] marked this conversation as resolved.
|
||
| } | ||
|
|
||
| export default defineTarget<Config>({ | ||
| id: 'pkg-pacman', | ||
| kind: 'package-manager', | ||
| label: 'Pacman / Arch custom repository', | ||
| async build(ctx, config) { | ||
| assertValidNames(config); | ||
| const pkgbuildPath = join(ctx.outDir, 'pacman', 'PKGBUILD'); | ||
| const confPath = join(ctx.outDir, 'pacman', `${config.repoName ?? config.pkgname}.pacman.conf`); | ||
| ctx.log(`generate PKGBUILD for ${config.pkgname} v${ctx.version} [${arches(config).join(', ')}]`); | ||
| await mkdir(dirname(pkgbuildPath), { recursive: true }); | ||
| await Promise.all([ | ||
| writeFile(pkgbuildPath, renderPkgbuild(ctx, config), 'utf-8'), | ||
| writeFile(confPath, renderRepoConf(config), 'utf-8'), | ||
| ]); | ||
| return { | ||
| artifact: pkgbuildPath, | ||
| meta: { pkgbuild: pkgbuildPath, repoConf: confPath, commands: publishCommands(ctx, config) }, | ||
| }; | ||
| }, | ||
| async ship(ctx, config) { | ||
| assertValidNames(config); | ||
| const repo = config.repoName ?? config.pkgname; | ||
| ctx.log(`publish ${config.pkgname}@${ctx.version} to pacman repo (${repo})`); | ||
| if (ctx.dryRun) return { id: 'dry-run', meta: { commands: publishCommands(ctx, config) } }; | ||
| // Live publish (makepkg + repo-add + signed upload) is not implemented yet — | ||
| // fail loudly rather than report a false success. (PACMAN_GPG_KEY is | ||
| // documented in setup(); not checked here since the path is unimplemented.) | ||
| throw new Error( | ||
| `pkg-pacman live publish for ${config.pkgname} is not implemented yet — ` + | ||
| 'use dryRun to preview the makepkg + repo-add commands.', | ||
| ); | ||
| }, | ||
| async status(id) { | ||
| const name = id.split('@')[0] ?? id; | ||
| return { state: 'in-review', url: `https://pacman.sh1pt.com/${name}/` }; | ||
| }, | ||
| setup: manualSetup({ | ||
| label: 'Pacman / Arch custom repository', | ||
| vendorDocUrl: 'https://man.archlinux.org/man/repo-add.8.en', | ||
| steps: [ | ||
| 'This is a custom pacman binary repo (repo-add), NOT the AUR — for the AUR use pkg-aur.', | ||
| 'Generate a signing key: gpg --full-generate-key', | ||
| 'Run: sh1pt secret set PACMAN_GPG_KEY "$(gpg --export-secret-keys --armor <key-id>)"', | ||
| 'Run: sh1pt secret set PACMAN_GPG_PASSPHRASE <passphrase>', | ||
| 'Build needs an Arch-like host with base-devel (makepkg) + the repo-add tool.', | ||
| 'Consumers add the [repo] + Server line (generated .pacman.conf) to /etc/pacman.conf.', | ||
| ], | ||
| }), | ||
| }); | ||
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/**/*"] | ||
| } |
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.