Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6dd1026c5a |
+34
@@ -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<major>.<minor>.<patch>).
|
||||||
|
#
|
||||||
|
# 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
|
||||||
+5
-1
@@ -15,4 +15,8 @@ Thumbs.db
|
|||||||
*.swo
|
*.swo
|
||||||
*~
|
*~
|
||||||
.vscode/
|
.vscode/
|
||||||
.idea/
|
.idea/
|
||||||
|
# Release tooling
|
||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
package-lock.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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
// Builds the PGXN-compatible pgmorbac source distribution from src/:
|
||||||
|
// dist/pgmorbac-<version>.zip extension files + META.json
|
||||||
|
// dist/pgmorbac-<version>.zip.sha256 checksum (sha256sum -c format)
|
||||||
|
// dist/pgmorbac-<version>.manifest.jwt Ed25519-signed release manifest
|
||||||
|
// dist/pgmorbac-<version>.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');
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
// Creates (or reuses) release v<version> 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}`);
|
||||||
Reference in New Issue
Block a user