diff --git a/.drone.yml b/.drone.yml new file mode 100644 index 0000000..53c85c4 --- /dev/null +++ b/.drone.yml @@ -0,0 +1,34 @@ +--- +kind: pipeline +type: docker +name: release + +# Cuts the signed PGXN distribution and publishes it as a Gitea release. +# Triggered by version tags (v..). +# +# Repo must be Trusted in Drone. Required secrets: +# pgmorbac_release_key Ed25519 private key PEM (content, not a path) +# gitea_token Gitea API token with write access to crudy/pgmorbac + +trigger: + event: + - tag + ref: + - refs/tags/v* + +steps: + - name: build-and-release + image: node:24-alpine + environment: + PGMORBAC_RELEASE_KEY_PEM: + from_secret: pgmorbac_release_key + GITEA_TOKEN: + from_secret: gitea_token + GITEA_URL: https://git.villains.fr + GITEA_REPO: crudy/pgmorbac + commands: + - apk add --no-cache zip bash + - npm install --no-audit --no-fund + - umask 077 && printf '%s\n' "$PGMORBAC_RELEASE_KEY_PEM" > /tmp/release-key.pem + - PGMORBAC_RELEASE_KEY=/tmp/release-key.pem node scripts/build-dist.mjs + - node scripts/gitea-release.mjs diff --git a/.gitignore b/.gitignore index 67b7a6f..96e5bd6 100644 --- a/.gitignore +++ b/.gitignore @@ -15,4 +15,8 @@ Thumbs.db *.swo *~ .vscode/ -.idea/ \ No newline at end of file +.idea/ +# Release tooling +node_modules/ +dist/ +package-lock.json diff --git a/package.json b/package.json new file mode 100644 index 0000000..5e67c58 --- /dev/null +++ b/package.json @@ -0,0 +1,14 @@ +{ + "name": "pgmorbac-release-tools", + "version": "0.1.0", + "private": true, + "type": "module", + "description": "Release tooling for the pgmorbac PostgreSQL extension (build + sign + publish the PGXN distribution).", + "scripts": { + "dist": "node scripts/build-dist.mjs", + "release": "node scripts/build-dist.mjs && node scripts/gitea-release.mjs" + }, + "devDependencies": { + "jose": "^5" + } +} diff --git a/scripts/build-dist.mjs b/scripts/build-dist.mjs new file mode 100644 index 0000000..4af75db --- /dev/null +++ b/scripts/build-dist.mjs @@ -0,0 +1,121 @@ +#!/usr/bin/env node +// Builds the PGXN-compatible pgmorbac source distribution from src/: +// dist/pgmorbac-.zip extension files + META.json +// dist/pgmorbac-.zip.sha256 checksum (sha256sum -c format) +// dist/pgmorbac-.manifest.jwt Ed25519-signed release manifest +// dist/pgmorbac-.pub.pem the verification public key +// PGMORBAC_RELEASE_KEY must point to the Ed25519 private key (PKCS8 PEM). +// The build self-verifies before exiting. + +import { createHash, createPublicKey } from 'crypto'; +import { execFileSync } from 'child_process'; +import { copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'fs'; +import { dirname, join } from 'path'; +import { fileURLToPath } from 'url'; +import { SignJWT, jwtVerify, importPKCS8, importSPKI } from 'jose'; + +const AUDIENCE = 'pgmorbac-release'; +const root = join(dirname(fileURLToPath(import.meta.url)), '..'); + +function fail(msg) { + console.error(`[pgmorbac-dist] ${msg}`); + process.exit(1); +} + +function controlVersion() { + const m = readFileSync(join(root, 'pgmorbac.control'), 'utf8').match(/default_version\s*=\s*'([^']+)'/); + if (!m) fail('pgmorbac.control has no default_version'); + return m[1]; +} + +const version = controlVersion(); +const meta = JSON.parse(readFileSync(join(root, 'META.json'), 'utf8')); +const baseScript = `pgmorbac--${version}.sql`; +if (meta.version !== version || meta.provides?.pgmorbac?.file !== baseScript) { + fail(`META.json version/provides out of sync with control ${version}`); +} + +const keyPath = process.env.PGMORBAC_RELEASE_KEY; +if (!keyPath) fail('PGMORBAC_RELEASE_KEY is not set (path to the Ed25519 private key PEM)'); +if (!existsSync(keyPath)) fail(`PGMORBAC_RELEASE_KEY points to a missing file: ${keyPath}`); +const privatePem = readFileSync(keyPath, 'utf8'); + +const distDir = join(root, 'dist'); +const stageName = `pgmorbac-${version}`; +const stageDir = join(distDir, stageName); +rmSync(distDir, { recursive: true, force: true }); +mkdirSync(stageDir, { recursive: true }); + +execFileSync(join(root, 'tools', 'build.sh'), ['src', join(stageDir, baseScript)], { cwd: root, stdio: 'inherit' }); +copyFileSync(join(root, 'pgmorbac.control'), join(stageDir, 'pgmorbac.control')); +copyFileSync(join(root, 'META.json'), join(stageDir, 'META.json')); +copyFileSync(join(root, 'LICENSE'), join(stageDir, 'LICENSE')); +const upgradesDir = join(root, 'src', 'upgrades'); +if (existsSync(upgradesDir)) { + for (const f of readdirSync(upgradesDir).filter(n => n.endsWith('.sql'))) { + copyFileSync(join(upgradesDir, f), join(stageDir, f)); + } +} + +writeFileSync(join(stageDir, 'Makefile'), `EXTENSION = pgmorbac +DATA = $(wildcard pgmorbac--*.sql) +PG_CONFIG ?= pg_config +PGXS := $(shell $(PG_CONFIG) --pgxs) +include $(PGXS) +`); + +writeFileSync(join(stageDir, 'README-INSTALL.md'), `# Installing pgmorbac ${version} + +Full documentation: https://pgmorbac.villains.fr + +## As a PostgreSQL extension (recommended) + + make install # uses pg_config / PGXS + psql -d mydb -c 'CREATE EXTENSION pgmorbac' + +## Plain SQL (no filesystem access to the server) + + psql -v ON_ERROR_STOP=1 -d mydb -f ${baseScript} + +## Verifying this distribution + + sha256sum -c pgmorbac-${version}.zip.sha256 + +The signed manifest (pgmorbac-${version}.manifest.jwt) is an Ed25519 JWT +(audience \`${AUDIENCE}\`) carrying the artifact sha256, verifiable against +pgmorbac-${version}.pub.pem published on the same release. +`); + +const zipName = `${stageName}.zip`; +try { + execFileSync('zip', ['-r', '-X', '-q', zipName, stageName], { cwd: distDir }); +} catch (err) { + fail(`zip failed (is the zip CLI installed?): ${err instanceof Error ? err.message : err}`); +} + +const zipBytes = readFileSync(join(distDir, zipName)); +const sha256 = createHash('sha256').update(zipBytes).digest('hex'); +writeFileSync(join(distDir, `${zipName}.sha256`), `${sha256} ${zipName}\n`); + +const manifest = { name: 'pgmorbac', version, artifacts: [{ name: zipName, sha256 }] }; +const signingKey = await importPKCS8(privatePem.trim(), 'EdDSA'); +const token = await new SignJWT({ manifest }) + .setProtectedHeader({ alg: 'EdDSA', typ: 'JWT' }) + .setIssuedAt() + .setAudience(AUDIENCE) + .sign(signingKey); +writeFileSync(join(distDir, `${stageName}.manifest.jwt`), `${token}\n`); + +const publicPem = createPublicKey(privatePem).export({ type: 'spki', format: 'pem' }).toString(); +writeFileSync(join(distDir, `${stageName}.pub.pem`), publicPem); + +const verifyKey = await importSPKI(publicPem, 'EdDSA'); +const { payload } = await jwtVerify(token, verifyKey, { algorithms: ['EdDSA'], audience: AUDIENCE }); +const rehash = createHash('sha256').update(readFileSync(join(distDir, zipName))).digest('hex'); +if (payload.manifest.artifacts[0].sha256 !== rehash || payload.manifest.version !== version) { + fail('self-verification failed: manifest does not match the built artifact'); +} + +console.log(`[pgmorbac-dist] Built ${zipName}`); +console.log(`[pgmorbac-dist] sha256 ${sha256}`); +console.log('[pgmorbac-dist] Manifest signed and self-verified'); diff --git a/scripts/gitea-release.mjs b/scripts/gitea-release.mjs new file mode 100644 index 0000000..1a06c9d --- /dev/null +++ b/scripts/gitea-release.mjs @@ -0,0 +1,84 @@ +#!/usr/bin/env node +// Creates (or reuses) release v on Gitea and attaches the dist assets. +// Run after build-dist.mjs. Requires GITEA_URL, GITEA_REPO, GITEA_TOKEN. + +import { readFileSync, existsSync } from 'fs'; +import { dirname, join } from 'path'; +import { fileURLToPath } from 'url'; + +const root = join(dirname(fileURLToPath(import.meta.url)), '..'); +const version = readFileSync(join(root, 'pgmorbac.control'), 'utf8').match(/default_version\s*=\s*'([^']+)'/)?.[1]; +if (!version) { + console.error('[gitea-release] could not read default_version from pgmorbac.control'); + process.exit(1); +} + +const base = process.env.GITEA_URL; +const repo = process.env.GITEA_REPO; +const token = process.env.GITEA_TOKEN; +if (!base || !repo || !token) { + console.error('[gitea-release] GITEA_URL, GITEA_REPO and GITEA_TOKEN are required'); + process.exit(1); +} + +const distDir = join(root, 'dist'); +const files = [ + `pgmorbac-${version}.zip`, + `pgmorbac-${version}.zip.sha256`, + `pgmorbac-${version}.manifest.jwt`, + `pgmorbac-${version}.pub.pem`, +]; +for (const f of files) { + if (!existsSync(join(distDir, f))) { + console.error(`[gitea-release] Missing artifact ${f} - run build-dist.mjs first`); + process.exit(1); + } +} + +const api = (path) => `${base}/api/v1/repos/${repo}${path}`; +const headers = { Authorization: `token ${token}` }; + +async function ensureRelease() { + const create = await fetch(api('/releases'), { + method: 'POST', + headers: { ...headers, 'Content-Type': 'application/json' }, + body: JSON.stringify({ + tag_name: `v${version}`, + name: `pgmorbac ${version}`, + body: `PGXN-compatible SQL distribution. Verify with sha256sum -c and the signed manifest (public key attached; see https://pgmorbac.villains.fr/download).`, + draft: false, + prerelease: false, + }), + }); + if (create.ok) return (await create.json()).id; + if (create.status !== 409) { + console.error(`[gitea-release] Release creation failed: ${create.status} ${await create.text()}`); + process.exit(1); + } + const existing = await fetch(api(`/releases/tags/v${version}`), { headers }); + if (!existing.ok) { + console.error(`[gitea-release] Release exists but lookup failed: ${existing.status}`); + process.exit(1); + } + return (await existing.json()).id; +} + +const releaseId = await ensureRelease(); +for (const f of files) { + const form = new FormData(); + form.set('attachment', new Blob([readFileSync(join(distDir, f))]), f); + const res = await fetch(api(`/releases/${releaseId}/assets?name=${encodeURIComponent(f)}`), { + method: 'POST', headers, body: form, + }); + if (!res.ok) { + const text = await res.text(); + if (res.status === 409 || text.includes('already exist')) { + console.log(`[gitea-release] ${f} already attached - skipping`); + continue; + } + console.error(`[gitea-release] Upload of ${f} failed: ${res.status} ${text}`); + process.exit(1); + } + console.log(`[gitea-release] Attached ${f}`); +} +console.log(`[gitea-release] Release v${version} ready on ${repo}`);