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
30 changes: 30 additions & 0 deletions packages/targets/pkg-pacman/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
# Arch Linux AUR / Pacman

Provides the `pkg-pacman` sh1pt target adapter for generating `PKGBUILD` and `.SRCINFO`
files and publishing packages to the [Arch User Repository (AUR)](https://aur.archlinux.org).

## What it does

- Generates a valid `PKGBUILD` file for Arch Linux / Pacman
- Generates the matching `.SRCINFO` metadata file (required by AUR)
- Publishes to AUR via SSH on `sh1pt promote ship`
- Supports x86_64, aarch64, and noarch architectures

## Package

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

## Setup

```bash
sh1pt secret set AUR_SSH_KEY <path-to-your-aur-ssh-private-key>
```

1. Register at [aur.archlinux.org](https://aur.archlinux.org)
2. Add your SSH public key in AUR account settings
3. Clone your package: `git clone ssh://aur@aur.archlinux.org/<pkgname>.git`

See the [AUR submission guidelines](https://wiki.archlinux.org/title/AUR_submission_guidelines) for details.
23 changes: 23 additions & 0 deletions packages/targets/pkg-pacman/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
{
"name": "@profullstack/sh1pt-target-pkg-pacman",
"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-pacman"
},
"homepage": "https://sh1pt.com",
"bugs": "https://github.com/profullstack/sh1pt/issues",
"files": ["dist"]
}
53 changes: 53 additions & 0 deletions packages/targets/pkg-pacman/src/index.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
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('PKGBUILD generation', () => {
it('writes PKGBUILD and .SRCINFO from config', async () => {
const outDir = await mkdtemp(join(tmpdir(), 'sh1pt-pacman-'));
tempDirs.push(outDir);

const result = await adapter.build(fakeBuildContext({ outDir, version: 'v3.0.1' }) as any, {
pkgname: 'myapp',
pkgdesc: 'An example CLI tool',
license: 'MIT',
url: 'https://example.com',
arch: 'x86_64',
releaseRepo: 'acme/myapp',
depends: ['glibc', 'gcc-libs'],
sha512sum: 'a'.repeat(128),
});

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

const pkgbuild = await readFile(join(outDir, 'PKGBUILD'), 'utf-8');
expect(pkgbuild).toContain('pkgname=myapp');
expect(pkgbuild).toContain('pkgver=3.0.1');
expect(pkgbuild).toContain('pkgdesc="An example CLI tool"');
expect(pkgbuild).toContain("arch=('x86_64')");
expect(pkgbuild).toContain("license=('MIT')");
expect(pkgbuild).toContain("depends=('glibc' 'gcc-libs')");
expect(pkgbuild).toContain('sha512sums=(\'' + 'a'.repeat(128) + '\')');
expect(pkgbuild).toContain('package()');

const srcinfo = await readFile(join(outDir, '.SRCINFO'), 'utf-8');
expect(srcinfo).toContain('pkgbase = myapp');
expect(srcinfo).toContain('pkgver = 3.0.1');
});

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

interface Config {
/** Package name in the AUR / Pacman repo */
pkgname: string;
/** Package description */
pkgdesc?: string;
/** SPDX license identifier, e.g. "MIT" */
license?: string;
/** Project homepage URL */
url?: string;
/** Architecture: x86_64 | aarch64 | any */
arch?: 'x86_64' | 'aarch64' | 'any';
/** GitHub release repo to derive default download URL, e.g. "myorg/myapp" */
releaseRepo?: string;
/** SHA-512 checksum of the source tarball (leave empty to use SKIP for development) */
sha512sum?: string;
/** Runtime dependencies */
depends?: string[];
/** Make/build dependencies */
makedepends?: string[];
/** Conflicts with other packages */
conflicts?: string[];
/** Provides (virtual packages) */
provides?: string[];
}

function defaultSourceUrl(config: Config): string {
const repo = config.releaseRepo ?? config.pkgname;
return `https://github.com/${repo}/releases/download/v$pkgver/${config.pkgname}-$pkgver-${config.arch ?? 'x86_64'}.tar.gz`;
}

function renderPKGBUILD(config: Config, version: string): string {
const name = config.pkgname;
const arch = config.arch ?? 'x86_64';
const license = config.license ?? 'MIT';
const description = config.pkgdesc ?? `${name} package`;
const homepage = config.url ?? 'https://sh1pt.com';
const sourceUrl = defaultSourceUrl(config);
const sha512 = config.sha512sum ?? 'SKIP';
const depends = config.depends ?? [];
const makedepends = config.makedepends ?? [];
const conflicts = config.conflicts ?? [];
const provides = config.provides ?? [];

const lines = [
`# Maintainer: sh1pt <noreply@sh1pt.com>`,
`pkgname=${name}`,
`pkgver=${version}`,
`pkgrel=1`,
`pkgdesc="${description}"`,
`arch=('${arch}')`,
`url="${homepage}"`,
`license=('${license}')`,
];

if (depends.length) lines.push(`depends=(${depends.map((d) => `'${d}'`).join(' ')})`);
if (makedepends.length) lines.push(`makedepends=(${makedepends.map((d) => `'${d}'`).join(' ')})`);
if (provides.length) lines.push(`provides=(${provides.map((p) => `'${p}'`).join(' ')})`);
if (conflicts.length) lines.push(`conflicts=(${conflicts.map((c) => `'${c}'`).join(' ')})`);

lines.push(
`source=("${name}-\${pkgver}.tar.gz::${sourceUrl}")`,
`sha512sums=('${sha512}')`,
'',
'package() {',
` install -Dm755 "${name}" "\${pkgdir}/usr/bin/${name}"`,
` install -Dm644 LICENSE "\${pkgdir}/usr/share/licenses/${name}/LICENSE" 2>/dev/null || true`,
'}',
Comment on lines +68 to +71
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 install -Dm755 "${name}" looks for the binary in the current directory at the time package() runs. makepkg changes into $srcdir before calling package(), and most GitHub Release tarballs extract into a subdirectory (e.g. myapp-3.0.1/). Without a cd step the binary won't be found and makepkg will fail. Referencing $srcdir/<name>-$pkgver/$name (or better, searching within $srcdir) is the standard pattern.

Suggested change
'package() {',
` install -Dm755 "${name}" "\${pkgdir}/usr/bin/${name}"`,
` install -Dm644 LICENSE "\${pkgdir}/usr/share/licenses/${name}/LICENSE" 2>/dev/null || true`,
'}',
'package() {',
` cd "\${srcdir}/${name}-\${pkgver}"`,
` install -Dm755 "${name}" "\${pkgdir}/usr/bin/${name}"`,
` install -Dm644 LICENSE "\${pkgdir}/usr/share/licenses/${name}/LICENSE" 2>/dev/null || true`,
'}',

'',
);

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

function renderSRCINFO(config: Config, version: string): string {
const name = config.pkgname;
const arch = config.arch ?? 'x86_64';
return [
`pkgbase = ${name}`,
`\tpkgdesc = ${config.pkgdesc ?? `${name} package`}`,
`\tpkgver = ${version}`,
`\tpkgrel = 1`,
`\turl = ${config.url ?? 'https://sh1pt.com'}`,
`\tarch = ${arch}`,
`\tlicense = ${config.license ?? 'MIT'}`,
`\tsource = ${name}-${version}.tar.gz::${defaultSourceUrl(config).replace('$pkgver', version)}`,
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 .SRCINFO source URL has only the first $pkgver substituted because String.replace(string, replacement) replaces only the first match. The default URL template contains two occurrences — one in the path segment (v$pkgver) and one in the filename (myapp-$pkgver-x86_64.tar.gz) — so the generated .SRCINFO will contain a literal $pkgver in the filename part, producing an invalid source URL that makepkg cannot download.

Suggested change
`\tsource = ${name}-${version}.tar.gz::${defaultSourceUrl(config).replace('$pkgver', version)}`,
`\tsource = ${name}-${version}.tar.gz::${defaultSourceUrl(config).replace(/\$pkgver/g, version)}`,

`\tsha512sums = ${config.sha512sum ?? 'SKIP'}`,
'',
`pkgname = ${name}`,
'',
].join('\n');
}

export default defineTarget<Config>({
id: 'pkg-pacman',
kind: 'package-manager',
label: 'Arch Linux AUR / Pacman',

async build(ctx, config) {
const version = ctx.version.replace(/^v/, '');
const pkgbuildPath = join(ctx.outDir, 'PKGBUILD');
const srcinfoPath = join(ctx.outDir, '.SRCINFO');

ctx.log(`generate PKGBUILD + .SRCINFO for ${config.pkgname} v${version}`);
await mkdir(ctx.outDir, { recursive: true });
await writeFile(pkgbuildPath, renderPKGBUILD(config, version), 'utf-8');
await writeFile(srcinfoPath, renderSRCINFO(config, version), 'utf-8');
ctx.log(`wrote ${pkgbuildPath}`);
ctx.log(`wrote ${srcinfoPath}`);

return { artifact: pkgbuildPath };
},

async ship(ctx, config) {
const version = ctx.version.replace(/^v/, '');
ctx.log(`push ${config.pkgname} v${version} to AUR`);

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

// TODO: push updated PKGBUILD + .SRCINFO to the AUR git remote
// AUR URL: ssh://aur@aur.archlinux.org/<pkgname>.git
// Requires AUR_SSH_KEY from ctx.secret('AUR_SSH_KEY')
return {
id: `${config.pkgname}@${version}`,
url: `https://aur.archlinux.org/packages/${config.pkgname}`,
};
},
Comment on lines +117 to +130
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 ship() silently returns success without doing anything

The non-dry-run path falls through to return { id, url } as if the package was published, but no actual git operations are performed — the AUR push is entirely unimplemented (the TODO comment confirms this). Any caller or CI pipeline will receive a successful result and believe the package is live on AUR when nothing was pushed. At minimum the stub should throw a NotImplementedError or log a clear warning so users are not silently misled into thinking a release happened.


async status(id) {
const [name] = id.split('@');
return { state: 'live', url: `https://aur.archlinux.org/packages/${name}` };
},

setup: manualSetup({
label: 'Arch Linux AUR',
vendorDocUrl: 'https://wiki.archlinux.org/title/AUR_submission_guidelines',
steps: [
'Register an account at aur.archlinux.org',
'Add your SSH public key in your AUR account settings',
'Run: sh1pt secret set AUR_SSH_KEY <path-to-private-key>',
'First time: clone your AUR package repo: ssh://aur@aur.archlinux.org/<pkgname>.git',
'sh1pt will push updated PKGBUILD and .SRCINFO on each release',
],
}),
});
1 change: 1 addition & 0 deletions packages/targets/pkg-pacman/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