Wire Forge-native release infrastructure
Some checks failed
Cache: Publish Nix / Publish Nix Cache (push) Waiting to run
Build Rust / Cargo Test (push) Successful in 4m14s
Build Site / Next.js Build (push) Failing after 6s
Infra: OpenTofu / OpenTofu (grafana) (push) Successful in 5s
Lint Governance / BEP Metadata (push) Successful in 0s
Build: Android / Android Rust Core Stub (push) Failing after 23s

This commit is contained in:
Conrad Kramer 2026-06-07 05:51:12 -07:00
parent 97c569fb35
commit 002bd382e9
199 changed files with 14268 additions and 185 deletions

View file

@ -0,0 +1,164 @@
#!/usr/bin/env node
import fs from "node:fs";
import path from "node:path";
import { webcrypto } from "node:crypto";
const crypto = globalThis.crypto ?? webcrypto;
function b64url(input) {
const buf = Buffer.isBuffer(input) ? input : Buffer.from(input);
return buf
.toString("base64")
.replace(/=/g, "")
.replace(/\+/g, "-")
.replace(/\//g, "_");
}
function parseArgs(argv) {
const out = { _: [] };
for (let i = 2; i < argv.length; i++) {
const arg = argv[i];
if (arg === "--help" || arg === "-h") out.help = true;
else if (arg.startsWith("--")) {
const key = arg.slice(2);
const value = argv[i + 1];
if (!value || value.startsWith("--")) throw new Error(`missing value for --${key}`);
out[key] = value;
i++;
} else {
out._.push(arg);
}
}
return out;
}
async function makeJwt({ keyId, issuerId, keyPem }) {
const header = { alg: "ES256", kid: keyId, typ: "JWT" };
const now = Math.floor(Date.now() / 1000);
const payload = { iss: issuerId, exp: now + 20 * 60, aud: "appstoreconnect-v1" };
const input = `${b64url(JSON.stringify(header))}.${b64url(JSON.stringify(payload))}`;
const pkcs8Der = Buffer.from(
keyPem.replace(/-----BEGIN PRIVATE KEY-----|-----END PRIVATE KEY-----|\s+/g, ""),
"base64",
);
const key = await crypto.subtle.importKey(
"pkcs8",
pkcs8Der,
{ name: "ECDSA", namedCurve: "P-256" },
false,
["sign"],
);
const sig = await crypto.subtle.sign(
{ name: "ECDSA", hash: "SHA-256" },
key,
new TextEncoder().encode(input),
);
return `${input}.${b64url(Buffer.from(sig))}`;
}
async function ascFetch(jwt, url, opts = {}) {
const res = await fetch(url, {
...opts,
headers: {
...(opts.headers ?? {}),
Authorization: `Bearer ${jwt}`,
"Content-Type": "application/json",
},
});
const text = await res.text();
let body;
try {
body = text ? JSON.parse(text) : {};
} catch {
body = { raw: text };
}
if (!res.ok) {
throw new Error(`ASC ${res.status} ${url}: ${JSON.stringify(body).slice(0, 2000)}`);
}
return body;
}
function certificateFileName(type, id) {
return `${type.toLowerCase().replace(/_/g, "-")}-${id}.cer`;
}
function csrContentForApi(csrFile) {
const bytes = fs.readFileSync(csrFile);
const text = bytes.toString("ascii");
const pemMatch = text.match(/-----BEGIN CERTIFICATE REQUEST-----([\s\S]+?)-----END CERTIFICATE REQUEST-----/);
if (pemMatch) {
return pemMatch[1].replace(/\s+/g, "");
}
return bytes.toString("base64");
}
async function main() {
const args = parseArgs(process.argv);
if (args.help) {
console.log("Usage: create-asc-certificate.mjs --key-id ID --issuer-id ID --key-file AuthKey.p8 --certificate-type IOS_DISTRIBUTION --csr-file file.csr --out-dir DIR");
process.exit(0);
}
const keyId = args["key-id"] ?? process.env.APP_STORE_CONNECT_KEY_ID;
const issuerId = args["issuer-id"] ?? process.env.APP_STORE_CONNECT_ISSUER_ID;
const keyFile = args["key-file"] ?? process.env.APP_STORE_CONNECT_KEY_FILE;
const certificateType = args["certificate-type"] ?? "IOS_DISTRIBUTION";
const csrFile = args["csr-file"];
const outDir = args["out-dir"] ?? ".";
const outFile = args["out-file"];
const metadataFile = args["metadata-file"];
if (!keyId || !issuerId || !keyFile || !csrFile) {
throw new Error("required: --key-id --issuer-id --key-file --csr-file");
}
const jwt = await makeJwt({
keyId,
issuerId,
keyPem: fs.readFileSync(keyFile, "utf8"),
});
const csrContent = csrContentForApi(csrFile);
const created = await ascFetch(jwt, "https://api.appstoreconnect.apple.com/v1/certificates", {
method: "POST",
body: JSON.stringify({
data: {
type: "certificates",
attributes: {
certificateType,
csrContent,
},
},
}),
});
const cert = created?.data;
const id = cert?.id;
const content = cert?.attributes?.certificateContent;
if (!id || !content) {
throw new Error(`ASC response did not include certificate id/content: ${JSON.stringify(created).slice(0, 2000)}`);
}
fs.mkdirSync(outDir, { recursive: true });
const certificatePath = outFile ?? path.join(outDir, certificateFileName(certificateType, id));
fs.writeFileSync(certificatePath, Buffer.from(content, "base64"));
const metadata = {
id,
certificateType: cert?.attributes?.certificateType ?? certificateType,
displayName: cert?.attributes?.displayName,
name: cert?.attributes?.name,
platform: cert?.attributes?.platform,
expirationDate: cert?.attributes?.expirationDate,
serialNumber: cert?.attributes?.serialNumber,
certificatePath,
};
if (metadataFile) {
fs.writeFileSync(metadataFile, `${JSON.stringify(metadata, null, 2)}\n`);
}
process.stdout.write(`${JSON.stringify(metadata, null, 2)}\n`);
}
main().catch((err) => {
console.error(err?.stack || String(err));
process.exit(1);
});

249
Scripts/apple/google-kms-csr.py Executable file
View file

@ -0,0 +1,249 @@
#!/usr/bin/env python3
"""Build an Apple-compatible PKCS#10 CSR backed by Google Cloud KMS.
The script intentionally implements only the RSA/SHA-256 subset Burrow needs for
Apple Developer ID and iOS Distribution certificate requests. It fetches the KMS
public key, builds CertificationRequestInfo locally, asks KMS to sign that DER
payload, and writes a standard PEM CSR.
"""
from __future__ import annotations
import argparse
import base64
import os
import subprocess
import sys
import tempfile
from pathlib import Path
DEFAULT_PROJECT = "project-88c23ce9-918a-470a-b33"
DEFAULT_LOCATION = "global"
DEFAULT_KEYRING = "burrow-identity"
DEFAULT_KEY = "apple-developer-id-application"
DEFAULT_VERSION = "1"
DEFAULT_COMMON_NAME = "Burrow Developer ID Application"
DEFAULT_EMAIL = "release@burrow.net"
def der_length(length: int) -> bytes:
if length < 0:
raise ValueError("negative DER length")
if length < 0x80:
return bytes([length])
encoded = length.to_bytes((length.bit_length() + 7) // 8, "big")
return bytes([0x80 | len(encoded)]) + encoded
def der(tag: int, body: bytes) -> bytes:
return bytes([tag]) + der_length(len(body)) + body
def der_sequence(*items: bytes) -> bytes:
return der(0x30, b"".join(items))
def der_set(*items: bytes) -> bytes:
return der(0x31, b"".join(items))
def der_integer(value: int) -> bytes:
if value < 0:
raise ValueError("negative INTEGER is not supported")
raw = value.to_bytes(max(1, (value.bit_length() + 7) // 8), "big")
if raw[0] & 0x80:
raw = b"\x00" + raw
return der(0x02, raw)
def der_null() -> bytes:
return der(0x05, b"")
def der_bit_string(value: bytes) -> bytes:
return der(0x03, b"\x00" + value)
def der_utf8(value: str) -> bytes:
return der(0x0C, value.encode("utf-8"))
def der_printable(value: str) -> bytes:
return der(0x13, value.encode("ascii"))
def der_ia5(value: str) -> bytes:
return der(0x16, value.encode("ascii"))
def der_oid(dotted: str) -> bytes:
parts = [int(part) for part in dotted.split(".")]
if len(parts) < 2 or parts[0] > 2 or parts[1] > 39:
raise ValueError(f"unsupported OID: {dotted}")
body = bytes([40 * parts[0] + parts[1]])
for value in parts[2:]:
if value < 0:
raise ValueError(f"negative OID component: {dotted}")
encoded = [value & 0x7F]
value >>= 7
while value:
encoded.append(0x80 | (value & 0x7F))
value >>= 7
body += bytes(reversed(encoded))
return der(0x06, body)
def name_attribute(oid: str, value_der: bytes) -> bytes:
return der_set(der_sequence(der_oid(oid), value_der))
def subject_name(common_name: str, email_address: str, country: str | None) -> bytes:
attrs: list[bytes] = []
if country:
if len(country) != 2 or not country.isalpha():
raise ValueError("--country must be a two-letter ISO country code")
attrs.append(name_attribute("2.5.4.6", der_printable(country.upper())))
if email_address:
attrs.append(name_attribute("1.2.840.113549.1.9.1", der_ia5(email_address)))
attrs.append(name_attribute("2.5.4.3", der_utf8(common_name)))
return der_sequence(*attrs)
def pem_to_der(pem: bytes) -> bytes:
lines = [
line.strip()
for line in pem.splitlines()
if line and not line.startswith(b"-----")
]
if not lines:
raise ValueError("PEM input did not contain base64 data")
return base64.b64decode(b"".join(lines), validate=True)
def pem_wrap(label: str, body: bytes) -> str:
encoded = base64.b64encode(body).decode("ascii")
lines = [f"-----BEGIN {label}-----"]
lines.extend(encoded[i : i + 64] for i in range(0, len(encoded), 64))
lines.append(f"-----END {label}-----")
return "\n".join(lines) + "\n"
def run(command: list[str]) -> None:
subprocess.run(command, check=True)
def fetch_public_key(args: argparse.Namespace, output: Path) -> None:
command = [
args.gcloud,
"kms",
"keys",
"versions",
"get-public-key",
args.kms_version,
"--location",
args.kms_location,
"--keyring",
args.kms_keyring,
"--key",
args.kms_key,
"--output-file",
str(output),
]
if args.gcloud_project:
command.extend(["--project", args.gcloud_project])
run(command)
def kms_sign(args: argparse.Namespace, payload: bytes) -> bytes:
with tempfile.TemporaryDirectory(prefix="burrow-kms-csr.") as tmp:
payload_path = Path(tmp) / "certification-request-info.der"
signature_path = Path(tmp) / "signature.b64"
payload_path.write_bytes(payload)
command = [
args.gcloud,
"kms",
"asymmetric-sign",
"--location",
args.kms_location,
"--keyring",
args.kms_keyring,
"--key",
args.kms_key,
"--version",
args.kms_version,
"--digest-algorithm",
"sha256",
"--input-file",
str(payload_path),
"--signature-file",
str(signature_path),
]
if args.gcloud_project:
command.extend(["--project", args.gcloud_project])
run(command)
signature = signature_path.read_bytes()
try:
return base64.b64decode(signature.strip(), validate=True)
except ValueError:
return signature
def build_csr(args: argparse.Namespace, spki_der: bytes) -> bytes:
cri = der_sequence(
der_integer(0),
subject_name(args.common_name, args.email_address, args.country),
spki_der,
b"\xA0\x00",
)
signature = kms_sign(args, cri)
signature_algorithm = der_sequence(
der_oid("1.2.840.113549.1.1.11"),
der_null(),
)
return der_sequence(cri, signature_algorithm, der_bit_string(signature))
def parse_args(argv: list[str]) -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="Generate an Apple-compatible CSR backed by a Google Cloud KMS RSA signing key.",
)
parser.add_argument("--gcloud-project", default=os.environ.get("GOOGLE_CLOUD_PROJECT") or os.environ.get("BURROW_GOOGLE_PROJECT_ID") or DEFAULT_PROJECT)
parser.add_argument("--kms-location", default=os.environ.get("BURROW_KMS_LOCATION", DEFAULT_LOCATION))
parser.add_argument("--kms-keyring", default=os.environ.get("BURROW_KMS_KEYRING", DEFAULT_KEYRING))
parser.add_argument("--kms-key", default=os.environ.get("BURROW_APPLE_KMS_KEY") or os.environ.get("BURROW_APPLE_DEVELOPER_ID_KMS_KEY", DEFAULT_KEY))
parser.add_argument("--kms-version", default=os.environ.get("BURROW_KMS_VERSION", DEFAULT_VERSION))
parser.add_argument("--common-name", default=os.environ.get("BURROW_APPLE_CSR_COMMON_NAME") or os.environ.get("BURROW_APPLE_DEVELOPER_ID_CSR_COMMON_NAME", DEFAULT_COMMON_NAME))
parser.add_argument("--email-address", default=os.environ.get("BURROW_APPLE_CSR_EMAIL") or os.environ.get("BURROW_APPLE_DEVELOPER_ID_CSR_EMAIL", DEFAULT_EMAIL))
parser.add_argument("--country", default=os.environ.get("BURROW_APPLE_CSR_COUNTRY") or os.environ.get("BURROW_APPLE_DEVELOPER_ID_CSR_COUNTRY", "US"))
parser.add_argument("--public-key-pem", help="Existing KMS public-key PEM path. If omitted, gcloud fetches it.")
parser.add_argument("--public-key-output", help="Write the fetched/used public key PEM to this path.")
parser.add_argument("--output", default="developer-id-application.certSigningRequest")
parser.add_argument("--gcloud", default=os.environ.get("GCLOUD", "gcloud"))
return parser.parse_args(argv)
def main(argv: list[str]) -> int:
args = parse_args(argv)
output = Path(args.output)
with tempfile.TemporaryDirectory(prefix="burrow-kms-csr.") as tmp:
public_key_path = Path(args.public_key_pem) if args.public_key_pem else Path(tmp) / "kms-public.pem"
if not args.public_key_pem:
fetch_public_key(args, public_key_path)
public_key_pem = public_key_path.read_bytes()
spki_der = pem_to_der(public_key_pem)
csr = build_csr(args, spki_der)
output.parent.mkdir(parents=True, exist_ok=True)
output.write_text(pem_wrap("CERTIFICATE REQUEST", csr), encoding="ascii")
if args.public_key_output:
public_output = Path(args.public_key_output)
public_output.parent.mkdir(parents=True, exist_ok=True)
public_output.write_bytes(public_key_pem)
print(f"wrote {output}")
return 0
if __name__ == "__main__":
raise SystemExit(main(sys.argv[1:]))

View file

@ -0,0 +1,327 @@
#!/usr/bin/env node
import fs from "node:fs";
import path from "node:path";
import { webcrypto } from "node:crypto";
const crypto = globalThis.crypto ?? webcrypto;
function b64url(input) {
const buf = Buffer.isBuffer(input) ? input : Buffer.from(input);
return buf
.toString("base64")
.replace(/=/g, "")
.replace(/\+/g, "-")
.replace(/\//g, "_");
}
function parseArgs(argv) {
const out = { _: [] };
for (let i = 2; i < argv.length; i++) {
const arg = argv[i];
if (arg === "--help" || arg === "-h") out.help = true;
else if (arg.startsWith("--")) {
const key = arg.slice(2);
const value = argv[i + 1];
if (!value || value.startsWith("--")) throw new Error(`missing value for --${key}`);
out[key] = value;
i++;
} else {
out._.push(arg);
}
}
return out;
}
async function makeJwt({ keyId, issuerId, keyPem }) {
const header = { alg: "ES256", kid: keyId, typ: "JWT" };
const now = Math.floor(Date.now() / 1000);
const payload = { iss: issuerId, exp: now + 20 * 60, aud: "appstoreconnect-v1" };
const input = `${b64url(JSON.stringify(header))}.${b64url(JSON.stringify(payload))}`;
const pkcs8Der = Buffer.from(
keyPem.replace(/-----BEGIN PRIVATE KEY-----|-----END PRIVATE KEY-----|\s+/g, ""),
"base64",
);
const key = await crypto.subtle.importKey(
"pkcs8",
pkcs8Der,
{ name: "ECDSA", namedCurve: "P-256" },
false,
["sign"],
);
const sig = await crypto.subtle.sign(
{ name: "ECDSA", hash: "SHA-256" },
key,
new TextEncoder().encode(input),
);
return `${input}.${b64url(Buffer.from(sig))}`;
}
async function ascFetch(jwt, url, opts = {}) {
const res = await fetch(url, {
...opts,
headers: {
...(opts.headers ?? {}),
Authorization: `Bearer ${jwt}`,
"Content-Type": "application/json",
},
});
const text = await res.text();
let body;
try {
body = text ? JSON.parse(text) : {};
} catch {
body = { raw: text };
}
if (!res.ok) {
throw new Error(`ASC ${res.status} ${url}: ${JSON.stringify(body).slice(0, 2000)}`);
}
return body;
}
function truthy(value) {
return ["1", "true", "yes", "on"].includes(String(value ?? "").toLowerCase());
}
function safeFileName(identifier, suffix) {
return `${identifier.replace(/[^A-Za-z0-9]/g, "").toLowerCase()}${suffix}`;
}
function bundleDisplayName(identifier, appId) {
return identifier === appId ? "Burrow App" : "Burrow Network Extension";
}
async function findCertificate(jwt, preferredTypes, preferredId) {
const certs = await ascFetch(
jwt,
"https://api.appstoreconnect.apple.com/v1/certificates?limit=200",
);
const data = certs.data ?? [];
if (preferredId) {
const cert = data.find((item) => item?.id === preferredId);
if (!cert) throw new Error(`unable to locate certificate id ${preferredId} via ASC API`);
const type = cert?.attributes?.certificateType;
if (preferredTypes.length && !preferredTypes.includes(type)) {
throw new Error(`certificate ${preferredId} has type ${type}, expected ${preferredTypes.join(" or ")}`);
}
return cert;
}
for (const type of preferredTypes) {
const cert = data.find((item) => item?.attributes?.certificateType === type);
if (cert) return cert;
}
throw new Error(`unable to locate certificate type ${preferredTypes.join(" or ")} via ASC API`);
}
async function resolveBundle(jwt, target, { createMissing, platform }) {
const bundleResp = await ascFetch(
jwt,
`https://api.appstoreconnect.apple.com/v1/bundleIds?limit=5&filter[identifier]=${encodeURIComponent(target.identifier)}`,
);
let bundle = (bundleResp.data ?? []).find(
(item) => item?.attributes?.identifier === target.identifier,
);
if (bundle || !createMissing) return bundle;
const created = await ascFetch(jwt, "https://api.appstoreconnect.apple.com/v1/bundleIds", {
method: "POST",
body: JSON.stringify({
data: {
type: "bundleIds",
attributes: {
identifier: target.identifier,
name: target.bundleName ?? target.name,
platform,
},
},
}),
});
bundle = created?.data;
if (bundle?.id) {
process.stdout.write(`created bundle id ${target.identifier} (${platform})\n`);
}
return bundle;
}
async function listCapabilityTypes(jwt, bundleId) {
const resp = await ascFetch(
jwt,
`https://api.appstoreconnect.apple.com/v1/bundleIds/${bundleId}/bundleIdCapabilities`,
);
return new Set((resp.data ?? []).map((item) => item?.attributes?.capabilityType).filter(Boolean));
}
async function enableCapability(jwt, bundleId, capabilityType, settings) {
await ascFetch(jwt, "https://api.appstoreconnect.apple.com/v1/bundleIdCapabilities", {
method: "POST",
body: JSON.stringify({
data: {
type: "bundleIdCapabilities",
attributes: {
capabilityType,
...(settings ? { settings } : {}),
},
relationships: {
bundleId: {
data: {
id: bundleId,
type: "bundleIds",
},
},
},
},
}),
});
}
async function profileIncludesCertificate(jwt, profileId, certificateId) {
const resp = await ascFetch(
jwt,
`https://api.appstoreconnect.apple.com/v1/profiles/${profileId}/certificates?limit=200`,
);
return (resp.data ?? []).some((item) => item?.id === certificateId);
}
async function ensureCapabilities(jwt, bundle, capabilities) {
const existing = await listCapabilityTypes(jwt, bundle.id);
for (const capability of capabilities) {
if (existing.has(capability.type)) continue;
try {
await enableCapability(jwt, bundle.id, capability.type, capability.settings);
process.stdout.write(`enabled ${capability.type} for ${bundle.attributes.identifier}\n`);
} catch (err) {
process.stderr.write(`warning: could not enable ${capability.type} for ${bundle.attributes.identifier}: ${err.message}\n`);
}
}
}
async function syncTarget(jwt, cert, profileType, target, outDir, options) {
const bundle = await resolveBundle(jwt, target, options);
if (!bundle) throw new Error(`bundleId not found in ASC: ${target.identifier}`);
if (options.enableCapabilities && target.capabilities?.length) {
await ensureCapabilities(jwt, bundle, target.capabilities);
}
const existing = await ascFetch(
jwt,
`https://api.appstoreconnect.apple.com/v1/profiles?limit=10&filter[profileType]=${encodeURIComponent(profileType)}&filter[name]=${encodeURIComponent(target.name)}`,
);
let profileId = (existing.data ?? []).find((item) => item?.attributes?.name === target.name)?.id;
if (profileId && options.replaceCertificateMismatch) {
const includesCertificate = await profileIncludesCertificate(jwt, profileId, cert.id);
if (!includesCertificate) {
await ascFetch(jwt, `https://api.appstoreconnect.apple.com/v1/profiles/${profileId}`, {
method: "DELETE",
});
process.stdout.write(`deleted stale profile ${target.name}; it did not include certificate ${cert.id}\n`);
profileId = undefined;
}
}
if (!profileId) {
const created = await ascFetch(jwt, "https://api.appstoreconnect.apple.com/v1/profiles", {
method: "POST",
body: JSON.stringify({
data: {
type: "profiles",
attributes: { name: target.name, profileType },
relationships: {
bundleId: { data: { type: "bundleIds", id: bundle.id } },
certificates: { data: [{ type: "certificates", id: cert.id }] },
},
},
}),
});
profileId = created?.data?.id;
}
if (!profileId) throw new Error(`failed to resolve/create profile for ${target.identifier}`);
const profile = await ascFetch(jwt, `https://api.appstoreconnect.apple.com/v1/profiles/${profileId}`);
const content = profile?.data?.attributes?.profileContent;
if (!content) throw new Error(`profileContent missing for ${target.name} (${profileId})`);
fs.mkdirSync(outDir, { recursive: true });
const profileBytes = Buffer.from(content, "base64");
const profilePath = path.join(outDir, target.file);
fs.writeFileSync(profilePath, profileBytes);
if (target.mobileFile) {
fs.writeFileSync(path.join(outDir, target.mobileFile), profileBytes);
}
process.stdout.write(`synced ${target.identifier} -> ${profilePath}\n`);
}
async function main() {
const args = parseArgs(process.argv);
if (args.help) {
console.log("Usage: sync-provisioning-profiles.mjs --platform ios|macos|all --key-id ID --issuer-id ID --key-file AuthKey.p8 --out-dir DIR [--ios-certificate-id ID] [--macos-certificate-id ID] [--replace-certificate-mismatch true]");
process.exit(0);
}
const platform = args.platform ?? "all";
const keyId = args["key-id"];
const issuerId = args["issuer-id"];
const keyFile = args["key-file"];
const outDir = args["out-dir"] ?? ".forgejo/actions/export";
const appId = args["app-id"] ?? "com.hackclub.burrow";
const networkId = args["network-id"] ?? `${appId}.network`;
const iosCertificateId = args["ios-certificate-id"] ?? process.env.BURROW_IOS_DISTRIBUTION_CERTIFICATE_ID;
const macosCertificateId = args["macos-certificate-id"] ?? process.env.BURROW_DEVELOPER_ID_CERTIFICATE_ID;
const createMissing = truthy(args["create-missing-bundle-ids"]);
const enableCapabilities = createMissing || truthy(args["enable-capabilities"]);
const replaceCertificateMismatch = truthy(args["replace-certificate-mismatch"]);
const bundlePlatform = args["bundle-platform"] ?? "UNIVERSAL";
const associatedDomains = (args["associated-domains"] ?? "applinks:burrow.rs?mode=developer,webcredentials:burrow.rs?mode=developer")
.split(",")
.map((item) => item.trim())
.filter(Boolean);
const appCapabilities = [
{ type: "NETWORK_EXTENSIONS" },
{ type: "APP_GROUPS" },
{
type: "ASSOCIATED_DOMAINS",
settings: [{ key: "ASSOCIATED_DOMAIN_IDS", options: associatedDomains.map((key) => ({ key, enabled: true })) }],
},
];
const extensionCapabilities = [
{ type: "NETWORK_EXTENSIONS" },
{ type: "APP_GROUPS" },
];
const options = { createMissing, enableCapabilities, replaceCertificateMismatch, platform: bundlePlatform };
if (!keyId || !issuerId || !keyFile) throw new Error("required: --key-id --issuer-id --key-file");
const jwt = await makeJwt({ keyId, issuerId, keyPem: fs.readFileSync(keyFile, "utf8") });
if (platform === "ios" || platform === "all") {
const cert = await findCertificate(jwt, ["IOS_DISTRIBUTION", "DISTRIBUTION", "APPLE_DISTRIBUTION"], iosCertificateId);
const targets = [appId, networkId].map((identifier) => ({
identifier,
name: `${identifier}.app-store`,
bundleName: bundleDisplayName(identifier, appId),
file: safeFileName(identifier, "appstore.provisionprofile"),
mobileFile: safeFileName(identifier, "appstore.mobileprovision"),
capabilities: identifier === appId ? appCapabilities : extensionCapabilities,
}));
for (const target of targets) {
await syncTarget(jwt, cert, "IOS_APP_STORE", target, outDir, options);
}
}
if (platform === "macos" || platform === "all") {
const cert = await findCertificate(jwt, ["DEVELOPER_ID_APPLICATION"], macosCertificateId);
const targets = [appId, networkId].map((identifier) => ({
identifier,
name: `${identifier}.developer-id`,
bundleName: bundleDisplayName(identifier, appId),
file: safeFileName(identifier, "developerid.provisionprofile"),
capabilities: identifier === appId ? appCapabilities : extensionCapabilities,
}));
for (const target of targets) {
await syncTarget(jwt, cert, "MAC_APP_DIRECT", target, outDir, options);
}
}
}
main().catch((err) => {
console.error(err?.stack || String(err));
process.exit(1);
});

View file

@ -6,6 +6,7 @@ bootstrap_token="${AUTHENTIK_BOOTSTRAP_TOKEN:-}"
directory_json="${AUTHENTIK_BURROW_DIRECTORY_JSON:-[]}"
users_group="${AUTHENTIK_BURROW_USERS_GROUP:-burrow-users}"
admins_group="${AUTHENTIK_BURROW_ADMINS_GROUP:-burrow-admins}"
extra_groups_json="${AUTHENTIK_BURROW_EXTRA_GROUPS_JSON:-[]}"
forgejo_application_slug="${AUTHENTIK_FORGEJO_APPLICATION_SLUG:-}"
usage() {
@ -20,6 +21,7 @@ Optional environment:
AUTHENTIK_URL
AUTHENTIK_BURROW_USERS_GROUP
AUTHENTIK_BURROW_ADMINS_GROUP
AUTHENTIK_BURROW_EXTRA_GROUPS_JSON
AUTHENTIK_FORGEJO_APPLICATION_SLUG
EOF
}
@ -39,6 +41,11 @@ if ! printf '%s' "$directory_json" | jq -e 'type == "array"' >/dev/null; then
exit 1
fi
if ! printf '%s' "$extra_groups_json" | jq -e 'type == "array"' >/dev/null; then
echo "error: AUTHENTIK_BURROW_EXTRA_GROUPS_JSON must be a JSON array" >&2
exit 1
fi
api() {
local method="$1"
local path="$2"
@ -251,6 +258,9 @@ ensure_application_group_binding() {
wait_for_authentik
ensure_group "$users_group" >/dev/null
ensure_group "$admins_group" >/dev/null
while IFS= read -r group_name; do
ensure_group "$group_name" >/dev/null
done < <(printf '%s\n' "$extra_groups_json" | jq -r '.[]')
while IFS= read -r user_spec; do
ensure_user "$user_spec"

View file

@ -11,8 +11,7 @@ client_secret="${AUTHENTIK_FORGEJO_CLIENT_SECRET:-}"
launch_url="${AUTHENTIK_FORGEJO_LAUNCH_URL:-https://git.burrow.net/}"
redirect_uris_json="${AUTHENTIK_FORGEJO_REDIRECT_URIS_JSON:-[
\"https://git.burrow.net/user/oauth2/burrow.net/callback\",
\"https://git.burrow.net/user/oauth2/authentik/callback\",
\"https://git.burrow.net/user/oauth2/GitHub/callback\"
\"https://git.burrow.net/user/oauth2/authentik/callback\"
]}"
usage() {

View file

@ -0,0 +1,285 @@
#!/usr/bin/env bash
set -euo pipefail
authentik_url="${AUTHENTIK_URL:-https://auth.burrow.net}"
bootstrap_token="${AUTHENTIK_BOOTSTRAP_TOKEN:-}"
application_slug="${AUTHENTIK_GOOGLE_WIF_APPLICATION_SLUG:-google-cloud}"
application_name="${AUTHENTIK_GOOGLE_WIF_APPLICATION_NAME:-Google Cloud WIF}"
provider_name="${AUTHENTIK_GOOGLE_WIF_PROVIDER_NAME:-Google Cloud WIF}"
template_slug="${AUTHENTIK_GOOGLE_WIF_TEMPLATE_SLUG:-ts}"
client_id="${AUTHENTIK_GOOGLE_WIF_CLIENT_ID:-google-wif.burrow.net}"
client_secret="${AUTHENTIK_GOOGLE_WIF_CLIENT_SECRET:-}"
launch_url="${AUTHENTIK_GOOGLE_WIF_LAUNCH_URL:-https://console.cloud.google.com/}"
redirect_uris_json="${AUTHENTIK_GOOGLE_WIF_REDIRECT_URIS_JSON:-[]}"
access_group="${AUTHENTIK_GOOGLE_WIF_ACCESS_GROUP:-burrow-automation}"
usage() {
cat <<'EOF'
Usage: Scripts/authentik-sync-google-wif-oidc.sh
Reconciles the Authentik OIDC provider used as the Google Workload Identity
Federation issuer for Burrow release runners.
Required environment:
AUTHENTIK_BOOTSTRAP_TOKEN
AUTHENTIK_GOOGLE_WIF_CLIENT_SECRET
Optional environment:
AUTHENTIK_URL
AUTHENTIK_GOOGLE_WIF_APPLICATION_SLUG
AUTHENTIK_GOOGLE_WIF_APPLICATION_NAME
AUTHENTIK_GOOGLE_WIF_PROVIDER_NAME
AUTHENTIK_GOOGLE_WIF_TEMPLATE_SLUG
AUTHENTIK_GOOGLE_WIF_CLIENT_ID
AUTHENTIK_GOOGLE_WIF_LAUNCH_URL
AUTHENTIK_GOOGLE_WIF_REDIRECT_URIS_JSON
AUTHENTIK_GOOGLE_WIF_ACCESS_GROUP
EOF
}
if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then
usage
exit 0
fi
if [[ -z "$bootstrap_token" ]]; then
echo "error: AUTHENTIK_BOOTSTRAP_TOKEN is required" >&2
exit 1
fi
if [[ -z "$client_secret" || "$client_secret" == PENDING* ]]; then
echo "Google WIF OIDC client secret is not configured; skipping Authentik Google WIF sync." >&2
exit 0
fi
if ! printf '%s' "$redirect_uris_json" | jq -e 'type == "array"' >/dev/null; then
echo "error: AUTHENTIK_GOOGLE_WIF_REDIRECT_URIS_JSON must be a JSON array" >&2
exit 1
fi
api() {
local method="$1"
local path="$2"
local data="${3:-}"
if [[ -n "$data" ]]; then
curl -fsS \
-X "$method" \
-H "Authorization: Bearer ${bootstrap_token}" \
-H "Content-Type: application/json" \
-d "$data" \
"${authentik_url}${path}"
else
curl -fsS \
-X "$method" \
-H "Authorization: Bearer ${bootstrap_token}" \
"${authentik_url}${path}"
fi
}
api_with_status() {
local method="$1"
local path="$2"
local data="${3:-}"
local response_file status
response_file="$(mktemp)"
trap 'rm -f "$response_file"' RETURN
if [[ -n "$data" ]]; then
status="$(
curl -sS \
-o "$response_file" \
-w '%{http_code}' \
-X "$method" \
-H "Authorization: Bearer ${bootstrap_token}" \
-H "Content-Type: application/json" \
-d "$data" \
"${authentik_url}${path}"
)"
else
status="$(
curl -sS \
-o "$response_file" \
-w '%{http_code}' \
-X "$method" \
-H "Authorization: Bearer ${bootstrap_token}" \
"${authentik_url}${path}"
)"
fi
printf '%s\n' "$status"
cat "$response_file"
}
wait_for_authentik() {
for _ in $(seq 1 90); do
if curl -fsS "${authentik_url}/-/health/ready/" >/dev/null 2>&1; then
return 0
fi
sleep 2
done
echo "error: Authentik did not become ready at ${authentik_url}" >&2
exit 1
}
wait_for_authentik
template_provider="$(
api GET "/api/v3/providers/oauth2/?page_size=200" \
| jq -c --arg slug "$template_slug" '.results[]? | select(.assigned_application_slug == $slug)' \
| head -n1
)"
if [[ -z "$template_provider" ]]; then
echo "error: could not resolve Authentik OAuth provider template ${template_slug}" >&2
exit 1
fi
authorization_flow="$(printf '%s\n' "$template_provider" | jq -r '.authorization_flow')"
invalidation_flow="$(printf '%s\n' "$template_provider" | jq -r '.invalidation_flow')"
property_mappings="$(printf '%s\n' "$template_provider" | jq -c '.property_mappings')"
signing_key="$(printf '%s\n' "$template_provider" | jq -r '.signing_key')"
provider_payload="$(
jq -n \
--arg name "$provider_name" \
--arg authorization_flow "$authorization_flow" \
--arg invalidation_flow "$invalidation_flow" \
--arg client_id "$client_id" \
--arg client_secret "$client_secret" \
--arg signing_key "$signing_key" \
--argjson property_mappings "$property_mappings" \
--argjson redirect_uris "$redirect_uris_json" \
'{
name: $name,
authorization_flow: $authorization_flow,
invalidation_flow: $invalidation_flow,
client_type: "confidential",
grant_types: ["client_credentials"],
client_id: $client_id,
client_secret: $client_secret,
include_claims_in_id_token: true,
redirect_uris: ($redirect_uris | map({matching_mode: "strict", url: .})),
property_mappings: $property_mappings,
signing_key: $signing_key,
issuer_mode: "per_provider",
sub_mode: "hashed_user_id"
}'
)"
existing_provider="$(
api GET "/api/v3/providers/oauth2/?page_size=200" \
| jq -c \
--arg application_slug "$application_slug" \
--arg provider_name "$provider_name" \
'.results[]? | select(.assigned_application_slug == $application_slug or .name == $provider_name)' \
| head -n1
)"
if [[ -n "$existing_provider" ]]; then
provider_pk="$(printf '%s\n' "$existing_provider" | jq -r '.pk')"
api PATCH "/api/v3/providers/oauth2/${provider_pk}/" "$provider_payload" >/dev/null
else
provider_pk="$(
api POST "/api/v3/providers/oauth2/" "$provider_payload" \
| jq -r '.pk // empty'
)"
fi
if [[ -z "${provider_pk:-}" ]]; then
echo "error: Google WIF OIDC provider did not return a primary key" >&2
exit 1
fi
application_payload="$(
jq -n \
--arg name "$application_name" \
--arg slug "$application_slug" \
--arg provider "$provider_pk" \
--arg launch_url "$launch_url" \
'{
name: $name,
slug: $slug,
provider: ($provider | tonumber),
meta_launch_url: $launch_url,
open_in_new_tab: false,
policy_engine_mode: "any"
}'
)"
existing_application="$(
api GET "/api/v3/core/applications/?page_size=200" \
| jq -c --arg slug "$application_slug" '.results[]? | select(.slug == $slug)' \
| head -n1
)"
if [[ -n "$existing_application" ]]; then
application_pk="$(printf '%s\n' "$existing_application" | jq -r '.pk')"
api PATCH "/api/v3/core/applications/${application_pk}/" "$application_payload" >/dev/null
else
create_application_result="$(
api_with_status POST "/api/v3/core/applications/" "$application_payload"
)"
create_application_status="$(printf '%s\n' "$create_application_result" | sed -n '1p')"
create_application_body="$(printf '%s\n' "$create_application_result" | sed '1d')"
if [[ "$create_application_status" =~ ^20[01]$ ]]; then
application_pk="$(printf '%s\n' "$create_application_body" | jq -r '.pk // empty')"
elif [[ "$create_application_status" == "400" ]] && printf '%s\n' "$create_application_body" | jq -e '
(.slug // [] | index("Application with this slug already exists.")) != null
or (.provider // [] | index("Application with this provider already exists.")) != null
' >/dev/null; then
application_pk="$(
api GET "/api/v3/core/applications/?page_size=200" \
| jq -r --arg slug "$application_slug" '.results[]? | select(.slug == $slug) | .pk // empty' \
| head -n1
)"
else
printf '%s\n' "$create_application_body" >&2
echo "error: could not reconcile Authentik application ${application_slug}" >&2
exit 1
fi
fi
if [[ -z "${application_pk:-}" ]]; then
echo "error: Google WIF OIDC application did not return a primary key" >&2
exit 1
fi
if [[ -n "$access_group" ]]; then
group_pk="$(
api GET "/api/v3/core/groups/?page_size=200" \
| jq -r --arg name "$access_group" '.results[]? | select(.name == $name) | .pk' \
| head -n1
)"
if [[ -z "$group_pk" ]]; then
echo "warning: Authentik Google WIF access group ${access_group} was not found; application policy left unchanged." >&2
else
api POST "/api/v3/policies/bindings/" "$(
jq -n \
--arg target "$application_pk" \
--arg group "$group_pk" \
'{
target: $target,
group: $group,
negate: false,
order: 0,
enabled: true,
timeout: 30,
failure_result: false
}'
)" >/dev/null || true
fi
fi
for _ in $(seq 1 30); do
if curl -fsS "${authentik_url}/application/o/${application_slug}/.well-known/openid-configuration" >/dev/null 2>&1; then
echo "Synced Authentik Google WIF OIDC application ${application_slug} (${application_name})."
exit 0
fi
sleep 2
done
echo "warning: Google WIF OIDC issuer document for ${application_slug} was not immediately readable; keeping reconciled config." >&2
echo "Synced Authentik Google WIF OIDC application ${application_slug} (${application_name})."

View file

@ -0,0 +1,332 @@
#!/usr/bin/env bash
set -euo pipefail
authentik_url="${AUTHENTIK_URL:-https://auth.burrow.net}"
bootstrap_token="${AUTHENTIK_BOOTSTRAP_TOKEN:-}"
application_slug="${AUTHENTIK_GRAFANA_APPLICATION_SLUG:-grafana}"
application_name="${AUTHENTIK_GRAFANA_APPLICATION_NAME:-Burrow Grafana}"
provider_name="${AUTHENTIK_GRAFANA_PROVIDER_NAME:-Burrow Grafana}"
template_slug="${AUTHENTIK_GRAFANA_TEMPLATE_SLUG:-ts}"
client_id="${AUTHENTIK_GRAFANA_CLIENT_ID:-graphs.burrow.net}"
client_secret="${AUTHENTIK_GRAFANA_CLIENT_SECRET:-}"
launch_url="${AUTHENTIK_GRAFANA_LAUNCH_URL:-https://graphs.burrow.net/}"
access_group="${AUTHENTIK_GRAFANA_ACCESS_GROUP:-}"
redirect_uris_json="${AUTHENTIK_GRAFANA_REDIRECT_URIS_JSON:-[
\"https://graphs.burrow.net/login/generic_oauth\"
]}"
usage() {
cat <<'EOF'
Usage: Scripts/authentik-sync-grafana-oidc.sh
Required environment:
AUTHENTIK_BOOTSTRAP_TOKEN
AUTHENTIK_GRAFANA_CLIENT_SECRET
Optional environment:
AUTHENTIK_URL
AUTHENTIK_GRAFANA_APPLICATION_SLUG
AUTHENTIK_GRAFANA_APPLICATION_NAME
AUTHENTIK_GRAFANA_PROVIDER_NAME
AUTHENTIK_GRAFANA_TEMPLATE_SLUG
AUTHENTIK_GRAFANA_CLIENT_ID
AUTHENTIK_GRAFANA_LAUNCH_URL
AUTHENTIK_GRAFANA_REDIRECT_URIS_JSON
AUTHENTIK_GRAFANA_ACCESS_GROUP
EOF
}
if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then
usage
exit 0
fi
if [[ -z "$bootstrap_token" ]]; then
echo "error: AUTHENTIK_BOOTSTRAP_TOKEN is required" >&2
exit 1
fi
if [[ -z "$client_secret" || "$client_secret" == PENDING* ]]; then
echo "Grafana OIDC client secret is not configured; skipping Authentik Grafana sync." >&2
exit 0
fi
if ! printf '%s' "$redirect_uris_json" | jq -e 'type == "array" and length > 0' >/dev/null; then
echo "error: AUTHENTIK_GRAFANA_REDIRECT_URIS_JSON must be a non-empty JSON array" >&2
exit 1
fi
api() {
local method="$1"
local path="$2"
local data="${3:-}"
if [[ -n "$data" ]]; then
curl -fsS \
-X "$method" \
-H "Authorization: Bearer ${bootstrap_token}" \
-H "Content-Type: application/json" \
-d "$data" \
"${authentik_url}${path}"
else
curl -fsS \
-X "$method" \
-H "Authorization: Bearer ${bootstrap_token}" \
"${authentik_url}${path}"
fi
}
api_with_status() {
local method="$1"
local path="$2"
local data="${3:-}"
local response_file status
response_file="$(mktemp)"
trap 'rm -f "$response_file"' RETURN
if [[ -n "$data" ]]; then
status="$(
curl -sS \
-o "$response_file" \
-w '%{http_code}' \
-X "$method" \
-H "Authorization: Bearer ${bootstrap_token}" \
-H "Content-Type: application/json" \
-d "$data" \
"${authentik_url}${path}"
)"
else
status="$(
curl -sS \
-o "$response_file" \
-w '%{http_code}' \
-X "$method" \
-H "Authorization: Bearer ${bootstrap_token}" \
"${authentik_url}${path}"
)"
fi
printf '%s\n' "$status"
cat "$response_file"
}
wait_for_authentik() {
for _ in $(seq 1 90); do
if curl -fsS "${authentik_url}/-/health/ready/" >/dev/null 2>&1; then
return 0
fi
sleep 2
done
echo "error: Authentik did not become ready at ${authentik_url}" >&2
exit 1
}
lookup_group_pk() {
local group_name="$1"
api GET "/api/v3/core/groups/?page_size=200" \
| jq -r --arg group_name "$group_name" '.results[]? | select(.name == $group_name) | .pk // empty' \
| head -n1
}
lookup_application_pk() {
local slug="$1"
local application_pk lookup_result lookup_status
application_pk="$(
api GET "/api/v3/core/applications/?page_size=200" \
| jq -r --arg slug "$slug" '.results[]? | select(.slug == $slug) | .pk // empty' \
| head -n1
)"
if [[ -n "$application_pk" ]]; then
printf '%s\n' "$application_pk"
return 0
fi
lookup_result="$(api_with_status GET "/api/v3/core/applications/${slug}/")"
lookup_status="$(printf '%s\n' "$lookup_result" | sed -n '1p')"
if [[ "$lookup_status" =~ ^20[01]$ ]]; then
printf '%s\n' "$lookup_result" | sed '1d' | jq -r '.pk // empty'
fi
}
ensure_application_group_binding() {
local application_slug="$1"
local group_name="$2"
local application_pk group_pk existing payload binding_pk
application_pk="$(lookup_application_pk "$application_slug")"
if [[ -z "$application_pk" ]]; then
echo "warning: could not resolve Authentik application ${application_slug}; skipping application group binding" >&2
return 0
fi
group_pk="$(lookup_group_pk "$group_name")"
if [[ -z "$group_pk" ]]; then
echo "error: could not resolve Authentik group ${group_name}" >&2
exit 1
fi
existing="$(
api GET "/api/v3/policies/bindings/?page_size=200&target=${application_pk}" \
| jq -c --arg group_pk "$group_pk" '.results[]? | select(.group == $group_pk)' \
| head -n1
)"
payload="$(
jq -cn \
--arg target "$application_pk" \
--arg group "$group_pk" \
'{
group: $group,
target: $target,
negate: false,
enabled: true,
order: 100,
timeout: 30,
failure_result: false
}'
)"
if [[ -n "$existing" ]]; then
binding_pk="$(printf '%s\n' "$existing" | jq -r '.pk')"
api PATCH "/api/v3/policies/bindings/${binding_pk}/" "$payload" >/dev/null
else
api POST "/api/v3/policies/bindings/" "$payload" >/dev/null
fi
}
wait_for_authentik
template_provider="$(
api GET "/api/v3/providers/oauth2/?page_size=200" \
| jq -c --arg template_slug "$template_slug" '.results[]? | select(.assigned_application_slug == $template_slug)' \
| head -n1
)"
if [[ -z "$template_provider" ]]; then
echo "error: could not resolve the Authentik OAuth provider template ${template_slug}" >&2
exit 1
fi
authorization_flow="$(printf '%s\n' "$template_provider" | jq -r '.authorization_flow')"
invalidation_flow="$(printf '%s\n' "$template_provider" | jq -r '.invalidation_flow')"
property_mappings="$(printf '%s\n' "$template_provider" | jq -c '.property_mappings')"
signing_key="$(printf '%s\n' "$template_provider" | jq -r '.signing_key')"
provider_payload="$(
jq -n \
--arg name "$provider_name" \
--arg authorization_flow "$authorization_flow" \
--arg invalidation_flow "$invalidation_flow" \
--arg client_id "$client_id" \
--arg client_secret "$client_secret" \
--arg signing_key "$signing_key" \
--argjson property_mappings "$property_mappings" \
--argjson redirect_uris "$redirect_uris_json" \
'{
name: $name,
authorization_flow: $authorization_flow,
invalidation_flow: $invalidation_flow,
client_type: "confidential",
client_id: $client_id,
client_secret: $client_secret,
include_claims_in_id_token: true,
redirect_uris: ($redirect_uris | map({matching_mode: "strict", url: .})),
property_mappings: $property_mappings,
signing_key: $signing_key,
issuer_mode: "per_provider",
sub_mode: "hashed_user_id"
}'
)"
existing_provider="$(
api GET "/api/v3/providers/oauth2/?page_size=200" \
| jq -c \
--arg application_slug "$application_slug" \
--arg provider_name "$provider_name" \
'.results[]? | select(.assigned_application_slug == $application_slug or .name == $provider_name)' \
| head -n1
)"
if [[ -n "$existing_provider" ]]; then
provider_pk="$(printf '%s\n' "$existing_provider" | jq -r '.pk')"
api PATCH "/api/v3/providers/oauth2/${provider_pk}/" "$provider_payload" >/dev/null
else
provider_pk="$(
api POST "/api/v3/providers/oauth2/" "$provider_payload" \
| jq -r '.pk // empty'
)"
fi
if [[ -z "${provider_pk:-}" ]]; then
echo "error: Grafana OIDC provider did not return a primary key" >&2
exit 1
fi
application_payload="$(
jq -n \
--arg name "$application_name" \
--arg slug "$application_slug" \
--arg provider "$provider_pk" \
--arg launch_url "$launch_url" \
'{
name: $name,
slug: $slug,
provider: ($provider | tonumber),
meta_launch_url: $launch_url,
open_in_new_tab: false,
policy_engine_mode: "any"
}'
)"
existing_application="$(
api GET "/api/v3/core/applications/?page_size=200" \
| jq -c --arg slug "$application_slug" '.results[]? | select(.slug == $slug)' \
| head -n1
)"
if [[ -n "$existing_application" ]]; then
application_pk="$(printf '%s\n' "$existing_application" | jq -r '.pk')"
else
create_application_result="$(
api_with_status POST "/api/v3/core/applications/" "$application_payload"
)"
create_application_status="$(printf '%s\n' "$create_application_result" | sed -n '1p')"
create_application_body="$(printf '%s\n' "$create_application_result" | sed '1d')"
if [[ "$create_application_status" =~ ^20[01]$ ]]; then
application_pk="$(printf '%s\n' "$create_application_body" | jq -r '.pk // empty')"
elif [[ "$create_application_status" == "400" ]] && printf '%s\n' "$create_application_body" | jq -e '
(.slug // [] | index("Application with this slug already exists.")) != null
or (.provider // [] | index("Application with this provider already exists.")) != null
' >/dev/null; then
application_pk="existing-duplicate"
else
printf '%s\n' "$create_application_body" >&2
echo "error: could not reconcile Authentik application ${application_slug}" >&2
exit 1
fi
fi
if [[ -z "${application_pk:-}" ]]; then
echo "error: Grafana OIDC application did not return a primary key" >&2
exit 1
fi
if [[ -n "$access_group" ]]; then
ensure_application_group_binding "$application_slug" "$access_group"
fi
for _ in $(seq 1 30); do
if curl -fsS "${authentik_url}/application/o/${application_slug}/.well-known/openid-configuration" >/dev/null 2>&1; then
echo "Synced Authentik Grafana OIDC application ${application_slug} (${application_name})."
exit 0
fi
sleep 2
done
echo "warning: Grafana OIDC issuer document for ${application_slug} was not immediately readable; keeping reconciled config." >&2
echo "Synced Authentik Grafana OIDC application ${application_slug} (${application_name})."

View file

@ -75,6 +75,7 @@ set -euo pipefail
base_services=(
forgejo.service
grafana.service
caddy.service
burrow-forgejo-bootstrap.service
burrow-forgejo-runner-bootstrap.service
@ -89,6 +90,7 @@ nsc_services=(
tailnet_services=(
burrow-authentik-runtime.service
burrow-authentik-ready.service
burrow-authentik-grafana-oidc.service
headscale.service
headscale-bootstrap.service
)
@ -161,6 +163,8 @@ if [[ "${EXPECT_TAILNET}" == "1" ]]; then
echo "== agenix =="
ls -l /run/agenix || true
test -s /run/agenix/burrowAuthentikEnv
test -s /run/agenix/burrowGrafanaAdminPassword
test -s /run/agenix/burrowGrafanaOidcClientSecret
test -s /run/agenix/burrowHeadscaleOidcClientSecret
fi
@ -177,6 +181,7 @@ if command -v curl >/dev/null 2>&1; then
curl -fsS -o /dev/null -w 'forgejo_login %{http_code}\n' http://127.0.0.1:3000/user/login
curl -fsS -o /dev/null -H 'Host: burrow.net' -w 'burrow_root %{http_code}\n' http://127.0.0.1/
curl -fsS -o /dev/null -H 'Host: git.burrow.net' -w 'git_login %{http_code}\n' http://127.0.0.1/user/login
curl -fsS -o /dev/null -H 'Host: graphs.burrow.net' -w 'grafana_login %{http_code}\n' http://127.0.0.1/login
if [[ "${EXPECT_TAILNET}" == "1" ]]; then
curl -fsS -o /dev/null -H 'Host: auth.burrow.net' -w 'authentik_ready %{http_code}\n' http://127.0.0.1/-/health/ready/
curl -sS -o /dev/null -H 'Host: ts.burrow.net' -w 'headscale_root %{http_code}\n' http://127.0.0.1/ || true

View file

@ -0,0 +1,102 @@
#!/usr/bin/env bash
set -euo pipefail
: "${BURROW_GARAGE_ENDPOINT:?BURROW_GARAGE_ENDPOINT is required}"
: "${AWS_ACCESS_KEY_ID:?AWS_ACCESS_KEY_ID is required for Garage backup}"
: "${AWS_SECRET_ACCESS_KEY:?AWS_SECRET_ACCESS_KEY is required for Garage backup}"
repo_root="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd)"
cd "${repo_root}"
if ! command -v aws >/dev/null 2>&1; then
echo "aws CLI is required for Garage backup" >&2
exit 127
fi
if ! command -v gcloud >/dev/null 2>&1; then
echo "gcloud is required for GCS backup" >&2
exit 127
fi
if ! gcloud auth list --filter=status:ACTIVE --format='value(account)' | grep -q .; then
Scripts/ci/google-wif-auth.sh
fi
export AWS_DEFAULT_REGION="${BURROW_GARAGE_REGION:-garage}"
export AWS_EC2_METADATA_DISABLED=true
requested_releases=0
requested_packages=0
requested_nix_cache=0
IFS=',' read -r -a requested_items <<< "${BURROW_GARAGE_BACKUP_SET:-all}"
for raw_item in "${requested_items[@]}"; do
item="${raw_item//[[:space:]]/}"
case "$item" in
"" )
;;
all )
requested_releases=1
requested_packages=1
requested_nix_cache=1
;;
release|releases )
requested_releases=1
;;
package|packages )
requested_packages=1
;;
attic|nix|nix-cache|nix_cache )
requested_nix_cache=1
;;
* )
echo "unknown Garage backup set item: $item" >&2
exit 64
;;
esac
done
if [[ "$requested_releases" == "0" && "$requested_packages" == "0" && "$requested_nix_cache" == "0" ]]; then
echo "no Garage backup buckets selected" >&2
exit 1
fi
work_root="${RUNNER_TEMP:-${TMPDIR:-/tmp}}/burrow-garage-backup"
rsync_flags=(--recursive)
if [[ "${BURROW_GARAGE_BACKUP_DELETE:-false}" == "true" ]]; then
rsync_flags+=(--delete-unmatched-destination-objects)
fi
sync_bucket() {
local name="$1"
local garage_bucket="$2"
local gcs_bucket="$3"
local source_dir="${work_root}/${name}"
rm -rf "$source_dir"
mkdir -p "$source_dir"
aws --endpoint-url "$BURROW_GARAGE_ENDPOINT" s3 sync "s3://${garage_bucket}" "$source_dir" --no-progress
gcloud storage rsync "${rsync_flags[@]}" "$source_dir" "gs://${gcs_bucket}"
}
if [[ "$requested_releases" == "1" ]]; then
sync_bucket \
releases \
"${BURROW_RELEASE_GARAGE_BUCKET:-burrow-releases}" \
"${BURROW_RELEASE_GCS_BUCKET:-burrow-net-releases}"
fi
if [[ "$requested_packages" == "1" ]]; then
sync_bucket \
packages \
"${BURROW_PACKAGE_GARAGE_BUCKET:-burrow-packages}" \
"${BURROW_PACKAGE_GCS_BUCKET:-burrow-net-packages}"
fi
if [[ "$requested_nix_cache" == "1" ]]; then
sync_bucket \
nix-cache \
"${BURROW_NIX_CACHE_GARAGE_BUCKET:-attic}" \
"${BURROW_NIX_CACHE_GCS_BUCKET:-burrow-net-nix-cache}"
fi

View file

@ -4,17 +4,8 @@ set -euo pipefail
repo_root="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd)"
cd "${repo_root}"
release_ref="${RELEASE_REF:-manual-${GITHUB_SHA:-unknown}}"
target="x86_64-unknown-linux-gnu"
out_dir="${repo_root}/dist"
staging="${out_dir}/burrow-${release_ref}-${target}"
build_number="${BUILD_NUMBER:-${RELEASE_REF:-manual-${GITHUB_SHA:-unknown}}}"
export BUILD_NUMBER="$build_number"
export BURROW_RELEASE_OUT="${BURROW_RELEASE_OUT:-${repo_root}/dist/builds/${build_number}}"
mkdir -p "${staging}"
cargo build --locked --release -p burrow --bin burrow
install -m 0755 target/release/burrow "${staging}/burrow"
cp README.md "${staging}/README.md"
tarball="${out_dir}/burrow-${release_ref}-${target}.tar.gz"
tar -C "${out_dir}" -czf "${tarball}" "$(basename "${staging}")"
shasum -a 256 "${tarball}" > "${tarball}.sha256"
Scripts/ci/package-release-artifacts.sh "${1:-all}"

View file

@ -0,0 +1,68 @@
#!/usr/bin/env bash
set -euo pipefail
require_env() {
local name="$1"
if [[ -z "${!name:-}" ]]; then
echo "missing required environment variable: ${name}" >&2
return 1
fi
}
require_file_or_env() {
local file_name="$1"
local env_name="$2"
if [[ -n "${!file_name:-}" && -f "${!file_name:-}" ]]; then
return 0
fi
if [[ -n "${!env_name:-}" ]]; then
return 0
fi
echo "missing required ${file_name} file or ${env_name} environment value" >&2
return 1
}
case "${1:-all}" in
gcs)
require_env BURROW_GOOGLE_PROJECT_ID
require_env BURROW_GOOGLE_PROJECT_NUMBER
require_env BURROW_GOOGLE_WIF_POOL_ID
require_env BURROW_GOOGLE_WIF_PROVIDER_ID
require_env BURROW_GOOGLE_WIF_SERVICE_ACCOUNT
require_env BURROW_AUTHENTIK_WIF_ISSUER
require_env BURROW_AUTHENTIK_WIF_AUDIENCE
require_env BURROW_AUTHENTIK_WIF_CLIENT_ID
if [[ -z "${BURROW_AUTHENTIK_WIF_TOKEN:-}" && -z "${BURROW_AUTHENTIK_WIF_CLIENT_SECRET:-}" ]]; then
if [[ -z "${BURROW_AUTHENTIK_WIF_TOKEN_FILE:-}" || ! -f "${BURROW_AUTHENTIK_WIF_TOKEN_FILE:-}" ]]; then
echo "missing Authentik WIF token, token file, or client secret" >&2
exit 1
fi
fi
;;
app-store)
require_env ASC_API_KEY_ID
require_env ASC_API_ISSUER_ID
require_file_or_env ASC_API_KEY_PATH ASC_API_KEY_BASE64
;;
apple-signing)
require_file_or_env APPLE_SIGNING_CERTIFICATE_PATH APPLE_SIGNING_CERTIFICATE_BASE64
require_env APPLE_SIGNING_CERTIFICATE_PASSWORD
;;
sparkle)
"$0" gcs
;;
microsoft-store)
require_env MICROSOFT_TENANT_ID
require_env MICROSOFT_CLIENT_ID
require_env MICROSOFT_CLIENT_SECRET
;;
all)
"$0" gcs
"$0" app-store
"$0" apple-signing
;;
*)
echo "unknown release config group: $1" >&2
exit 64
;;
esac

View file

@ -143,13 +143,18 @@ if [[ -e "${config_file}" && ! -w "${config_file}" ]]; then
fi
mkdir -p "$(dirname -- "${config_file}")"
cat > "${config_file}" <<'EOF'
experimental-features = nix-command flakes
sandbox = true
fallback = true
substituters = https://cache.nixos.org
trusted-public-keys = cache.nixos.org-1:6NCHdD59X431o0gWypbMrAURkbJ16ZPMQFGspcDShjY=
EOF
{
echo "experimental-features = nix-command flakes"
echo "sandbox = true"
echo "fallback = true"
if [[ -n "${BURROW_NIX_CACHE_PUBLIC_KEY:-}" ]]; then
echo "substituters = ${BURROW_NIX_CACHE_URL:-https://nix.burrow.net/${BURROW_NIX_CACHE_NAME:-burrow}} https://cache.nixos.org"
echo "trusted-public-keys = ${BURROW_NIX_CACHE_PUBLIC_KEY} cache.nixos.org-1:6NCHdD59X431o0gWypbMrAURkbJ16ZPMQFGspcDShjY="
else
echo "substituters = https://cache.nixos.org"
echo "trusted-public-keys = cache.nixos.org-1:6NCHdD59X431o0gWypbMrAURkbJ16ZPMQFGspcDShjY="
fi
} > "${config_file}"
command -v nix >/dev/null 2>&1 || {
echo "nix is still unavailable after bootstrap" >&2

73
Scripts/ci/ensure-nsc.sh Executable file
View file

@ -0,0 +1,73 @@
#!/usr/bin/env bash
set -euo pipefail
if command -v nsc >/dev/null 2>&1; then
nsc version || true
exit 0
fi
if ! command -v curl >/dev/null 2>&1; then
echo "::error ::curl is required to install nsc" >&2
exit 1
fi
if ! command -v tar >/dev/null 2>&1; then
echo "::error ::tar is required to install nsc" >&2
exit 1
fi
os="$(uname -s | tr '[:upper:]' '[:lower:]')"
arch="$(uname -m)"
case "$arch" in
x86_64|amd64) arch="amd64" ;;
aarch64|arm64) arch="arm64" ;;
*)
echo "::error ::Unsupported arch for nsc: ${arch}" >&2
exit 1
;;
esac
if [[ "$os" != "linux" ]]; then
echo "::warning ::nsc bootstrap is only supported on Linux; relying on the runner or Nix shell for ${os}."
exit 1
fi
version="${NSC_VERSION:-0.0.506}"
expected_sha256=""
case "${version}/${arch}" in
0.0.506/amd64) expected_sha256="4a093e0269878a9514e7c00d99b88a924f307ad15fb9d1595ee5f6582f99a0f0" ;;
0.0.506/arm64) expected_sha256="6acf074b67fcbaaf59a4437f6bd40da7e38438c427849c1b5fcf4621ce75aee2" ;;
esac
asset="nsc_${version}_linux_${arch}.tar.gz"
url="https://get.namespace.so/packages/nsc/v${version}/${asset}"
tmp_dir="${RUNNER_TEMP:-${TMPDIR:-/tmp}}/burrow-nsc.$$"
install_dir="${RUNNER_TEMP:-${TMPDIR:-/tmp}}/nsc-bin"
mkdir -p "$tmp_dir" "$install_dir"
trap 'rm -rf "$tmp_dir"' EXIT
curl -fsSL -o "${tmp_dir}/${asset}" "$url"
if [[ -n "$expected_sha256" ]] && command -v sha256sum >/dev/null 2>&1; then
actual_sha256="$(sha256sum "${tmp_dir}/${asset}" | awk '{print $1}')"
if [[ "$actual_sha256" != "$expected_sha256" ]]; then
echo "::error ::nsc ${asset} sha256 mismatch: expected ${expected_sha256}, got ${actual_sha256}" >&2
exit 1
fi
fi
tar -xzf "${tmp_dir}/${asset}" -C "$tmp_dir"
for bin in nsc bazel-credential-nsc docker-credential-nsc; do
if [[ -x "${tmp_dir}/${bin}" ]]; then
install -m 0755 "${tmp_dir}/${bin}" "${install_dir}/${bin}"
fi
done
if [[ ! -x "${install_dir}/nsc" || ! -x "${install_dir}/bazel-credential-nsc" ]]; then
echo "::error ::Failed to install nsc and bazel-credential-nsc from ${asset}" >&2
exit 1
fi
if [[ -n "${GITHUB_PATH:-}" ]]; then
echo "${install_dir}" >> "${GITHUB_PATH}"
fi
export PATH="${install_dir}:$PATH"
nsc version || true

60
Scripts/ci/ensure-spacectl.sh Executable file
View file

@ -0,0 +1,60 @@
#!/usr/bin/env bash
set -euo pipefail
if command -v spacectl >/dev/null 2>&1; then
spacectl version || true
exit 0
fi
if ! command -v curl >/dev/null 2>&1; then
echo "::error ::curl is required to install spacectl" >&2
exit 1
fi
if ! command -v tar >/dev/null 2>&1; then
echo "::error ::tar is required to install spacectl" >&2
exit 1
fi
os="$(uname -s | tr '[:upper:]' '[:lower:]')"
arch="$(uname -m)"
case "$arch" in
x86_64|amd64) arch="amd64" ;;
aarch64|arm64) arch="arm64" ;;
*)
echo "::error ::Unsupported arch for spacectl: ${arch}" >&2
exit 1
;;
esac
if [[ "$os" != "linux" && "$os" != "darwin" ]]; then
echo "::error ::Unsupported OS for spacectl: ${os}" >&2
exit 1
fi
version="${SPACECTL_VERSION:-v0.5.0}"
version_num="${version#v}"
asset="spacectl_${version_num}_${os}_${arch}.tar.gz"
url="https://github.com/namespacelabs/spacectl/releases/download/${version}/${asset}"
tmp_dir="${RUNNER_TEMP:-${TMPDIR:-/tmp}}/burrow-spacectl.$$"
install_dir="${RUNNER_TEMP:-${TMPDIR:-/tmp}}/spacectl-bin"
mkdir -p "$tmp_dir" "$install_dir"
trap 'rm -rf "$tmp_dir"' EXIT
curl -fsSL -o "${tmp_dir}/${asset}" "$url"
tar -xzf "${tmp_dir}/${asset}" -C "$tmp_dir"
bin="${tmp_dir}/spacectl"
if [[ ! -x "$bin" ]]; then
bin="$(find "$tmp_dir" -maxdepth 3 -type f -name spacectl -perm -111 | head -n1 || true)"
fi
if [[ -z "${bin}" || ! -x "${bin}" ]]; then
echo "::error ::Failed to locate spacectl binary in ${asset}" >&2
exit 1
fi
install -m 0755 "$bin" "${install_dir}/spacectl"
if [[ -n "${GITHUB_PATH:-}" ]]; then
echo "${install_dir}" >> "${GITHUB_PATH}"
fi
export PATH="${install_dir}:$PATH"
spacectl version || true

126
Scripts/ci/google-wif-auth.sh Executable file
View file

@ -0,0 +1,126 @@
#!/usr/bin/env bash
set -euo pipefail
project_id="${GOOGLE_CLOUD_PROJECT:-${BURROW_GOOGLE_PROJECT_ID:-project-88c23ce9-918a-470a-b33}}"
project_number="${BURROW_GOOGLE_PROJECT_NUMBER:-416198671487}"
pool_id="${BURROW_GOOGLE_WIF_POOL_ID:-burrow-authentik}"
provider_id="${BURROW_GOOGLE_WIF_PROVIDER_ID:-forgejo-runners}"
service_account="${BURROW_GOOGLE_WIF_SERVICE_ACCOUNT:-burrow-forgejo-runner@project-88c23ce9-918a-470a-b33.iam.gserviceaccount.com}"
authentik_issuer="${BURROW_AUTHENTIK_WIF_ISSUER:-https://auth.burrow.net/application/o/google-cloud/}"
authentik_audience="${BURROW_AUTHENTIK_WIF_AUDIENCE:-google-wif.burrow.net}"
authentik_client_id="${BURROW_AUTHENTIK_WIF_CLIENT_ID:-${AUTHENTIK_GOOGLE_WIF_CLIENT_ID:-google-wif.burrow.net}}"
authentik_client_secret="${BURROW_AUTHENTIK_WIF_CLIENT_SECRET:-${AUTHENTIK_GOOGLE_WIF_CLIENT_SECRET:-}}"
authentik_token_file="${BURROW_AUTHENTIK_WIF_TOKEN_FILE:-}"
authentik_token="${BURROW_AUTHENTIK_WIF_TOKEN:-}"
work_dir="${RUNNER_TEMP:-${TMPDIR:-/tmp}}/burrow-google-wif"
emit_env=1
usage() {
cat <<'EOF'
Usage: Scripts/ci/google-wif-auth.sh [--no-emit-env]
Authenticates gcloud to the Burrow Google project through Authentik-issued OIDC
and Google Workload Identity Federation. No Google service-account JSON key is
used or written.
Inputs:
GOOGLE_CLOUD_PROJECT / BURROW_GOOGLE_PROJECT_ID
BURROW_GOOGLE_PROJECT_NUMBER
BURROW_GOOGLE_WIF_POOL_ID
BURROW_GOOGLE_WIF_PROVIDER_ID
BURROW_GOOGLE_WIF_SERVICE_ACCOUNT
BURROW_AUTHENTIK_WIF_ISSUER
BURROW_AUTHENTIK_WIF_AUDIENCE
BURROW_AUTHENTIK_WIF_TOKEN or BURROW_AUTHENTIK_WIF_TOKEN_FILE
If no token is supplied, the script requests one from Authentik using
BURROW_AUTHENTIK_WIF_CLIENT_ID and BURROW_AUTHENTIK_WIF_CLIENT_SECRET.
EOF
}
while [[ "$#" -gt 0 ]]; do
case "$1" in
--no-emit-env)
emit_env=0
;;
-h|--help)
usage
exit 0
;;
*)
echo "unknown argument: $1" >&2
exit 64
;;
esac
shift
done
require_command() {
if ! command -v "$1" >/dev/null 2>&1; then
echo "$1 is required for Google WIF authentication" >&2
exit 127
fi
}
require_command gcloud
require_command curl
require_command jq
mkdir -p "$work_dir"
chmod 0700 "$work_dir"
token_path="$work_dir/authentik-oidc-token.jwt"
credential_config="$work_dir/google-wif-credentials.json"
if [[ -n "$authentik_token_file" ]]; then
if [[ ! -s "$authentik_token_file" ]]; then
echo "Authentik WIF token file is empty or missing: $authentik_token_file" >&2
exit 1
fi
cp "$authentik_token_file" "$token_path"
elif [[ -n "$authentik_token" ]]; then
printf '%s' "$authentik_token" > "$token_path"
else
if [[ -z "$authentik_client_secret" ]]; then
echo "missing Authentik WIF token. Set BURROW_AUTHENTIK_WIF_TOKEN, BURROW_AUTHENTIK_WIF_TOKEN_FILE, or BURROW_AUTHENTIK_WIF_CLIENT_SECRET." >&2
exit 1
fi
token_endpoint="${authentik_issuer%/}/token/"
curl -fsS \
-u "${authentik_client_id}:${authentik_client_secret}" \
-d grant_type=client_credentials \
-d scope="openid profile email groups" \
-d audience="$authentik_audience" \
"$token_endpoint" \
| jq -r '.id_token // empty' > "$token_path"
fi
chmod 0600 "$token_path"
if [[ ! -s "$token_path" ]]; then
echo "Authentik did not return an OIDC token for Google WIF" >&2
exit 1
fi
provider_resource="projects/${project_number}/locations/global/workloadIdentityPools/${pool_id}/providers/${provider_id}"
gcloud iam workload-identity-pools create-cred-config "$provider_resource" \
--service-account "$service_account" \
--subject-token-type="urn:ietf:params:oauth:token-type:jwt" \
--credential-source-file "$token_path" \
--output-file "$credential_config"
chmod 0600 "$credential_config"
gcloud auth login --cred-file="$credential_config" --brief
gcloud config set project "$project_id" >/dev/null
{
echo "GOOGLE_APPLICATION_CREDENTIALS=$credential_config"
echo "CLOUDSDK_AUTH_CREDENTIAL_FILE_OVERRIDE=$credential_config"
echo "CLOUDSDK_CORE_PROJECT=$project_id"
echo "GOOGLE_CLOUD_PROJECT=$project_id"
} > "$work_dir/google-wif.env"
if [[ "$emit_env" == "1" && -n "${GITHUB_ENV:-}" ]]; then
cat "$work_dir/google-wif.env" >> "$GITHUB_ENV"
fi
echo "Authenticated gcloud through Authentik WIF as ${service_account} in ${project_id}."

View file

@ -0,0 +1,124 @@
#!/usr/bin/env bash
set -euo pipefail
repo_root="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd)"
cd "$repo_root"
signing_ready=0
keychain_path=""
write_env() {
local key="$1"
local value="$2"
if [[ -n "${GITHUB_ENV:-}" ]]; then
echo "${key}=${value}" >> "$GITHUB_ENV"
fi
}
install_profile_file() {
local source="$1"
[[ -f "$source" ]] || return 0
local uuid
uuid="$(security cms -D -i "$source" 2>/dev/null | plutil -extract UUID raw -o - - 2>/dev/null || true)"
if [[ -z "$uuid" ]]; then
uuid="$(basename "$source")"
fi
local profiles_dir="$HOME/Library/MobileDevice/Provisioning Profiles"
mkdir -p "$profiles_dir"
cp "$source" "${profiles_dir}/${uuid}.provisionprofile"
echo "Installed provisioning profile ${uuid}."
}
install_profile_base64() {
local name="$1"
local value="${!name:-}"
[[ -n "$value" ]] || return 0
local out="${RUNNER_TEMP:-/tmp}/${name}.provisionprofile"
printf '%s' "$value" | base64 --decode > "$out"
install_profile_file "$out"
}
install_profile_env_path() {
local name="$1"
local path="${!name:-}"
[[ -n "$path" ]] || return 0
if [[ ! -f "$path" ]]; then
echo "::error ::${name} points to a missing provisioning profile: ${path}" >&2
exit 1
fi
install_profile_file "$path"
}
for profile in \
Apple/Profiles/*.provisionprofile \
Apple/Profiles/*.mobileprovision \
.forgejo/actions/export/*.provisionprofile \
.forgejo/actions/export/*.mobileprovision; do
[[ -e "$profile" ]] || continue
install_profile_file "$profile"
done
install_profile_env_path APPLE_PROVISIONING_PROFILE_PATH
install_profile_env_path APPLE_NETWORK_EXTENSION_PROVISIONING_PROFILE_PATH
install_profile_env_path APPLE_MACOS_PROVISIONING_PROFILE_PATH
install_profile_base64 APPLE_PROVISIONING_PROFILE_BASE64
install_profile_base64 APPLE_NETWORK_EXTENSION_PROVISIONING_PROFILE_BASE64
install_profile_base64 APPLE_MACOS_PROVISIONING_PROFILE_BASE64
cert_base64="${APPLE_SIGNING_CERTIFICATE_BASE64:-${APPLE_DISTRIBUTION_CERTIFICATE_BASE64:-}}"
cert_path_env="${APPLE_SIGNING_CERTIFICATE_PATH:-${APPLE_DISTRIBUTION_CERTIFICATE_PATH:-}}"
cert_password="${APPLE_SIGNING_CERTIFICATE_PASSWORD:-${APPLE_DISTRIBUTION_CERTIFICATE_PASSWORD:-}}"
keychain_password="${APPLE_KEYCHAIN_PASSWORD:-${cert_password}}"
if [[ -n "$cert_base64" || -n "$cert_path_env" ]]; then
if [[ -z "$cert_password" ]]; then
echo "::error ::APPLE_SIGNING_CERTIFICATE_PASSWORD is required when Apple signing certificate material is set." >&2
exit 1
fi
cert_path="${RUNNER_TEMP:-/tmp}/burrow-apple-signing.p12"
keychain_path="$HOME/Library/Keychains/BurrowRelease.keychain-db"
if [[ -n "$cert_path_env" ]]; then
if [[ ! -f "$cert_path_env" ]]; then
echo "::error ::APPLE_SIGNING_CERTIFICATE_PATH points to a missing file: ${cert_path_env}" >&2
exit 1
fi
cp "$cert_path_env" "$cert_path"
else
printf '%s' "$cert_base64" | base64 --decode > "$cert_path"
fi
security create-keychain -p "$keychain_password" "$keychain_path" || true
security set-keychain-settings -lut 21600 "$keychain_path"
security unlock-keychain -p "$keychain_password" "$keychain_path"
security import "$cert_path" \
-k "$keychain_path" \
-P "$cert_password" \
-T /usr/bin/codesign \
-T /usr/bin/security \
-T /usr/bin/productbuild \
-T /Applications/Xcode.app/Contents/Developer/usr/bin/xcodebuild
security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$keychain_password" "$keychain_path" >/dev/null
existing="$(security list-keychains -d user | sed 's/[ "]//g' || true)"
if ! printf '%s\n' "$existing" | grep -Fxq "$keychain_path"; then
security list-keychains -d user -s "$keychain_path" $existing
fi
signing_ready=1
write_env BURROW_APPLE_KEYCHAIN_PATH "$keychain_path"
else
identities="$(security find-identity -v -p codesigning 2>/dev/null || true)"
if printf '%s\n' "$identities" | grep -Eq '"(Apple Distribution|Developer ID Application):'; then
signing_ready=1
fi
fi
write_env BURROW_APPLE_SIGNING_READY "$signing_ready"
if [[ "$signing_ready" == "1" ]]; then
echo "Apple signing assets are available."
else
echo "::notice ::Apple signing certificate is not configured; release lane will build unsigned validation artifacts only."
fi

225
Scripts/ci/nscloud-cache.sh Executable file
View file

@ -0,0 +1,225 @@
#!/usr/bin/env bash
set -euo pipefail
if [[ "$#" -eq 0 ]]; then
echo "No cache modes specified; skipping."
exit 0
fi
want_bazel_cache=0
for mode in "$@"; do
if [[ "$mode" == "bazel" ]]; then
want_bazel_cache=1
break
fi
done
portable_mkdir() {
hash -r >/dev/null 2>&1 || true
command -p mkdir "$@"
}
append_bazel_storage_cache() {
local bazelrc_path="$1"
local cache_root="${BURROW_BAZEL_STORAGE_CACHE:-}"
if [[ -z "$cache_root" && -n "${NSC_CACHE_PATH:-}" ]]; then
cache_root="${NSC_CACHE_PATH}/bazel"
fi
if [[ -z "$cache_root" ]]; then
return 0
fi
portable_mkdir -p "${cache_root}/disk" "${cache_root}/repository"
{
echo "build --disk_cache=${cache_root}/disk"
echo "build --repository_cache=${cache_root}/repository"
} >> "$bazelrc_path"
echo "Namespace Bazel storage cache configured: ${cache_root}"
}
configure_bazel_remote_cache() {
if [[ "$want_bazel_cache" != "1" ]]; then
return 0
fi
export BURROW_BAZEL_NSCCACHE_ACTIVE=0
if ! command -v nsc >/dev/null 2>&1; then
if bash Scripts/ci/ensure-nsc.sh; then
nsc_bin_dir="${RUNNER_TEMP:-${TMPDIR:-/tmp}}/nsc-bin"
if [[ -x "${nsc_bin_dir}/nsc" ]]; then
export PATH="${nsc_bin_dir}:$PATH"
fi
else
echo "::warning ::nsc not found; using local Bazel disk cache only."
if [[ -n "${GITHUB_ENV:-}" ]]; then
echo "BURROW_BAZEL_NSCCACHE_ACTIVE=0" >> "$GITHUB_ENV"
fi
return 0
fi
fi
local bazelrc_path
bazelrc_path="${NSC_BAZELRC_PATH:-${TMPDIR:-/tmp}/burrow-nsc-cache.bazelrc}"
portable_mkdir -p "$(dirname "$bazelrc_path")"
if nsc auth check-login >/dev/null 2>&1 && nsc cache bazel setup --bazelrc "$bazelrc_path" >/dev/null 2>&1; then
append_bazel_storage_cache "$bazelrc_path"
export BURROW_BAZEL_NSCCACHE_BAZELRC="$bazelrc_path"
export BURROW_BAZEL_NSCCACHE_ACTIVE=1
echo "Namespace Bazel cache configured: $bazelrc_path"
if [[ -n "${GITHUB_ENV:-}" ]]; then
{
echo "BURROW_BAZEL_NSCCACHE_ACTIVE=1"
echo "BURROW_BAZEL_NSCCACHE_BAZELRC=$bazelrc_path"
echo "NSC_BAZELRC_PATH=$bazelrc_path"
} >> "$GITHUB_ENV"
fi
else
append_bazel_storage_cache "$bazelrc_path"
echo "::warning ::Namespace Bazel remote cache unavailable; using local Bazel repository/disk cache."
if [[ -n "${GITHUB_ENV:-}" ]]; then
echo "BURROW_BAZEL_NSCCACHE_ACTIVE=0" >> "$GITHUB_ENV"
echo "BURROW_BAZEL_NSCCACHE_BAZELRC=$bazelrc_path" >> "$GITHUB_ENV"
echo "NSC_BAZELRC_PATH=$bazelrc_path" >> "$GITHUB_ENV"
fi
fi
}
cache_root="${NSC_CACHE_PATH:-}"
if [[ -z "$cache_root" ]]; then
ensure_dir() {
local dir="$1"
if portable_mkdir -p "$dir" 2>/dev/null; then
return 0
fi
if [[ "$(id -u)" != "0" ]] && command -v sudo >/dev/null 2>&1 && sudo -n true >/dev/null 2>&1; then
sudo mkdir -p "$dir" >/dev/null 2>&1 || return 1
return 0
fi
return 1
}
if [[ "$(uname -s 2>/dev/null || true)" == "Linux" ]]; then
tmp_root="${RUNNER_TEMP:-${TMPDIR:-/tmp}}/burrow-nscloud-cache"
for candidate in "/cache/nscloud" "$tmp_root" "$PWD/.nscloud-cache"; do
if ensure_dir "$candidate"; then
cache_root="$candidate"
break
fi
done
elif ensure_dir "$PWD/.nscloud-cache"; then
cache_root="$PWD/.nscloud-cache"
fi
fi
if [[ -z "$cache_root" ]]; then
echo "NSCloud cache volume not configured (NSC_CACHE_PATH missing); skipping."
configure_bazel_remote_cache
exit 0
fi
export NSC_CACHE_PATH="$cache_root"
if [[ -n "${GITHUB_ENV:-}" ]]; then
echo "NSC_CACHE_PATH=$cache_root" >> "$GITHUB_ENV"
fi
spacectl_bin_dir="${RUNNER_TEMP:-${TMPDIR:-/tmp}}/spacectl-bin"
if ! command -v spacectl >/dev/null 2>&1; then
if ! bash Scripts/ci/ensure-spacectl.sh; then
echo "::warning ::Unable to install spacectl; skipping filesystem cache mount."
configure_bazel_remote_cache
exit 0
fi
if [[ -x "${spacectl_bin_dir}/spacectl" ]]; then
export PATH="${spacectl_bin_dir}:$PATH"
fi
fi
mode_args=()
for mode in "$@"; do
[[ -z "$mode" ]] && continue
if [[ "$mode" == "bazel" ]]; then
continue
fi
if [[ "$mode" == "nix" && "${BURROW_NSC_DIRECT_NIX_VOLUME:-0}" == "1" ]]; then
echo "Namespace Nix cache volume is mounted directly at /nix; skipping spacectl nix mount."
continue
fi
if [[ "$mode" == "nix" && "$(uname -s)" == "Darwin" ]]; then
echo "Skipping late Nix cache mount on macOS; using existing Nix profile and Bazel cache."
continue
fi
mode_args+=("--mode=${mode}")
done
if [[ "${#mode_args[@]}" -eq 0 ]]; then
echo "No filesystem cache modes require mounting; skipping spacectl cache mount."
configure_bazel_remote_cache
exit 0
fi
portable_mkdir -p "$cache_root" 2>/dev/null || true
args=(-o json cache mount "--dry_run=false" "--cache_root=${cache_root}" "${mode_args[@]}")
echo "Mounting NSCloud cache: spacectl ${args[*]//${cache_root}/<cache_root>}"
spacectl_bin="$(command -v spacectl)"
run_mount=("$spacectl_bin" "${args[@]}")
if [[ "$(id -u)" != "0" ]]; then
if ! command -v sudo >/dev/null 2>&1 || ! sudo -n true >/dev/null 2>&1; then
echo "::warning ::spacectl cache mount requires passwordless sudo; skipping filesystem cache."
configure_bazel_remote_cache
exit 0
fi
run_mount=(sudo -n env "PATH=$PATH" "$spacectl_bin" "${args[@]}")
fi
tmp_err="$(mktemp "${TMPDIR:-/tmp}/spacectl.XXXXXX")"
cleanup() {
rm -f "$tmp_err"
}
trap cleanup EXIT
json=""
if ! json="$("${run_mount[@]}" 2>"$tmp_err")"; then
echo "::warning ::spacectl cache mount failed; skipping filesystem cache."
if [[ -n "$json" ]]; then
printf '%s\n' "$json" || true
fi
if [[ -s "$tmp_err" ]]; then
sed -n '1,200p' "$tmp_err" || true
fi
configure_bazel_remote_cache
exit 0
fi
if command -v python3 >/dev/null 2>&1; then
NSC_MOUNT_JSON="$json" python3 - <<'PY'
import json
import os
import sys
raw = os.environ.get("NSC_MOUNT_JSON", "").strip()
if not raw:
sys.exit(0)
try:
payload = json.loads(raw)
except Exception as exc:
print(f"::warning ::Unable to parse spacectl JSON output; skipping cache env export: {exc}")
sys.exit(0)
if payload.get("error"):
print(f"::warning ::spacectl cache mount skipped: {payload.get('message') or 'error=true'}")
sys.exit(0)
add_envs = (payload.get("output") or {}).get("add_envs") or {}
gh_env = os.environ.get("GITHUB_ENV")
if gh_env and isinstance(add_envs, dict):
with open(gh_env, "a", encoding="utf-8") as f:
for key, value in add_envs.items():
if key and value is not None:
f.write(f"{key}={value}\n")
PY
fi
configure_bazel_remote_cache

View file

@ -0,0 +1,287 @@
#!/usr/bin/env bash
set -euo pipefail
repo_root="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd)"
cd "$repo_root"
platform="${1:-all}"
build_number="${BUILD_NUMBER:-${RELEASE_BUILD_NUMBER:-${RELEASE_REF:-manual}}}"
out_root="${BURROW_RELEASE_OUT:-${repo_root}/dist/builds/${build_number}}"
apple_root="${out_root}/apple"
archives_root="${apple_root}/archives"
derived_data="${BURROW_DERIVED_DATA_PATH:-${repo_root}/.derived-data/apple}"
source_packages="${BURROW_SWIFTPM_CACHE_PATH:-${XDG_CACHE_HOME:-${HOME}/.cache}/burrow/swiftpm}"
project="Apple/Burrow.xcodeproj"
scheme="${BURROW_APPLE_SCHEME:-App}"
bundle_id="${BURROW_APPLE_BUNDLE_ID:-com.hackclub.burrow}"
network_extension_bundle_id="${BURROW_APPLE_NETWORK_EXTENSION_BUNDLE_ID:-${bundle_id}.network}"
signing_ready="${BURROW_APPLE_SIGNING_READY:-0}"
require_signing="${BURROW_APPLE_REQUIRE_SIGNING:-false}"
sparkle_feed_url="${BURROW_SPARKLE_FEED_URL:-https://releases.burrow.net/sparkle/appcast.xml}"
sparkle_public_ed_key="${BURROW_SPARKLE_PUBLIC_ED_KEY:-Myv9ZNZT6YGKMtMezh52ra4WqaeEKc4VlvVU0evhJeI=}"
sparkle_sign_with_kms="${BURROW_SPARKLE_SIGN_WITH_KMS:-false}"
mkdir -p "$apple_root" "$archives_root" "$derived_data" "$source_packages"
truthy() {
case "${1:-}" in
1|true|TRUE|True|yes|YES|Yes|y|Y|on|ON|On) return 0 ;;
*) return 1 ;;
esac
}
sha256_file() {
local file="$1"
if command -v shasum >/dev/null 2>&1; then
shasum -a 256 "$file" > "${file}.sha256"
else
sha256sum "$file" > "${file}.sha256"
fi
}
write_version() {
mkdir -p Apple/Configuration
printf 'CURRENT_PROJECT_VERSION = %s\n' "$build_number" > Apple/Configuration/Version.xcconfig
}
xcode_common_args=(
-project "$project"
-scheme "$scheme"
-configuration Release
-derivedDataPath "$derived_data"
-clonedSourcePackagesDirPath "$source_packages"
CURRENT_PROJECT_VERSION="$build_number"
APP_BUNDLE_IDENTIFIER="$bundle_id"
NETWORK_EXTENSION_BUNDLE_IDENTIFIER="$network_extension_bundle_id"
BURROW_SPARKLE_FEED_URL="$sparkle_feed_url"
BURROW_SPARKLE_PUBLIC_ED_KEY="$sparkle_public_ed_key"
)
xcode_auth_args=()
if [[ -n "${ASC_API_KEY_ID:-}" && -n "${ASC_API_ISSUER_ID:-}" && -n "${ASC_API_KEY_PATH:-}" && -f "${ASC_API_KEY_PATH:-}" ]]; then
xcode_auth_args+=(
-authenticationKeyID "$ASC_API_KEY_ID"
-authenticationKeyIssuerID "$ASC_API_ISSUER_ID"
-authenticationKeyPath "$ASC_API_KEY_PATH"
)
fi
if [[ -n "${BURROW_APPLE_KEYCHAIN_PATH:-}" ]]; then
xcode_common_args+=(OTHER_CODE_SIGN_FLAGS="--keychain ${BURROW_APPLE_KEYCHAIN_PATH}")
fi
require_xcodebuild() {
if ! command -v xcodebuild >/dev/null 2>&1; then
echo "xcodebuild is required for Apple release artifacts" >&2
exit 1
fi
xcodebuild -version
xcrun --find swiftc >/dev/null
}
unsigned_note() {
local platform_name="$1"
local artifact_name="$2"
{
echo "Burrow ${platform_name} signing assets were not available."
echo
echo "The lane built an unsigned validation artifact instead of an uploadable release artifact."
echo "Seal the Apple release credentials with agenix and let CI sync provisioning profiles from App Store Connect to produce ${artifact_name}."
} > "${apple_root}/README-${platform_name}-unsigned.txt"
}
ensure_signing_or_note() {
local platform_name="$1"
local artifact_name="$2"
if [[ "$signing_ready" == "1" ]]; then
return 0
fi
if truthy "$require_signing"; then
echo "Apple signing is required for ${platform_name}, but signing assets are not configured." >&2
exit 1
fi
unsigned_note "$platform_name" "$artifact_name"
return 1
}
write_export_options() {
local method="$1"
local path="$2"
cat > "$path" <<EOF
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>method</key>
<string>${method}</string>
<key>destination</key>
<string>export</string>
<key>signingStyle</key>
<string>automatic</string>
<key>stripSwiftSymbols</key>
<true/>
<key>teamID</key>
<string>${DEVELOPMENT_TEAM:-P6PV2R9443}</string>
</dict>
</plist>
EOF
}
build_ios_unsigned() {
local product_dir zip_path
xcodebuild build \
"${xcode_common_args[@]}" \
-destination "generic/platform=iOS Simulator" \
ARCHS=arm64 \
ONLY_ACTIVE_ARCH=YES \
CODE_SIGNING_ALLOWED=NO \
CODE_SIGNING_REQUIRED=NO
product_dir="${derived_data}/Build/Products/Release-iphonesimulator"
zip_path="${apple_root}/Burrow-iOS-simulator-${build_number}.unsigned.zip"
if [[ -d "${product_dir}/Burrow.app" ]]; then
(cd "$product_dir" && zip -qr "$zip_path" Burrow.app)
sha256_file "$zip_path"
fi
}
build_ios_release() {
local archive_path export_dir export_options ipa
if ! ensure_signing_or_note "iOS" "Burrow-iOS-${build_number}.ipa"; then
build_ios_unsigned
return 0
fi
archive_path="${archives_root}/Burrow-iOS-${build_number}.xcarchive"
export_dir="${apple_root}/ios-export"
export_options="${apple_root}/ExportOptions-iOS.plist"
write_export_options "app-store-connect" "$export_options"
xcodebuild archive \
"${xcode_common_args[@]}" \
-destination "generic/platform=iOS" \
-archivePath "$archive_path" \
ARCHS=arm64 \
-allowProvisioningUpdates \
"${xcode_auth_args[@]}"
xcodebuild -exportArchive \
-archivePath "$archive_path" \
-exportPath "$export_dir" \
-exportOptionsPlist "$export_options" \
-allowProvisioningUpdates \
"${xcode_auth_args[@]}"
ipa="$(find "$export_dir" -maxdepth 1 -type f -name '*.ipa' | sort | head -n 1 || true)"
if [[ -z "$ipa" ]]; then
echo "iOS export completed but did not produce an IPA" >&2
exit 1
fi
cp "$ipa" "${apple_root}/Burrow-iOS-${build_number}.ipa"
sha256_file "${apple_root}/Burrow-iOS-${build_number}.ipa"
}
build_macos_unsigned() {
local product_dir zip_path
xcodebuild build \
"${xcode_common_args[@]}" \
-destination "platform=macOS" \
CODE_SIGNING_ALLOWED=NO \
CODE_SIGNING_REQUIRED=NO
product_dir="${derived_data}/Build/Products/Release"
zip_path="${apple_root}/Burrow-macOS-${build_number}.unsigned.zip"
if [[ -d "${product_dir}/Burrow.app" ]]; then
ditto -c -k --keepParent "${product_dir}/Burrow.app" "$zip_path"
sha256_file "$zip_path"
fi
}
build_macos_release() {
local archive_path export_dir export_options app dmg channel sparkle_dir length pubdate url
if ! ensure_signing_or_note "macOS" "Burrow-macOS-${build_number}.dmg"; then
build_macos_unsigned
return 0
fi
archive_path="${archives_root}/Burrow-macOS-${build_number}.xcarchive"
export_dir="${apple_root}/macos-export"
export_options="${apple_root}/ExportOptions-macOS.plist"
write_export_options "${BURROW_MACOS_EXPORT_METHOD:-developer-id}" "$export_options"
xcodebuild archive \
"${xcode_common_args[@]}" \
-destination "generic/platform=macOS" \
-archivePath "$archive_path" \
-allowProvisioningUpdates \
"${xcode_auth_args[@]}"
xcodebuild -exportArchive \
-archivePath "$archive_path" \
-exportPath "$export_dir" \
-exportOptionsPlist "$export_options" \
-allowProvisioningUpdates \
"${xcode_auth_args[@]}"
app="$(find "$export_dir" -maxdepth 2 -type d -name 'Burrow.app' | sort | head -n 1 || true)"
if [[ -z "$app" ]]; then
echo "macOS export completed but did not produce Burrow.app" >&2
exit 1
fi
dmg="${apple_root}/Burrow-macOS-${build_number}.dmg"
rm -f "$dmg"
hdiutil create -volname "Burrow ${build_number}" -srcfolder "$app" -ov -format UDZO "$dmg"
sha256_file "$dmg"
channel="${SPARKLE_CHANNEL:-build-${build_number}}"
sparkle_dir="${out_root}/sparkle/${channel}"
mkdir -p "$sparkle_dir"
cp "$dmg" "$sparkle_dir/"
length="$(wc -c < "$dmg" | tr -d ' ')"
pubdate="$(LC_ALL=C date -u '+%a, %d %b %Y %H:%M:%S +0000')"
url="${SPARKLE_DOWNLOAD_PREFIX:-https://releases.burrow.net/sparkle}/${channel}/$(basename "$dmg")"
cat > "${sparkle_dir}/appcast.xml" <<EOF
<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle">
<channel>
<title>Burrow ${channel}</title>
<item>
<title>Burrow ${build_number}</title>
<pubDate>${pubdate}</pubDate>
<sparkle:version>${build_number}</sparkle:version>
<sparkle:shortVersionString>0.1</sparkle:shortVersionString>
<enclosure url="${url}" sparkle:version="${build_number}" length="${length}" type="application/octet-stream" />
</item>
</channel>
</rss>
EOF
if truthy "$sparkle_sign_with_kms"; then
Scripts/sparkle/sign-appcast-kms.py \
--appcast "${sparkle_dir}/appcast.xml" \
--artifact-dir "$sparkle_dir"
fi
}
require_xcodebuild
write_version
case "$platform" in
all)
build_ios_release
build_macos_release
;;
ios)
build_ios_release
;;
macos)
build_macos_release
;;
*)
echo "unknown Apple release platform: $platform" >&2
exit 64
;;
esac
find "$out_root" -type f -print | sort

View file

@ -0,0 +1,332 @@
#!/usr/bin/env bash
set -euo pipefail
repo_root="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd)"
cd "${repo_root}"
platform="${1:-all}"
build_number="${BUILD_NUMBER:-${RELEASE_BUILD_NUMBER:-${RELEASE_REF:-manual}}}"
out_root="${BURROW_RELEASE_OUT:-${repo_root}/dist/builds/${build_number}}"
mkdir -p "${out_root}"
sha256_file() {
local file="$1"
if command -v shasum >/dev/null 2>&1; then
shasum -a 256 "$file" > "${file}.sha256"
else
sha256sum "$file" > "${file}.sha256"
fi
}
burrow_crate_version() {
awk -F '"' '/^version = / { print $2; exit }' burrow/Cargo.toml
}
package_release_suffix() {
printf '%s' "$build_number" | tr -c 'A-Za-z0-9.+~' '.'
}
arch_package_suffix() {
printf '%s' "$build_number" | tr -c 'A-Za-z0-9.+_' '.'
}
deb_arch_for_target() {
case "$1" in
x86_64-unknown-linux-gnu) printf 'amd64' ;;
aarch64-unknown-linux-gnu) printf 'arm64' ;;
riscv64gc-unknown-linux-gnu) printf 'riscv64' ;;
*) return 1 ;;
esac
}
rpm_arch_for_target() {
case "$1" in
x86_64-unknown-linux-gnu) printf 'x86_64' ;;
aarch64-unknown-linux-gnu) printf 'aarch64' ;;
riscv64gc-unknown-linux-gnu) printf 'riscv64' ;;
*) return 1 ;;
esac
}
pacman_arch_for_target() {
case "$1" in
x86_64-unknown-linux-gnu) printf 'x86_64' ;;
aarch64-unknown-linux-gnu) printf 'aarch64' ;;
riscv64gc-unknown-linux-gnu) printf 'riscv64' ;;
*) return 1 ;;
esac
}
copy_license_readme() {
local dst="$1"
cp README.md "$dst/README.md"
if [[ -f LICENSE ]]; then
cp LICENSE "$dst/LICENSE"
elif [[ -f LICENSE.md ]]; then
cp LICENSE.md "$dst/LICENSE.md"
fi
}
package_deb() {
local target binary deb_arch pkg_version suffix staging deb_file installed_size
target="$1"
binary="$2"
if ! deb_arch="$(deb_arch_for_target "$target")"; then
echo "warning: no Debian architecture mapping for ${target}; skipping .deb package" >&2
return 0
fi
if ! command -v dpkg-deb >/dev/null 2>&1; then
echo "warning: dpkg-deb is not available; skipping .deb package" >&2
return 0
fi
suffix="$(package_release_suffix)"
pkg_version="${BURROW_PACKAGE_VERSION:-$(burrow_crate_version)+${suffix}}"
staging="${out_root}/linux/pkg-deb/burrow_${pkg_version}_${deb_arch}"
rm -rf "$staging"
mkdir -p \
"$staging/DEBIAN" \
"$staging/usr/bin" \
"$staging/usr/lib/systemd/system" \
"$staging/usr/share/doc/burrow" \
"$staging/usr/share/licenses/burrow"
install -m 0755 "$binary" "$staging/usr/bin/burrow"
install -m 0644 systemd/burrow.service "$staging/usr/lib/systemd/system/burrow.service"
install -m 0644 systemd/burrow.socket "$staging/usr/lib/systemd/system/burrow.socket"
install -m 0644 README.md "$staging/usr/share/doc/burrow/README.md"
install -m 0644 LICENSE.md "$staging/usr/share/licenses/burrow/LICENSE.md"
installed_size="$(du -sk "$staging/usr" | awk '{ print $1 }')"
cat > "$staging/DEBIAN/control" <<EOF
Package: burrow
Version: ${pkg_version}
Section: net
Priority: optional
Architecture: ${deb_arch}
Maintainer: Burrow Release Automation <packages@burrow.net>
Installed-Size: ${installed_size}
Depends: systemd
Description: Burrow VPN daemon and CLI
Burrow installs the daemon entrypoint and systemd socket used by the desktop UI.
EOF
deb_file="${out_root}/linux/burrow_${pkg_version}_${deb_arch}.deb"
dpkg-deb --build --root-owner-group "$staging" "$deb_file"
sha256_file "$deb_file"
}
package_rpm() {
local target binary rpm_arch
target="$1"
binary="$2"
if ! rpm_arch="$(rpm_arch_for_target "$target")"; then
echo "warning: no RPM architecture mapping for ${target}; skipping .rpm package" >&2
return 0
fi
if ! command -v cargo-generate-rpm >/dev/null 2>&1; then
echo "warning: cargo-generate-rpm is not available; skipping .rpm package" >&2
return 0
fi
mkdir -p target/release
cp "$binary" target/release/burrow
if cargo generate-rpm -p burrow; then
find target/generate-rpm -type f -name '*.rpm' -exec cp {} "${out_root}/linux/" \; 2>/dev/null || true
find "${out_root}/linux" -maxdepth 1 -type f -name '*.rpm' -print | while read -r rpm; do
sha256_file "$rpm"
done
else
echo "warning: cargo-generate-rpm failed for ${rpm_arch}; skipping .rpm package" >&2
fi
}
package_pacman() {
local target binary pacman_arch pkg_version pkgrel work source_sum service_sum socket_sum readme_sum license_sum
target="$1"
binary="$2"
if ! pacman_arch="$(pacman_arch_for_target "$target")"; then
echo "warning: no pacman architecture mapping for ${target}; skipping Arch package" >&2
return 0
fi
if ! command -v makepkg >/dev/null 2>&1; then
echo "warning: makepkg is not available; skipping Arch package" >&2
return 0
fi
pkg_version="${BURROW_ARCH_PKGVER:-$(burrow_crate_version)_$(arch_package_suffix)}"
pkgrel="${BURROW_ARCH_PKGREL:-1}"
work="${out_root}/linux/pkg-arch/${pacman_arch}"
rm -rf "$work"
mkdir -p "$work"
install -m 0755 "$binary" "$work/burrow"
install -m 0644 systemd/burrow.service "$work/burrow.service"
install -m 0644 systemd/burrow.socket "$work/burrow.socket"
install -m 0644 README.md "$work/README.md"
install -m 0644 LICENSE.md "$work/LICENSE.md"
source_sum="$(sha256sum "$work/burrow" | awk '{ print $1 }')"
service_sum="$(sha256sum "$work/burrow.service" | awk '{ print $1 }')"
socket_sum="$(sha256sum "$work/burrow.socket" | awk '{ print $1 }')"
readme_sum="$(sha256sum "$work/README.md" | awk '{ print $1 }')"
license_sum="$(sha256sum "$work/LICENSE.md" | awk '{ print $1 }')"
cat > "$work/PKGBUILD" <<EOF
# Maintainer: Burrow Release Automation <packages@burrow.net>
pkgname=burrow
pkgver=${pkg_version}
pkgrel=${pkgrel}
pkgdesc="Burrow VPN daemon and CLI"
arch=("${pacman_arch}")
url="https://git.burrow.net/hackclub/burrow"
license=("GPL-3.0-or-later")
depends=("systemd")
source=("burrow" "burrow.service" "burrow.socket" "README.md" "LICENSE.md")
sha256sums=("${source_sum}" "${service_sum}" "${socket_sum}" "${readme_sum}" "${license_sum}")
package() {
install -Dm755 "\${srcdir}/burrow" "\${pkgdir}/usr/bin/burrow"
install -Dm644 "\${srcdir}/burrow.service" "\${pkgdir}/usr/lib/systemd/system/burrow.service"
install -Dm644 "\${srcdir}/burrow.socket" "\${pkgdir}/usr/lib/systemd/system/burrow.socket"
install -Dm644 "\${srcdir}/README.md" "\${pkgdir}/usr/share/doc/burrow/README.md"
install -Dm644 "\${srcdir}/LICENSE.md" "\${pkgdir}/usr/share/licenses/burrow/LICENSE.md"
}
EOF
(
cd "$work"
makepkg --force --nodeps --noconfirm
)
find "$work" -maxdepth 1 -type f -name '*.pkg.tar.*' -exec cp {} "${out_root}/linux/" \;
find "${out_root}/linux" -maxdepth 1 -type f -name '*.pkg.tar.*' -print | while read -r package; do
sha256_file "$package"
done
}
package_linux() {
local target host archive staging
host="$(rustc -vV | awk '/^host:/ { print $2 }')"
target="${BURROW_LINUX_TARGET:-$host}"
if [[ "$target" != *-linux-* && -z "${BURROW_LINUX_TARGET:-}" ]]; then
mkdir -p "${out_root}/linux"
echo "warning: host target ${target} is not Linux; skipping Linux artifact on this host" >&2
printf 'Linux build was skipped on host target %s. Run this lane on a Linux Namespace runner or set BURROW_LINUX_TARGET explicitly.\n' "$target" > "${out_root}/linux/README.txt"
return 0
fi
staging="${out_root}/linux/burrow-${build_number}-${target}"
mkdir -p "$staging"
cargo build --locked --release -p burrow --bin burrow --target "$target"
install -m 0755 "target/${target}/release/burrow" "$staging/burrow"
copy_license_readme "$staging"
archive="${out_root}/linux/burrow-${build_number}-${target}.tar.gz"
tar -C "${out_root}/linux" -czf "$archive" "$(basename "$staging")"
sha256_file "$archive"
package_deb "$target" "target/${target}/release/burrow"
package_rpm "$target" "target/${target}/release/burrow"
package_pacman "$target" "target/${target}/release/burrow"
}
package_windows_stub_unix() {
local target exe archive staging
target="${BURROW_WINDOWS_TARGET:-x86_64-pc-windows-gnu}"
staging="${out_root}/windows/burrow-${build_number}-${target}"
mkdir -p "$staging"
target_libdir="$(rustc --print target-libdir --target "$target" 2>/dev/null || true)"
if [[ -z "$target_libdir" || ! -d "$target_libdir" ]]; then
echo "warning: Rust target ${target} is not installed on this host" >&2
printf 'Windows stub build for %s was skipped on this host. Use the Namespace Windows lane for the native artifact.\n' "$target" > "${out_root}/windows/README.txt"
return 0
fi
if cargo build --locked --release -p burrow-windows-stub --target "$target"; then
exe="target/${target}/release/burrow.exe"
else
echo "warning: unable to build the Windows release stub on this host" >&2
printf 'Windows stub build for %s was skipped on this host. Use the Namespace Windows lane for the native artifact.\n' "$target" > "${out_root}/windows/README.txt"
return 0
fi
install -m 0755 "$exe" "$staging/burrow.exe"
copy_license_readme "$staging"
printf 'Burrow Windows stub build %s\n' "$build_number" > "$staging/README-Windows.txt"
archive="${out_root}/windows/burrow-${build_number}-${target}.zip"
(cd "${out_root}/windows" && zip -qr "$archive" "$(basename "$staging")")
sha256_file "$archive"
}
package_sparkle_unsigned() {
local macos_dir appcast channel dmg
channel="${SPARKLE_CHANNEL:-build-${build_number}}"
macos_dir="${out_root}/macos"
appcast="${out_root}/sparkle/${channel}/appcast.xml"
mkdir -p "$(dirname "$appcast")"
dmg="$(find "$macos_dir" -maxdepth 1 -type f -name '*.dmg' -print 2>/dev/null | sort | tail -n 1 || true)"
if [[ -z "$dmg" ]]; then
echo "No macOS DMG exists yet; Sparkle appcast generation is waiting on the macOS signed artifact." > "${out_root}/sparkle/${channel}/README.txt"
return 0
fi
local name length pubdate url
name="$(basename "$dmg")"
length="$(wc -c < "$dmg" | tr -d ' ')"
pubdate="$(LC_ALL=C date -u '+%a, %d %b %Y %H:%M:%S +0000')"
url="${SPARKLE_DOWNLOAD_PREFIX:-https://releases.burrow.net/sparkle}/${channel}/${name}"
cat > "$appcast" <<EOF
<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle">
<channel>
<title>Burrow ${channel}</title>
<item>
<title>Burrow ${build_number}</title>
<pubDate>${pubdate}</pubDate>
<sparkle:version>${build_number}</sparkle:version>
<sparkle:shortVersionString>0.1</sparkle:shortVersionString>
<enclosure url="${url}" sparkle:version="${build_number}" length="${length}" type="application/octet-stream" />
</item>
</channel>
</rss>
EOF
cp "$dmg" "${out_root}/sparkle/${channel}/"
}
case "$platform" in
all)
package_linux
package_windows_stub_unix
if [[ "$(uname -s)" == "Darwin" ]] && command -v xcodebuild >/dev/null 2>&1; then
Scripts/ci/package-apple-artifacts.sh all
else
mkdir -p "${out_root}/apple"
printf 'Apple artifacts require a macOS runner with Xcode. The release workflow builds them in the dedicated Apple lane.\n' > "${out_root}/apple/README.txt"
fi
package_sparkle_unsigned
;;
linux)
package_linux
;;
windows-stub|windows)
package_windows_stub_unix
;;
sparkle)
package_sparkle_unsigned
;;
apple)
Scripts/ci/package-apple-artifacts.sh all
;;
ios|macos)
Scripts/ci/package-apple-artifacts.sh "$platform"
;;
*)
echo "unknown release platform: $platform" >&2
exit 64
;;
esac
find "$out_root" -type f -print | sort

View file

@ -7,7 +7,8 @@ set -euo pipefail
: "${TOKEN:?TOKEN is required}"
release_api="${API_URL}/repos/${REPOSITORY}/releases"
tag_api="${release_api}/tags/${RELEASE_TAG}"
encoded_release_tag="$(python3 -c 'import urllib.parse,sys; print(urllib.parse.quote(sys.argv[1], safe=""))' "${RELEASE_TAG}")"
tag_api="${release_api}/tags/${encoded_release_tag}"
release_json="$(mktemp)"
create_json="$(mktemp)"
trap 'rm -f "${release_json}" "${create_json}"' EXIT
@ -50,6 +51,9 @@ if [[ -z "${release_id}" || "${release_id}" == "null" ]]; then
fi
for file in dist/*; do
if [[ ! -f "${file}" ]]; then
continue
fi
name="$(basename "${file}")"
asset_id="$(jq -r --arg name "${name}" '.assets[]? | select(.name == $name) | .id' "${release_json}" | head -n1)"
if [[ -n "${asset_id}" ]]; then

49
Scripts/ci/publish-nix-cache.sh Executable file
View file

@ -0,0 +1,49 @@
#!/usr/bin/env bash
set -euo pipefail
repo_root="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd)"
cd "${repo_root}"
cache_server_name="${BURROW_NIX_CACHE_SERVER_NAME:-burrow}"
cache_server_url="${BURROW_NIX_CACHE_SERVER_URL:-https://nix.burrow.net}"
cache_name="${BURROW_NIX_CACHE_NAME:-burrow}"
cache_ref="${cache_server_name}:${cache_name}"
token="${BURROW_NIX_CACHE_PUSH_TOKEN:-}"
if [[ -z "$token" ]]; then
if [[ "${BURROW_NIX_CACHE_REQUIRED:-false}" == "true" ]]; then
echo "BURROW_NIX_CACHE_PUSH_TOKEN is required to publish Nix cache paths" >&2
exit 1
fi
echo "::notice ::BURROW_NIX_CACHE_PUSH_TOKEN is not set; skipping Nix cache publish."
exit 0
fi
if ! command -v attic >/dev/null 2>&1; then
echo "attic is required to publish Nix cache paths" >&2
exit 127
fi
if ! command -v nix >/dev/null 2>&1; then
echo "nix is required to build paths for cache publishing" >&2
exit 127
fi
attic login "$cache_server_name" "$cache_server_url" "$token" --set-default
push_inputs=("$@")
if [[ "${#push_inputs[@]}" -eq 0 ]]; then
read -r -a attrs <<< "${BURROW_NIX_CACHE_ATTRS:-.#burrow .#forgejo-nsc-dispatcher .#forgejo-nsc-autoscaler}"
if [[ "${#attrs[@]}" -eq 0 ]]; then
echo "no Nix attrs were supplied for cache publishing" >&2
exit 1
fi
mapfile -t push_inputs < <(nix build --no-link --print-out-paths "${attrs[@]}")
fi
if [[ "${#push_inputs[@]}" -eq 0 ]]; then
echo "no Nix output paths were produced for cache publishing" >&2
exit 1
fi
attic push "$cache_ref" "${push_inputs[@]}"

56
Scripts/ci/resolve-nix-bin.sh Executable file
View file

@ -0,0 +1,56 @@
#!/usr/bin/env bash
set -euo pipefail
find_nix_bin() {
local candidate
for candidate in \
"${NIX_BIN:-}" \
"$(command -v nix 2>/dev/null || true)" \
"${HOME:-}/.nix-profile/bin/nix" \
"${HOME:-}/.local/state/nix/profile/bin/nix" \
"/nix/var/nix/profiles/per-user/${USER:-runner}/profile/bin/nix" \
/nix/var/nix/profiles/default/bin/nix \
/run/current-system/sw/bin/nix \
/etc/profiles/per-user/runner/bin/nix \
/Users/runner/.nix-profile/bin/nix \
/Users/runner/.local/state/nix/profile/bin/nix \
/home/runner/.nix-profile/bin/nix \
/home/runner/.local/state/nix/profile/bin/nix \
/nix/profile/bin/nix; do
if [[ -n "$candidate" && -x "$candidate" ]]; then
printf '%s\n' "$candidate"
return 0
fi
done
candidate="$(
find \
"${HOME:-/nonexistent}" \
/Users/runner \
/home/runner \
/nix/var/nix/profiles \
/etc/profiles/per-user \
-path '*/bin/nix' -type f 2>/dev/null | head -n 1 || true
)"
if [[ -n "$candidate" && -x "$candidate" ]]; then
printf '%s\n' "$candidate"
return 0
fi
return 1
}
if nix_bin="$(find_nix_bin)"; then
printf '%s\n' "$nix_bin"
exit 0
fi
if bash Scripts/ci/ensure-nix.sh >/dev/null 2>&1; then
if nix_bin="$(find_nix_bin)"; then
printf '%s\n' "$nix_bin"
exit 0
fi
fi
echo "::error ::nix is required but is not on PATH and no standard install path exists" >&2
exit 1

View file

@ -0,0 +1,72 @@
#!/usr/bin/env bash
set -euo pipefail
repo_root="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd)"
cd "$repo_root"
platform="${1:-all}"
out_dir="${BURROW_APPLE_PROFILES_OUT_DIR:-.forgejo/actions/export}"
app_id="${BURROW_APPLE_BUNDLE_ID:-com.hackclub.burrow}"
network_id="${BURROW_APPLE_NETWORK_EXTENSION_BUNDLE_ID:-${app_id}.network}"
replace_certificate_mismatch="${BURROW_APPLE_REPLACE_CERTIFICATE_MISMATCH:-false}"
if [[ -n "${BURROW_IOS_DISTRIBUTION_CERTIFICATE_ID:-}" || -n "${BURROW_DEVELOPER_ID_CERTIFICATE_ID:-}" ]]; then
replace_certificate_mismatch="${BURROW_APPLE_REPLACE_CERTIFICATE_MISMATCH:-true}"
fi
key_id="${ASC_API_KEY_ID:-${APPSTORE_KEY_ID:-}}"
issuer_id="${ASC_API_ISSUER_ID:-${APPSTORE_KEY_ISSUER_ID:-}}"
key_path="${ASC_API_KEY_PATH:-${APPSTORE_KEY_PATH:-}}"
key_base64="${ASC_API_KEY_BASE64:-}"
if [[ -z "$key_id" || -z "$issuer_id" ]]; then
echo "::notice ::App Store Connect credentials are not available; provisioning profile sync skipped."
exit 0
fi
temp_key=""
cleanup() {
[[ -z "$temp_key" ]] || rm -f "$temp_key"
}
trap cleanup EXIT
if [[ -z "$key_path" ]]; then
if [[ -z "$key_base64" ]]; then
echo "::notice ::ASC_API_KEY_PATH/ASC_API_KEY_BASE64 is not available; provisioning profile sync skipped."
exit 0
fi
temp_key="$(mktemp "${RUNNER_TEMP:-/tmp}/burrow-asc-key.XXXXXX.p8")"
printf '%s' "$key_base64" | base64 --decode > "$temp_key"
chmod 600 "$temp_key"
key_path="$temp_key"
fi
if [[ ! -f "$key_path" ]]; then
echo "::error ::App Store Connect key path does not exist: ${key_path}" >&2
exit 1
fi
mkdir -p "$out_dir"
profile_args=(
--platform "$platform"
--key-id "$key_id"
--issuer-id "$issuer_id"
--key-file "$key_path"
--out-dir "$out_dir"
--app-id "$app_id"
--network-id "$network_id"
--create-missing-bundle-ids "${BURROW_APPLE_CREATE_MISSING_BUNDLE_IDS:-false}"
--enable-capabilities "${BURROW_APPLE_ENABLE_CAPABILITIES:-false}"
--replace-certificate-mismatch "$replace_certificate_mismatch"
--bundle-platform "${BURROW_APPLE_BUNDLE_PLATFORM:-UNIVERSAL}"
--associated-domains "${BURROW_APPLE_ASSOCIATED_DOMAINS:-applinks:burrow.rs?mode=developer,webcredentials:burrow.rs?mode=developer}"
)
if [[ -n "${BURROW_IOS_DISTRIBUTION_CERTIFICATE_ID:-}" ]]; then
profile_args+=(--ios-certificate-id "$BURROW_IOS_DISTRIBUTION_CERTIFICATE_ID")
fi
if [[ -n "${BURROW_DEVELOPER_ID_CERTIFICATE_ID:-}" ]]; then
profile_args+=(--macos-certificate-id "$BURROW_DEVELOPER_ID_CERTIFICATE_ID")
fi
node Scripts/apple/sync-provisioning-profiles.mjs "${profile_args[@]}"

View file

@ -0,0 +1,35 @@
#!/usr/bin/env bash
set -euo pipefail
: "${BURROW_GARAGE_ENDPOINT:?BURROW_GARAGE_ENDPOINT is required}"
: "${AWS_ACCESS_KEY_ID:?AWS_ACCESS_KEY_ID is required for Garage upload}"
: "${AWS_SECRET_ACCESS_KEY:?AWS_SECRET_ACCESS_KEY is required for Garage upload}"
repo_root="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd)"
cd "${repo_root}"
source_dir="${BURROW_PACKAGE_REPOSITORY_DIR:-${repo_root}/publish/repositories}"
if [[ ! -d "$source_dir" ]]; then
echo "package repository directory not found: $source_dir" >&2
exit 1
fi
if ! command -v aws >/dev/null 2>&1; then
echo "aws CLI is required for Garage package repository upload" >&2
exit 127
fi
bucket="${BURROW_PACKAGE_GARAGE_BUCKET:-burrow-packages}"
prefix="${BURROW_PACKAGE_GARAGE_PREFIX:-${BURROW_PACKAGE_GCS_PREFIX:-}}"
region="${BURROW_GARAGE_REGION:-garage}"
destination="s3://${bucket}"
if [[ -n "$prefix" ]]; then
destination="${destination}/${prefix}"
fi
export AWS_DEFAULT_REGION="$region"
export AWS_EC2_METADATA_DISABLED=true
aws --endpoint-url "$BURROW_GARAGE_ENDPOINT" s3 sync "$source_dir" "$destination" --no-progress

View file

@ -0,0 +1,28 @@
#!/usr/bin/env bash
set -euo pipefail
repo_root="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd)"
cd "${repo_root}"
prefix="${BURROW_PACKAGE_GCS_PREFIX:-}"
source_dir="${BURROW_PACKAGE_REPOSITORY_DIR:-${repo_root}/publish/repositories}"
if [[ ! -d "$source_dir" ]]; then
echo "package repository directory not found: $source_dir" >&2
exit 1
fi
bucket="${BURROW_PACKAGE_GCS_BUCKET:-burrow-net-packages}"
if ! command -v gcloud >/dev/null 2>&1; then
echo "gcloud is required for GCS package repository upload" >&2
exit 127
fi
gcloud auth list --filter=status:ACTIVE --format='value(account)' | grep -q . || {
echo "gcloud has no active account; run Scripts/ci/google-wif-auth.sh first" >&2
exit 1
}
destination="gs://${bucket}"
if [[ -n "$prefix" ]]; then
destination="${destination}/${prefix}"
fi
gcloud storage rsync --recursive "$source_dir" "$destination"

View file

@ -0,0 +1,39 @@
#!/usr/bin/env bash
set -euo pipefail
repo_root="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd)"
cd "${repo_root}"
uploaded=0
garage_configured=0
if [[ -n "${BURROW_GARAGE_ENDPOINT:-}" && -n "${AWS_ACCESS_KEY_ID:-}" && -n "${AWS_SECRET_ACCESS_KEY:-}" ]]; then
garage_configured=1
fi
if [[ "$garage_configured" == "1" ]]; then
Scripts/ci/upload-package-garage.sh
uploaded=1
elif [[ "${BURROW_PACKAGE_REQUIRE_GARAGE:-false}" == "true" ]]; then
echo "Garage package repository upload is required, but BURROW_GARAGE_ENDPOINT or AWS credentials are missing" >&2
exit 1
else
echo "::notice ::Garage package repository upload not configured; skipping primary S3-compatible target."
fi
if [[ "${BURROW_PACKAGE_GCS_BACKUP:-true}" != "false" ]]; then
if ! command -v gcloud >/dev/null 2>&1; then
echo "gcloud is required for GCS package repository backup" >&2
exit 127
fi
if ! gcloud auth list --filter=status:ACTIVE --format='value(account)' | grep -q .; then
Scripts/ci/google-wif-auth.sh
fi
Scripts/ci/upload-package-repositories.sh
uploaded=1
fi
if [[ "$uploaded" != "1" ]]; then
echo "no package repository storage targets were enabled" >&2
exit 1
fi

View file

@ -0,0 +1,31 @@
#!/usr/bin/env bash
set -euo pipefail
: "${BUILD_NUMBER:?BUILD_NUMBER is required}"
: "${BURROW_GARAGE_ENDPOINT:?BURROW_GARAGE_ENDPOINT is required}"
: "${AWS_ACCESS_KEY_ID:?AWS_ACCESS_KEY_ID is required for Garage upload}"
: "${AWS_SECRET_ACCESS_KEY:?AWS_SECRET_ACCESS_KEY is required for Garage upload}"
repo_root="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd)"
cd "${repo_root}"
source_dir="${BURROW_RELEASE_OUT:-${repo_root}/dist/builds/${BUILD_NUMBER}}"
if [[ ! -d "$source_dir" ]]; then
echo "release artifact directory not found: $source_dir" >&2
exit 1
fi
if ! command -v aws >/dev/null 2>&1; then
echo "aws CLI is required for Garage release upload" >&2
exit 127
fi
bucket="${BURROW_RELEASE_GARAGE_BUCKET:-burrow-releases}"
region="${BURROW_GARAGE_REGION:-garage}"
destination="s3://${bucket}/builds/${BUILD_NUMBER}"
export AWS_DEFAULT_REGION="$region"
export AWS_EC2_METADATA_DISABLED=true
aws --endpoint-url "$BURROW_GARAGE_ENDPOINT" s3 sync "$source_dir" "$destination" --no-progress

View file

@ -0,0 +1,25 @@
#!/usr/bin/env bash
set -euo pipefail
: "${BUILD_NUMBER:?BUILD_NUMBER is required}"
repo_root="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd)"
cd "${repo_root}"
source_dir="${BURROW_RELEASE_OUT:-${repo_root}/dist/builds/${BUILD_NUMBER}}"
if [[ ! -d "$source_dir" ]]; then
echo "release artifact directory not found: $source_dir" >&2
exit 1
fi
bucket="${BURROW_RELEASE_GCS_BUCKET:-burrow-net-releases}"
if ! command -v gcloud >/dev/null 2>&1; then
echo "gcloud is required for GCS release upload" >&2
exit 127
fi
gcloud auth list --filter=status:ACTIVE --format='value(account)' | grep -q . || {
echo "gcloud has no active account; run Scripts/ci/google-wif-auth.sh first" >&2
exit 1
}
gcloud storage rsync --recursive "$source_dir" "gs://${bucket}/builds/${BUILD_NUMBER}"

View file

@ -0,0 +1,41 @@
#!/usr/bin/env bash
set -euo pipefail
: "${BUILD_NUMBER:?BUILD_NUMBER is required}"
repo_root="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd)"
cd "${repo_root}"
uploaded=0
garage_configured=0
if [[ -n "${BURROW_GARAGE_ENDPOINT:-}" && -n "${AWS_ACCESS_KEY_ID:-}" && -n "${AWS_SECRET_ACCESS_KEY:-}" ]]; then
garage_configured=1
fi
if [[ "$garage_configured" == "1" ]]; then
Scripts/ci/upload-release-garage.sh
uploaded=1
elif [[ "${BURROW_RELEASE_REQUIRE_GARAGE:-false}" == "true" ]]; then
echo "Garage release upload is required, but BURROW_GARAGE_ENDPOINT or AWS credentials are missing" >&2
exit 1
else
echo "::notice ::Garage release upload not configured; skipping primary S3-compatible target."
fi
if [[ "${BURROW_RELEASE_GCS_BACKUP:-true}" != "false" ]]; then
if ! command -v gcloud >/dev/null 2>&1; then
echo "gcloud is required for GCS release backup" >&2
exit 127
fi
if ! gcloud auth list --filter=status:ACTIVE --format='value(account)' | grep -q .; then
Scripts/ci/google-wif-auth.sh
fi
Scripts/ci/upload-release-gcs.sh
uploaded=1
fi
if [[ "$uploaded" != "1" ]]; then
echo "no release storage targets were enabled" >&2
exit 1
fi

View file

@ -0,0 +1,205 @@
#!/usr/bin/env bash
set -euo pipefail
usage() {
cat <<'EOF'
Usage: Scripts/forgejo-dispatch-via-host.sh <workflow> [--ref <ref>] [--inputs <json>] [--inputs-file <path>] [--return-run-info]
Dispatch a Burrow Forgejo workflow by SSHing to the live forge host and using
the managed dispatcher token from the host runtime.
Environment:
FORGEJO_BASE_URL Default: https://git.burrow.net
FORGEJO_REPO Default: hackclub/burrow
BURROW_FORGE_HOST Default: root@git.burrow.net
BURROW_FORGE_HOST_ADDRESS Optional SSH HostName override
BURROW_FORGE_SSH_KEY Optional explicit SSH private key
AGE_FORGE_SSH_KEY Optional injected SSH private key material
BURROW_FORGE_DISPATCHER_CONFIG_FILE Default: /run/agenix/burrowForgejoNscDispatcherConfig
EOF
}
base_url="${FORGEJO_BASE_URL:-https://git.burrow.net}"
repo_slug="${FORGEJO_REPO:-hackclub/burrow}"
forge_host="${BURROW_FORGE_HOST:-root@git.burrow.net}"
forge_host_address="${BURROW_FORGE_HOST_ADDRESS:-}"
dispatcher_config_file="${BURROW_FORGE_DISPATCHER_CONFIG_FILE:-/run/agenix/burrowForgejoNscDispatcherConfig}"
workflow=""
ref="main"
inputs_json=""
return_run_info="false"
while [[ $# -gt 0 ]]; do
case "$1" in
-h|--help)
usage
exit 0
;;
--ref)
ref="${2:-}"
shift 2
;;
--inputs)
inputs_json="${2:-}"
shift 2
;;
--inputs-file)
inputs_json="$(cat "${2:-}")"
shift 2
;;
--return-run-info)
return_run_info="true"
shift
;;
*)
if [[ -z "$workflow" ]]; then
workflow="$1"
shift
else
echo "Unexpected argument: $1" >&2
usage >&2
exit 64
fi
;;
esac
done
if [[ -z "$workflow" ]]; then
echo "Missing workflow name or file, for example build-release.yml." >&2
usage >&2
exit 64
fi
script_dir="$(CDPATH= cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
resolved_forge_ssh_key="$("${script_dir}/resolve-forge-ssh-key.sh")"
eval "$resolved_forge_ssh_key"
ssh_key="${BURROW_FORGE_SSH_KEY}"
if [[ -n "$inputs_json" ]]; then
if ! python3 - "$inputs_json" <<'PY' >/dev/null
import json
import sys
value = json.loads(sys.argv[1])
if not isinstance(value, dict):
raise SystemExit(1)
PY
then
echo "--inputs must be a JSON object." >&2
exit 64
fi
fi
workflow_path="$(python3 - "$workflow" <<'PY'
import sys
import urllib.parse
print(urllib.parse.quote(sys.argv[1], safe=""))
PY
)"
payload="$(python3 - "$ref" "$inputs_json" "$return_run_info" <<'PY'
import json
import sys
ref = sys.argv[1]
inputs_arg = sys.argv[2]
return_run_info = sys.argv[3] == "true"
payload = {"ref": ref}
if inputs_arg:
inputs = json.loads(inputs_arg)
payload["inputs"] = {key: str(value) for key, value in inputs.items()}
if return_run_info:
payload["return_run_info"] = True
print(json.dumps(payload))
PY
)"
payload_b64="$(python3 - "$payload" <<'PY'
import base64
import sys
print(base64.b64encode(sys.argv[1].encode()).decode())
PY
)"
ssh_opts=(
-i "$ssh_key"
-o IdentitiesOnly=yes
-o UserKnownHostsFile=/dev/null
-o GlobalKnownHostsFile=/dev/null
-o StrictHostKeyChecking=accept-new
)
if [[ -n "$forge_host_address" ]]; then
ssh_opts+=(-o "HostName=$forge_host_address")
fi
response="$(
ssh "${ssh_opts[@]}" "$forge_host" bash -s -- \
"$base_url" "$repo_slug" "$workflow_path" "$dispatcher_config_file" "$payload_b64" <<'EOF'
set -euo pipefail
base_url="$1"
repo_slug="$2"
workflow_path="$3"
dispatcher_config_file="$4"
payload_b64="$5"
payload="$(python3 - "$payload_b64" <<'PY'
import base64
import sys
print(base64.b64decode(sys.argv[1]).decode())
PY
)"
if [[ ! -s "$dispatcher_config_file" ]]; then
echo "Forge dispatcher config is missing or empty: $dispatcher_config_file" >&2
exit 1
fi
runtime_token="$(
awk '
/^[A-Za-z0-9_-]+:/ { in_forgejo = ($0 ~ /^forgejo:/); next }
in_forgejo && /^[[:space:]]+token:/ {
sub(/^[[:space:]]+token:[[:space:]]*/, "")
gsub(/^"/, "")
gsub(/"$/, "")
gsub(/^\047/, "")
gsub(/\047$/, "")
print
exit
}
' "$dispatcher_config_file" | tr -d "\r\n"
)"
if [[ -z "$runtime_token" ]]; then
echo "Forge dispatcher token not found in $dispatcher_config_file" >&2
exit 1
fi
url="${base_url%/}/api/v1/repos/${repo_slug}/actions/workflows/${workflow_path}/dispatches"
response_file="$(mktemp)"
http_code="$(
curl -sS -o "$response_file" -w '%{http_code}' -X POST \
-H "Authorization: token ${runtime_token}" \
-H "Content-Type: application/json" \
--data-binary "$payload" \
"$url"
)"
if [[ "$http_code" != 2* ]]; then
cat "$response_file" >&2
rm -f "$response_file"
exit 22
fi
cat "$response_file"
rm -f "$response_file"
EOF
)"
if [[ "$return_run_info" == "true" ]]; then
printf '%s\n' "$response"
else
echo "Dispatched ${workflow} on ${ref} via ${forge_host}."
fi

240
Scripts/forgejo-dispatch.sh Executable file
View file

@ -0,0 +1,240 @@
#!/usr/bin/env bash
set -euo pipefail
usage() {
cat <<'EOF'
Usage: Scripts/forgejo-dispatch.sh <workflow> [--ref <ref>] [--inputs <json>] [--inputs-file <path>] [--return-run-info]
Dispatch a Burrow Forgejo workflow through the Forgejo API.
Environment:
FORGEJO_BASE_URL Default: https://git.burrow.net
FORGEJO_REPO Default: hackclub/burrow
FORGEJO_PAT Token string
FORGEJO_PAT_FILE Path to plaintext token
FORGEJO_DISPATCH_HOST_FALLBACK auto, always, or never. Default: auto
EOF
}
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
base_url="${FORGEJO_BASE_URL:-https://git.burrow.net}"
repo_slug="${FORGEJO_REPO:-hackclub/burrow}"
host_fallback="${FORGEJO_DISPATCH_HOST_FALLBACK:-auto}"
original_args=("$@")
workflow=""
ref="main"
inputs_json=""
return_run_info="false"
case "$host_fallback" in
auto|always|never) ;;
*)
echo "FORGEJO_DISPATCH_HOST_FALLBACK must be auto, always, or never." >&2
exit 64
;;
esac
host_dispatch() {
local reason="$1"
local fallback="${repo_root}/Scripts/forgejo-dispatch-via-host.sh"
if [[ ! -x "$fallback" ]]; then
return 1
fi
echo "Forgejo API dispatch ${reason}; retrying through the forge host." >&2
exec "$fallback" "${original_args[@]}"
}
if [[ "$host_fallback" == "always" ]]; then
if ! host_dispatch "forced by FORGEJO_DISPATCH_HOST_FALLBACK=always"; then
echo "Forge host dispatcher is not available." >&2
exit 1
fi
fi
while [[ $# -gt 0 ]]; do
case "$1" in
-h|--help)
usage
exit 0
;;
--ref)
ref="${2:-}"
shift 2
;;
--inputs)
inputs_json="${2:-}"
shift 2
;;
--inputs-file)
inputs_json="$(cat "${2:-}")"
shift 2
;;
--return-run-info)
return_run_info="true"
shift
;;
*)
if [[ -z "$workflow" ]]; then
workflow="$1"
shift
else
echo "Unexpected argument: $1" >&2
usage >&2
exit 64
fi
;;
esac
done
if [[ -z "$workflow" ]]; then
echo "Missing workflow name or file, for example build-release.yml." >&2
usage >&2
exit 64
fi
extract_dispatcher_token() {
python3 - "$1" <<'PY'
import re
import sys
from pathlib import Path
path = Path(sys.argv[1])
in_forgejo = False
for line in path.read_text(encoding="utf-8").splitlines():
if re.match(r"^[A-Za-z0-9_-]+:", line):
in_forgejo = line.startswith("forgejo:")
continue
if not in_forgejo:
continue
match = re.match(r"\s+token:\s*['\"]?([^'\"]+)['\"]?\s*$", line)
if match:
print(match.group(1))
break
PY
}
decrypt_age_secret() {
local path="$1"
if [[ ! -f "$path" ]]; then
return 1
fi
if command -v nix >/dev/null 2>&1; then
nix run "${repo_root}#agenix" -- -d "$path" 2>/dev/null && return 0
fi
if command -v agenix >/dev/null 2>&1; then
agenix -d "$path" 2>/dev/null && return 0
fi
return 1
}
pat="${FORGEJO_PAT:-}"
pat_source="none"
if [[ -n "$pat" ]]; then
pat_source="FORGEJO_PAT"
fi
if [[ -z "$pat" && -n "${FORGEJO_PAT_FILE:-}" && -f "$FORGEJO_PAT_FILE" ]]; then
pat="$(tr -d '\r\n' < "$FORGEJO_PAT_FILE")"
if [[ -n "$pat" ]]; then
pat_source="FORGEJO_PAT_FILE"
fi
fi
if [[ -z "$pat" ]]; then
for default_file in \
"${repo_root}/intake/forgejo_dispatch_pat.txt" \
"${repo_root}/intake/forgejo_nsc_dispatcher.yaml"
do
if [[ -s "$default_file" ]]; then
if [[ "$default_file" == *.yaml ]]; then
pat="$(extract_dispatcher_token "$default_file" | tr -d '\r\n')"
else
pat="$(tr -d '\r\n' < "$default_file")"
fi
fi
if [[ -n "$pat" ]]; then
pat_source="$default_file"
break
fi
done
fi
if [[ -z "$pat" ]]; then
decrypted_config="$(mktemp)"
trap 'rm -f "$decrypted_config"' EXIT
if decrypt_age_secret "${repo_root}/secrets/infra/forgejo-nsc-dispatcher-config.age" > "$decrypted_config"; then
pat="$(extract_dispatcher_token "$decrypted_config" | tr -d '\r\n')"
if [[ -n "$pat" ]]; then
pat_source="secrets/infra/forgejo-nsc-dispatcher-config.age"
fi
fi
fi
if [[ -z "$pat" ]]; then
if [[ "$host_fallback" == "auto" ]]; then
if host_dispatch "has no local token"; then
exit 0
fi
fi
echo "Forgejo token not found. Set FORGEJO_PAT or FORGEJO_PAT_FILE." >&2
exit 1
fi
if [[ -n "$inputs_json" ]]; then
if ! printf '%s\n' "$inputs_json" | jq -e 'type == "object"' >/dev/null 2>&1; then
echo "--inputs must be a JSON object." >&2
exit 64
fi
inputs_json="$(printf '%s\n' "$inputs_json" | jq -c 'with_entries(.value |= tostring)')"
payload="$(jq -n --arg ref "$ref" --argjson inputs "$inputs_json" --argjson return_run_info "$return_run_info" \
'{ref:$ref, inputs:$inputs} + (if $return_run_info then {return_run_info:true} else {} end)')"
else
payload="$(jq -n --arg ref "$ref" --argjson return_run_info "$return_run_info" \
'{ref:$ref} + (if $return_run_info then {return_run_info:true} else {} end)')"
fi
workflow_path="$(python3 - "$workflow" <<'PY'
import sys
import urllib.parse
print(urllib.parse.quote(sys.argv[1], safe=""))
PY
)"
url="${base_url%/}/api/v1/repos/${repo_slug}/actions/workflows/${workflow_path}/dispatches"
response_file="$(mktemp)"
trap 'rm -f "$response_file" "${decrypted_config:-}"' EXIT
curl_status=0
http_code="$(
curl -sS -o "$response_file" -w '%{http_code}' -X POST \
-H "Authorization: token ${pat}" \
-H "Content-Type: application/json" \
--data-binary "$payload" \
"$url"
)" || curl_status="$?"
if [[ "$curl_status" != "0" || "$http_code" != 2* ]]; then
if [[ "$host_fallback" == "auto" && "$pat_source" != "FORGEJO_PAT" && ( "$http_code" == "401" || "$http_code" == "403" ) ]]; then
if host_dispatch "returned HTTP ${http_code} with ${pat_source}"; then
exit 0
fi
fi
cat "$response_file" >&2 || true
if [[ "$curl_status" != "0" ]]; then
exit "$curl_status"
fi
exit 22
fi
cat "$response_file"
if [[ "$return_run_info" != "true" ]]; then
echo "Dispatched ${workflow} on ${ref}."
fi

41
Scripts/grafana-tofu.sh Executable file
View file

@ -0,0 +1,41 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT="$(
git rev-parse --show-toplevel 2>/dev/null || {
cd "$(dirname "${BASH_SOURCE[0]}")/.."
pwd
}
)"
work_dir="${BURROW_GRAFANA_TOFU_DIR:-$ROOT/infra/grafana}"
plugin_cache_dir="${BURROW_TOFU_PLUGIN_CACHE_DIR:-$ROOT/.cache/opentofu/plugins}"
usage() {
cat <<'EOF'
Usage: Scripts/grafana-tofu.sh [tofu args...]
Runs OpenTofu for Burrow Grafana API configuration. This stack creates folders
and dashboards only; Nix owns the Grafana service, authentication, data sources,
and boot-time provisioning files.
Examples:
Scripts/grafana-tofu.sh init -backend-config=backend.hcl
Scripts/grafana-tofu.sh plan -var-file=terraform.tfvars
Local syntax check:
Scripts/grafana-tofu.sh init -backend=false
Scripts/grafana-tofu.sh validate
EOF
}
if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then
usage
exit 0
fi
mkdir -p "$plugin_cache_dir"
export TF_PLUGIN_CACHE_DIR="$plugin_cache_dir"
export TF_IN_AUTOMATION="${TF_IN_AUTOMATION:-true}"
exec nix shell --inputs-from "$ROOT" nixpkgs#opentofu -c tofu -chdir="$work_dir" "$@"

41
Scripts/identity-tofu.sh Executable file
View file

@ -0,0 +1,41 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT="$(
git rev-parse --show-toplevel 2>/dev/null || {
cd "$(dirname "${BASH_SOURCE[0]}")/.."
pwd
}
)"
work_dir="${BURROW_IDENTITY_TOFU_DIR:-$ROOT/infra/identity}"
plugin_cache_dir="${BURROW_TOFU_PLUGIN_CACHE_DIR:-$ROOT/.cache/opentofu/plugins}"
usage() {
cat <<'EOF'
Usage: Scripts/identity-tofu.sh [tofu args...]
Runs OpenTofu for Burrow Google KMS and Workload Identity Federation resources.
Cloud provider credentials must come from the normal provider environment or a
WIF flow; do not put credentials in backend config.
Examples:
Scripts/identity-tofu.sh init -backend-config=backend.hcl
Scripts/identity-tofu.sh plan -var-file=terraform.tfvars
Local syntax check:
Scripts/identity-tofu.sh init -backend=false
Scripts/identity-tofu.sh validate
EOF
}
if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then
usage
exit 0
fi
mkdir -p "$plugin_cache_dir"
export TF_PLUGIN_CACHE_DIR="$plugin_cache_dir"
export TF_IN_AUTOMATION="${TF_IN_AUTOMATION:-true}"
exec nix shell --inputs-from "$ROOT" nixpkgs#opentofu -c tofu -chdir="$work_dir" "$@"

41
Scripts/openbao-tofu.sh Executable file
View file

@ -0,0 +1,41 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT="$(
git rev-parse --show-toplevel 2>/dev/null || {
cd "$(dirname "${BASH_SOURCE[0]}")/.."
pwd
}
)"
work_dir="${BURROW_OPENBAO_TOFU_DIR:-$ROOT/infra/openbao}"
plugin_cache_dir="${BURROW_TOFU_PLUGIN_CACHE_DIR:-$ROOT/.cache/opentofu/plugins}"
usage() {
cat <<'EOF'
Usage: Scripts/openbao-tofu.sh [tofu args...]
Runs OpenTofu for Burrow OpenBao cloud seal and OpenBao API configuration. The
Vault/OpenBao provider reads VAULT_TOKEN from the environment when managing
OpenBao mounts, policies, or auth backends.
Examples:
Scripts/openbao-tofu.sh init -backend-config=backend.hcl
Scripts/openbao-tofu.sh plan -var-file=terraform.tfvars
Local syntax check:
Scripts/openbao-tofu.sh init -backend=false
Scripts/openbao-tofu.sh validate
EOF
}
if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then
usage
exit 0
fi
mkdir -p "$plugin_cache_dir"
export TF_PLUGIN_CACHE_DIR="$plugin_cache_dir"
export TF_IN_AUTOMATION="${TF_IN_AUTOMATION:-true}"
exec nix shell --inputs-from "$ROOT" nixpkgs#opentofu -c tofu -chdir="$work_dir" "$@"

View file

@ -0,0 +1,78 @@
#!/usr/bin/env bash
set -euo pipefail
apply=0
prefer_packagekit=1
usage() {
cat <<'EOF'
Usage: Scripts/package/bootstrap-native-daemon.sh [--apply] [--no-packagekit]
Detects the host package manager and prints, or with --apply runs, the native
install command for the Burrow daemon package. This is the host-side bootstrap
equivalent the Flatpak GUI can point users at when PackageKit is unavailable.
This assumes the Burrow native repository for the detected package manager is
already configured. Repository setup should be explicit because it establishes
package trust roots.
EOF
}
while [[ "$#" -gt 0 ]]; do
case "$1" in
--apply)
apply=1
;;
--no-packagekit)
prefer_packagekit=0
;;
-h|--help)
usage
exit 0
;;
*)
echo "unknown argument: $1" >&2
exit 64
;;
esac
shift
done
run_or_print() {
if [[ "$apply" == "1" ]]; then
"$@"
else
printf '%q ' "$@"
printf '\n'
fi
}
if [[ "$prefer_packagekit" == "1" ]] && command -v pkcon >/dev/null 2>&1; then
run_or_print pkcon install burrow
exit 0
fi
if command -v apt-get >/dev/null 2>&1; then
run_or_print sudo apt-get install burrow
elif command -v dnf >/dev/null 2>&1; then
run_or_print sudo dnf install burrow
elif command -v zypper >/dev/null 2>&1; then
run_or_print sudo zypper install burrow
elif command -v pacman >/dev/null 2>&1; then
run_or_print sudo pacman -S burrow
elif command -v apk >/dev/null 2>&1; then
run_or_print sudo apk add burrow
elif command -v xbps-install >/dev/null 2>&1; then
run_or_print sudo xbps-install -S burrow
elif command -v eopkg >/dev/null 2>&1; then
run_or_print sudo eopkg install burrow
elif command -v emerge >/dev/null 2>&1; then
run_or_print sudo emerge --ask net-vpn/burrow
elif command -v swupd >/dev/null 2>&1; then
run_or_print sudo swupd bundle-add burrow
elif command -v nix >/dev/null 2>&1; then
run_or_print nix profile install git+https://git.burrow.net/hackclub/burrow
else
echo "No supported native package manager found. Install Burrow through DEB, RPM, pacman, AUR, Flatpak GUI plus daemon package, or the NixOS flake." >&2
exit 69
fi

View file

@ -0,0 +1,248 @@
#!/usr/bin/env bash
set -euo pipefail
repo_root="$(
git rev-parse --show-toplevel 2>/dev/null || {
cd "$(dirname "${BASH_SOURCE[0]}")/../.."
pwd
}
)"
input_dir="${BURROW_PACKAGE_INPUT_DIR:-${repo_root}/dist/packages}"
output_dir="${BURROW_PACKAGE_REPOSITORY_DIR:-${repo_root}/publish/repositories}"
channel="${BURROW_PACKAGE_CHANNEL:-stable}"
suite="${BURROW_APT_SUITE:-stable}"
component="${BURROW_APT_COMPONENT:-main}"
apt_arch="${BURROW_APT_ARCH:-${BURROW_PACKAGE_ARCH:-amd64}}"
rpm_arch="${BURROW_RPM_ARCH:-x86_64}"
pacman_arch="${BURROW_PACMAN_ARCH:-x86_64}"
repo_name="${BURROW_ARCH_REPO_NAME:-burrow}"
generic_kms_public_key_pem="${BURROW_PACKAGE_REPO_KMS_PUBLIC_KEY_PEM:-}"
generic_kms_key="${BURROW_PACKAGE_REPO_KMS_KEY:-}"
apt_kms_public_key_pem="${BURROW_APT_REPO_KMS_PUBLIC_KEY_PEM:-$generic_kms_public_key_pem}"
apt_kms_key="${BURROW_APT_REPO_KMS_KEY:-${generic_kms_key:-package-apt-repository}}"
rpm_kms_public_key_pem="${BURROW_RPM_REPO_KMS_PUBLIC_KEY_PEM:-$generic_kms_public_key_pem}"
rpm_kms_key="${BURROW_RPM_REPO_KMS_KEY:-${generic_kms_key:-package-rpm-repository}}"
pacman_kms_public_key_pem="${BURROW_PACMAN_REPO_KMS_PUBLIC_KEY_PEM:-$generic_kms_public_key_pem}"
pacman_kms_key="${BURROW_PACMAN_REPO_KMS_KEY:-${generic_kms_key:-package-pacman-repository}}"
usage() {
cat <<'EOF'
Usage: Scripts/package/build-repositories.sh [apt|rpm|arch|keys|all]
Builds native Linux package repositories from existing package artifacts.
Inputs:
BURROW_PACKAGE_INPUT_DIR directory containing .deb, .rpm, and .pkg.tar.* files
BURROW_PACKAGE_REPOSITORY_DIR output directory, default publish/repositories
BURROW_APT_REPO_KMS_PUBLIC_KEY_PEM Google KMS public key PEM for APT OpenPGP signatures
BURROW_APT_REPO_KMS_KEY Google KMS key name for APT signatures
BURROW_RPM_REPO_KMS_PUBLIC_KEY_PEM Google KMS public key PEM for RPM repository signatures
BURROW_RPM_REPO_KMS_KEY Google KMS key name for RPM repository signatures
BURROW_PACMAN_REPO_KMS_PUBLIC_KEY_PEM Google KMS public key PEM for pacman signatures
BURROW_PACMAN_REPO_KMS_KEY Google KMS key name for pacman signatures
BURROW_PACKAGE_REPO_KMS_PUBLIC_KEY_PEM fallback public key PEM for all formats
BURROW_PACKAGE_REPO_KMS_KEY fallback KMS key name for all formats
BURROW_KMS_KEYRING/BURROW_KMS_LOCATION/BURROW_KMS_VERSION/GOOGLE_CLOUD_PROJECT
The repository metadata is signed with Google KMS-backed OpenPGP detached
signatures when each format's KMS public key and key name are configured.
EOF
}
if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then
usage
exit 0
fi
mode="${1:-all}"
sha256_file() {
local file="$1"
if command -v shasum >/dev/null 2>&1; then
shasum -a 256 "$file" > "${file}.sha256"
else
sha256sum "$file" > "${file}.sha256"
fi
}
sign_file() {
local file="$1"
local output="${2:-${file}.sig}"
local armor="${3:-0}"
local kms_key="$4"
local kms_public_key_pem="$5"
if [[ -z "$kms_public_key_pem" || -z "$kms_key" ]]; then
echo "warning: KMS OpenPGP signing disabled for ${file}; set the ${file} repository KMS public key and key name" >&2
return 0
fi
args=(
"${repo_root}/Scripts/package/google-kms-openpgp.py"
detach-sign
--public-key-pem "$kms_public_key_pem"
--kms-key "$kms_key"
--input "$file"
--output "$output"
)
if [[ "$armor" == "1" ]]; then
args+=(--armor)
fi
"${args[@]}"
}
write_public_key() {
local name="$1"
local kms_key="$2"
local kms_public_key_pem="$3"
local user_id="$4"
local output="${output_dir}/keys/${name}.asc"
if [[ -z "$kms_public_key_pem" || -z "$kms_key" ]]; then
echo "warning: KMS OpenPGP public key export disabled for ${name}; set the repository KMS public key and key name" >&2
return 0
fi
mkdir -p "$(dirname "$output")"
"${repo_root}/Scripts/package/google-kms-openpgp.py" \
public-key \
--public-key-pem "$kms_public_key_pem" \
--kms-key "$kms_key" \
--user-id "$user_id" \
--output "$output" \
--armor
}
write_release_file() {
local release_path="$1"
local dist_dir="$2"
local now
now="$(LC_ALL=C date -u '+%a, %d %b %Y %H:%M:%S +0000')"
{
printf 'Origin: Burrow\n'
printf 'Label: Burrow\n'
printf 'Suite: %s\n' "$suite"
printf 'Codename: %s\n' "$suite"
printf 'Date: %s\n' "$now"
printf 'Architectures: %s\n' "$apt_arch"
printf 'Components: %s\n' "$component"
printf 'Description: Burrow native package repository\n'
printf 'SHA256:\n'
(
cd "$dist_dir"
find . -type f ! -name Release -print | LC_ALL=C sort | while read -r file; do
clean="${file#./}"
sum="$(sha256sum "$clean" | awk '{print $1}')"
size="$(wc -c < "$clean" | tr -d ' ')"
printf ' %s %s %s\n' "$sum" "$size" "$clean"
done
)
} > "$release_path"
}
build_apt() {
local root binary_dir pool_dir release_dir release_file
root="${output_dir}/apt"
pool_dir="${root}/pool/${component}/b/burrow"
binary_dir="${root}/dists/${suite}/${component}/binary-${apt_arch}"
release_dir="${root}/dists/${suite}"
mkdir -p "$pool_dir" "$binary_dir" "$release_dir"
find "$input_dir" -type f -name '*.deb' -exec cp {} "$pool_dir/" \; 2>/dev/null || true
if ! find "$pool_dir" -type f -name '*.deb' -print -quit | grep -q .; then
echo "warning: no .deb packages found under ${input_dir}; apt repository contains README only" >&2
printf 'Place Burrow .deb artifacts in this repository pool before publishing.\n' > "${root}/README.txt"
return 0
fi
if ! command -v dpkg-scanpackages >/dev/null 2>&1; then
echo "error: dpkg-scanpackages is required to build the apt repository" >&2
exit 127
fi
(
cd "$root"
dpkg-scanpackages --arch "$apt_arch" "pool/${component}" /dev/null > "dists/${suite}/${component}/binary-${apt_arch}/Packages"
gzip -9cn "dists/${suite}/${component}/binary-${apt_arch}/Packages" > "dists/${suite}/${component}/binary-${apt_arch}/Packages.gz"
)
release_file="${release_dir}/Release"
write_release_file "$release_file" "$release_dir"
sign_file "$release_file" "${release_file}.gpg" 1 "$apt_kms_key" "$apt_kms_public_key_pem"
sha256_file "$release_file"
}
build_rpm() {
local root repomd
root="${output_dir}/rpm/${channel}/${rpm_arch}"
mkdir -p "$root"
find "$input_dir" -type f -name '*.rpm' -exec cp {} "$root/" \; 2>/dev/null || true
if ! find "$root" -maxdepth 1 -type f -name '*.rpm' -print -quit | grep -q .; then
echo "warning: no .rpm packages found under ${input_dir}; rpm repository contains README only" >&2
printf 'Place Burrow .rpm artifacts in this directory before publishing.\n' > "${root}/README.txt"
return 0
fi
if ! command -v createrepo_c >/dev/null 2>&1; then
echo "error: createrepo_c is required to build the rpm repository" >&2
exit 127
fi
createrepo_c "$root"
repomd="${root}/repodata/repomd.xml"
sign_file "$repomd" "${repomd}.asc" 1 "$rpm_kms_key" "$rpm_kms_public_key_pem"
sha256_file "$repomd"
}
build_arch() {
local root db
root="${output_dir}/arch/${repo_name}/${pacman_arch}"
mkdir -p "$root"
find "$input_dir" -type f \( -name '*.pkg.tar.zst' -o -name '*.pkg.tar.xz' -o -name '*.pkg.tar.gz' \) -exec cp {} "$root/" \; 2>/dev/null || true
if ! find "$root" -maxdepth 1 -type f -name '*.pkg.tar.*' -print -quit | grep -q .; then
echo "warning: no Arch packages found under ${input_dir}; pacman repository contains README only" >&2
printf 'Place Burrow .pkg.tar.* artifacts in this directory before publishing.\n' > "${root}/README.txt"
return 0
fi
if ! command -v repo-add >/dev/null 2>&1; then
echo "error: repo-add is required to build the Arch repository" >&2
exit 127
fi
(
cd "$root"
repo-add "${repo_name}.db.tar.gz" ./*.pkg.tar.*
)
db="${root}/${repo_name}.db.tar.gz"
sign_file "$db" "${db}.sig" 0 "$pacman_kms_key" "$pacman_kms_public_key_pem"
find "$root" -maxdepth 1 -type f -name '*.pkg.tar.*' ! -name '*.sig' -print | while read -r package; do
sign_file "$package" "${package}.sig" 0 "$pacman_kms_key" "$pacman_kms_public_key_pem"
done
sha256_file "$db"
}
build_keys() {
write_public_key "burrow-package-apt-repository" "$apt_kms_key" "$apt_kms_public_key_pem" "Burrow APT Package Repository <packages@burrow.net>"
write_public_key "burrow-package-rpm-repository" "$rpm_kms_key" "$rpm_kms_public_key_pem" "Burrow RPM Package Repository <packages@burrow.net>"
write_public_key "burrow-package-pacman-repository" "$pacman_kms_key" "$pacman_kms_public_key_pem" "Burrow pacman Package Repository <packages@burrow.net>"
}
mkdir -p "$output_dir"
case "$mode" in
apt)
build_apt
;;
rpm)
build_rpm
;;
arch)
build_arch
;;
keys)
build_keys
;;
all)
build_keys
build_apt
build_rpm
build_arch
;;
*)
echo "unknown repository mode: ${mode}" >&2
exit 64
;;
esac

View file

@ -0,0 +1,313 @@
#!/usr/bin/env python3
"""Create OpenPGP public keys and detached signatures backed by Google KMS.
This intentionally implements only the narrow RSA/SHA-256 subset Burrow needs
for Linux package repository metadata. The private key lives in Google Cloud KMS;
this script builds OpenPGP packets locally and asks KMS to produce the RSA
signature value.
"""
from __future__ import annotations
import argparse
import base64
import binascii
import hashlib
import os
import struct
import subprocess
import sys
import tempfile
import time
from pathlib import Path
RSA_PUBLIC_KEY_ALGO = 1
SHA256_HASH_ALGO = 8
def parse_der_length(data: bytes, offset: int) -> tuple[int, int]:
first = data[offset]
offset += 1
if first < 0x80:
return first, offset
count = first & 0x7F
if count == 0 or count > 4:
raise ValueError("unsupported DER length")
value = int.from_bytes(data[offset : offset + count], "big")
return value, offset + count
def parse_der_tlv(data: bytes, offset: int, expected_tag: int | None = None) -> tuple[int, bytes, int]:
tag = data[offset]
offset += 1
length, offset = parse_der_length(data, offset)
value = data[offset : offset + length]
if len(value) != length:
raise ValueError("truncated DER value")
if expected_tag is not None and tag != expected_tag:
raise ValueError(f"expected DER tag {expected_tag:#x}, got {tag:#x}")
return tag, value, offset + length
def pem_to_der(pem: bytes) -> bytes:
lines = [
line.strip()
for line in pem.splitlines()
if line and not line.startswith(b"-----")
]
return base64.b64decode(b"".join(lines))
def parse_rsa_public_key_from_pem(path: Path) -> tuple[int, int]:
der = pem_to_der(path.read_bytes())
_, spki, end = parse_der_tlv(der, 0, 0x30)
if end != len(der):
raise ValueError("trailing data after SubjectPublicKeyInfo")
_, _alg, offset = parse_der_tlv(spki, 0, 0x30)
_, bit_string, offset = parse_der_tlv(spki, offset, 0x03)
if offset != len(spki):
raise ValueError("trailing data after SPKI bit string")
if not bit_string or bit_string[0] != 0:
raise ValueError("unsupported SPKI bit string")
rsa_der = bit_string[1:]
_, rsa_seq, end = parse_der_tlv(rsa_der, 0, 0x30)
if end != len(rsa_der):
raise ValueError("trailing data after RSA public key")
_, n_bytes, offset = parse_der_tlv(rsa_seq, 0, 0x02)
_, e_bytes, offset = parse_der_tlv(rsa_seq, offset, 0x02)
if offset != len(rsa_seq):
raise ValueError("trailing data after RSA integers")
return int.from_bytes(n_bytes, "big"), int.from_bytes(e_bytes, "big")
def packet_header(tag: int, length: int) -> bytes:
if length < 192:
return bytes([0xC0 | tag, length])
if length < 8384:
length -= 192
return bytes([0xC0 | tag, (length >> 8) + 192, length & 0xFF])
return bytes([0xC0 | tag, 0xFF]) + struct.pack(">I", length)
def old_packet_header(tag: int, length: int) -> bytes:
if length > 0xFFFF:
raise ValueError("old-format two-octet packet length exceeded")
return bytes([(tag << 2) | 1]) + struct.pack(">H", length)
def mpi(value: int) -> bytes:
if value < 0:
raise ValueError("MPI value must be non-negative")
raw = value.to_bytes(max(1, (value.bit_length() + 7) // 8), "big")
return struct.pack(">H", value.bit_length()) + raw.lstrip(b"\x00")
def subpacket(kind: int, body: bytes) -> bytes:
payload = bytes([kind]) + body
length = len(payload)
if length < 192:
return bytes([length]) + payload
if length < 8384:
length -= 192
return bytes([(length >> 8) + 192, length & 0xFF]) + payload
return b"\xff" + struct.pack(">I", length) + payload
def public_key_body(n: int, e: int, created_at: int) -> bytes:
return (
b"\x04"
+ struct.pack(">I", created_at)
+ bytes([RSA_PUBLIC_KEY_ALGO])
+ mpi(n)
+ mpi(e)
)
def fingerprint(public_body: bytes) -> bytes:
return hashlib.sha1(old_packet_header(6, len(public_body)) + public_body).digest()
def armor(kind: str, body: bytes) -> str:
b64 = base64.b64encode(body).decode("ascii")
crc = crc24(body)
checksum = base64.b64encode(crc.to_bytes(3, "big")).decode("ascii")
lines = [f"-----BEGIN PGP {kind}-----", ""]
lines.extend(b64[i : i + 64] for i in range(0, len(b64), 64))
lines.append(f"={checksum}")
lines.append(f"-----END PGP {kind}-----")
return "\n".join(lines) + "\n"
def crc24(data: bytes) -> int:
crc = 0xB704CE
for octet in data:
crc ^= octet << 16
for _ in range(8):
crc <<= 1
if crc & 0x1000000:
crc ^= 0x1864CFB
return crc & 0xFFFFFF
def kms_sign(payload: bytes, args: argparse.Namespace) -> bytes:
with tempfile.TemporaryDirectory(prefix="burrow-kms-openpgp.") as tmp:
input_path = Path(tmp) / "payload"
signature_path = Path(tmp) / "signature.b64"
input_path.write_bytes(payload)
command = [
"gcloud",
"kms",
"asymmetric-sign",
"--location",
args.kms_location,
"--keyring",
args.kms_keyring,
"--key",
args.kms_key,
"--version",
args.kms_version,
"--digest-algorithm",
"sha256",
"--input-file",
str(input_path),
"--signature-file",
str(signature_path),
]
if args.gcloud_project:
command.extend(["--project", args.gcloud_project])
subprocess.run(command, check=True)
return base64.b64decode(signature_path.read_text(encoding="utf-8").strip())
def signature_packet(
signed_payload: bytes,
public_body: bytes,
sig_type: int,
created_at: int,
args: argparse.Namespace,
) -> bytes:
fpr = fingerprint(public_body)
key_id = fpr[-8:]
hashed = b"".join(
[
subpacket(2, struct.pack(">I", created_at)),
subpacket(33, b"\x04" + fpr),
]
)
unhashed = subpacket(16, key_id)
sig_data = (
b"\x04"
+ bytes([sig_type, RSA_PUBLIC_KEY_ALGO, SHA256_HASH_ALGO])
+ struct.pack(">H", len(hashed))
+ hashed
)
trailer = b"\x04\xff" + struct.pack(">I", len(sig_data))
to_hash = signed_payload + sig_data + trailer
digest = hashlib.sha256(to_hash).digest()
signature = kms_sign(to_hash, args)
body = (
sig_data
+ struct.pack(">H", len(unhashed))
+ unhashed
+ digest[:2]
+ mpi(int.from_bytes(signature, "big"))
)
return packet_header(2, len(body)) + body
def public_key_command(args: argparse.Namespace) -> int:
created_at = args.created_at or int(time.time())
n, e = parse_rsa_public_key_from_pem(Path(args.public_key_pem))
public_body = public_key_body(n, e, created_at)
public_packet = packet_header(6, len(public_body)) + public_body
uid = args.user_id.encode("utf-8")
uid_packet = packet_header(13, len(uid)) + uid
uid_signed_payload = (
old_packet_header(6, len(public_body))
+ public_body
+ b"\xb4"
+ struct.pack(">I", len(uid))
+ uid
)
cert_packet = signature_packet(
uid_signed_payload,
public_body,
sig_type=0x13,
created_at=created_at,
args=args,
)
key = public_packet + uid_packet + cert_packet
output = Path(args.output)
if args.armor:
output.write_text(armor("PUBLIC KEY BLOCK", key), encoding="utf-8")
else:
output.write_bytes(key)
return 0
def detach_sign_command(args: argparse.Namespace) -> int:
created_at = args.created_at or int(time.time())
n, e = parse_rsa_public_key_from_pem(Path(args.public_key_pem))
public_body = public_key_body(n, e, args.key_created_at)
payload = Path(args.input).read_bytes()
sig_type = 0x01 if args.text else 0x00
packet = signature_packet(payload, public_body, sig_type, created_at, args)
output = Path(args.output)
if args.armor:
output.write_text(armor("SIGNATURE", packet), encoding="utf-8")
else:
output.write_bytes(packet)
return 0
def add_kms_args(parser: argparse.ArgumentParser) -> None:
parser.add_argument("--kms-location", default=os.environ.get("BURROW_KMS_LOCATION", "global"))
parser.add_argument("--kms-keyring", default=os.environ.get("BURROW_KMS_KEYRING", "burrow-identity"))
parser.add_argument("--kms-key", required=not os.environ.get("BURROW_KMS_KEY"), default=os.environ.get("BURROW_KMS_KEY"))
parser.add_argument("--kms-version", default=os.environ.get("BURROW_KMS_VERSION", "1"))
parser.add_argument("--gcloud-project", default=os.environ.get("GOOGLE_CLOUD_PROJECT"))
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description=__doc__)
subcommands = parser.add_subparsers(dest="command", required=True)
public_key = subcommands.add_parser("public-key")
public_key.add_argument("--public-key-pem", required=True)
public_key.add_argument("--user-id", default="Burrow Package Repository <packages@burrow.net>")
public_key.add_argument("--output", required=True)
public_key.add_argument("--armor", action="store_true")
public_key.add_argument("--created-at", type=int)
add_kms_args(public_key)
public_key.set_defaults(func=public_key_command)
detach = subcommands.add_parser("detach-sign")
detach.add_argument("--public-key-pem", required=True)
detach.add_argument("--key-created-at", type=int, default=1704067200)
detach.add_argument("--input", required=True)
detach.add_argument("--output", required=True)
detach.add_argument("--armor", action="store_true")
detach.add_argument("--text", action="store_true")
detach.add_argument("--created-at", type=int)
add_kms_args(detach)
detach.set_defaults(func=detach_sign_command)
return parser
def main() -> int:
parser = build_parser()
args = parser.parse_args()
try:
return args.func(args)
except subprocess.CalledProcessError as exc:
print(f"gcloud signing failed: {exc}", file=sys.stderr)
return exc.returncode or 1
except Exception as exc:
print(f"error: {exc}", file=sys.stderr)
return 1
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -23,7 +23,7 @@ Options:
--no-refresh-token Reuse intake/forgejo_nsc_token.txt if it already exists.
--token-name <name> Forgejo PAT name prefix (default: forgejo-nsc)
--contact-user <name> Forgejo username used for PAT creation (default: contact)
--scope-owner <name> Forgejo org/user owner for the default NSC scope (default: burrow)
--scope-owner <name> Forgejo org/user owner for the default NSC scope (default: hackclub)
--scope-name <name> Forgejo repository name for the default NSC scope (default: burrow)
-h, --help Show this help text.
EOF
@ -36,7 +36,7 @@ KNOWN_HOSTS_FILE="${BURROW_FORGE_KNOWN_HOSTS_FILE:-${HOME}/.cache/burrow/forge-k
REFRESH_TOKEN=1
TOKEN_NAME_PREFIX="${FORGEJO_PAT_NAME:-forgejo-nsc}"
CONTACT_USER="${FORGEJO_CONTACT_USER:-contact}"
SCOPE_OWNER="${FORGEJO_SCOPE_OWNER:-burrow}"
SCOPE_OWNER="${FORGEJO_SCOPE_OWNER:-hackclub}"
SCOPE_NAME="${FORGEJO_SCOPE_NAME:-burrow}"
BURROW_FLAKE_TMPDIRS=()

41
Scripts/releases-tofu.sh Executable file
View file

@ -0,0 +1,41 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT="$(
git rev-parse --show-toplevel 2>/dev/null || {
cd "$(dirname "${BASH_SOURCE[0]}")/.."
pwd
}
)"
work_dir="${BURROW_RELEASES_TOFU_DIR:-$ROOT/infra/releases}"
plugin_cache_dir="${BURROW_TOFU_PLUGIN_CACHE_DIR:-$ROOT/.cache/opentofu/plugins}"
usage() {
cat <<'EOF'
Usage: Scripts/releases-tofu.sh [tofu args...]
Runs OpenTofu for Burrow Google Cloud Storage release and package repository
buckets. Cloud provider credentials must come from ADC or Workload Identity
Federation; do not put service-account JSON in this repository.
Examples:
Scripts/releases-tofu.sh init -backend-config=backend.hcl
Scripts/releases-tofu.sh plan -var-file=terraform.tfvars
Local syntax check:
Scripts/releases-tofu.sh init -backend=false
Scripts/releases-tofu.sh validate
EOF
}
if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then
usage
exit 0
fi
mkdir -p "$plugin_cache_dir"
export TF_PLUGIN_CACHE_DIR="$plugin_cache_dir"
export TF_IN_AUTOMATION="${TF_IN_AUTOMATION:-true}"
exec nix shell --inputs-from "$ROOT" nixpkgs#opentofu -c tofu -chdir="$work_dir" "$@"

172
Scripts/resolve-age-identity.sh Executable file
View file

@ -0,0 +1,172 @@
#!/usr/bin/env bash
set -euo pipefail
identity_is_valid() {
local path="$1"
if [[ -z "$path" || ! -f "$path" || ! -s "$path" ]]; then
return 1
fi
chmod 600 "$path" 2>/dev/null || true
if command -v age-keygen >/dev/null 2>&1 && age-keygen -y "$path" >/dev/null 2>&1; then
return 0
fi
if openssh_private_identity_has_markers "$path"; then
return 0
fi
if command -v ssh-keygen >/dev/null 2>&1 && ssh-keygen -y -f "$path" >/dev/null 2>&1; then
return 0
fi
local header
header="$(LC_ALL=C head -c 80 "$path" 2>/dev/null || true)"
case "$header" in
AGE-SECRET-KEY-*) return 0 ;;
esac
echo "::warning ::Ignoring invalid age identity candidate at ${path}" >&2
return 1
}
openssh_private_identity_has_markers() {
local path="$1"
local first_line
first_line="$(LC_ALL=C sed -n '1p' "$path" 2>/dev/null || true)"
if [[ "$first_line" != "-----BEGIN OPENSSH PRIVATE KEY-----" ]]; then
return 1
fi
if LC_ALL=C grep -Fq '\n' "$path" 2>/dev/null; then
return 1
fi
LC_ALL=C grep -q '^-----END OPENSSH PRIVATE KEY-----$' "$path" 2>/dev/null
}
candidate_path() {
local path="$1"
if identity_is_valid "$path"; then
printf '%s\n' "$path"
return 0
fi
return 1
}
preferred_home() {
if [[ -n "${RUNNER_HOME:-}" ]]; then
printf '%s\n' "$RUNNER_HOME"
elif [[ -n "${FORGEJO_RUNNER_HOME:-}" ]]; then
printf '%s\n' "$FORGEJO_RUNNER_HOME"
elif [[ -n "${HOME:-}" ]]; then
printf '%s\n' "$HOME"
elif [[ -n "${FORGEJO_RUNNER_WORKDIR:-}" ]]; then
printf '%s\n' "${FORGEJO_RUNNER_WORKDIR}/home"
else
printf '%s\n' "/tmp/forgejo-runner/home"
fi
}
decode_base64() {
local value="$1"
local path="$2"
if command -v base64 >/dev/null 2>&1; then
if printf '%s' "$value" | base64 -d > "$path" 2>/dev/null; then
return 0
fi
if printf '%s' "$value" | base64 -D > "$path" 2>/dev/null; then
return 0
fi
fi
return 1
}
materialize_secret() {
local raw="$1"
local home="$2"
local path="${home}/.ssh/age_keystore"
install -d -m 700 "${home}/.ssh"
printf '%s\n' "$raw" > "$path"
perl -0pi -e 's/\r\n?/\n/g' "$path" 2>/dev/null || true
chmod 600 "$path"
if identity_is_valid "$path"; then
printf '%s\n' "$path"
return 0
fi
if [[ "$raw" == *"\\n"* ]]; then
printf '%b' "$raw" > "$path"
perl -0pi -e 's/\r\n?/\n/g' "$path" 2>/dev/null || true
chmod 600 "$path"
if identity_is_valid "$path"; then
printf '%s\n' "$path"
return 0
fi
fi
if decode_base64 "$raw" "$path"; then
perl -0pi -e 's/\r\n?/\n/g' "$path" 2>/dev/null || true
chmod 600 "$path"
if identity_is_valid "$path"; then
printf '%s\n' "$path"
return 0
fi
fi
rm -f "$path"
echo "::warning ::Injected age identity was present but not parseable; trying other candidates" >&2
return 1
}
resolve_from_existing_files() {
local home
home="$(preferred_home)"
local candidate
while IFS= read -r candidate; do
candidate_path "$candidate" && return 0
done <<EOF
${AGE_IDENTITY_PATH:-}
${BURROW_RUNNER_AGE_IDENTITY_PATH:-}
/var/lib/forgejo-runner-agent/age_keystore
EOF
if [[ -n "${BURROW_RUNNER_AGE_IDENTITY_B64:-}" ]]; then
local decoded_path
decoded_path="${home}/.ssh/age_keystore.from_b64"
if decode_base64 "$BURROW_RUNNER_AGE_IDENTITY_B64" "$decoded_path" && candidate_path "$decoded_path"; then
return 0
fi
rm -f "$decoded_path"
if materialize_secret "$BURROW_RUNNER_AGE_IDENTITY_B64" "$home"; then
return 0
fi
fi
if [[ -n "${BURROW_RUNNER_AGE_IDENTITY:-}" ]]; then
materialize_secret "$BURROW_RUNNER_AGE_IDENTITY" "$home" && return 0
fi
if [[ -n "${AGE_FORGE_SSH_KEY:-}" ]]; then
materialize_secret "$AGE_FORGE_SSH_KEY" "$home" && return 0
fi
while IFS= read -r candidate; do
candidate_path "$candidate" && return 0
done <<EOF
${RUNNER_HOME:-}/.ssh/age_keystore
${FORGEJO_RUNNER_HOME:-}/.ssh/age_keystore
${HOME:-}/.ssh/age_keystore
${FORGEJO_RUNNER_WORKDIR:-}/home/.ssh/age_keystore
${FORGEJO_RUNNER_WORKDIR:-}/age_keystore
/tmp/forgejo-runner/home/.ssh/age_keystore
/tmp/forgejo-runner/age_keystore
EOF
return 1
}
if resolved="$(resolve_from_existing_files)"; then
printf 'AGE_IDENTITY_PATH=%s\n' "$resolved"
exit 0
fi
echo "::error ::Missing age identity (checked explicit path, runner home, managed host path, and injected secrets)" >&2
exit 1

121
Scripts/resolve-forge-ssh-key.sh Executable file
View file

@ -0,0 +1,121 @@
#!/usr/bin/env bash
set -euo pipefail
preferred_home() {
if [[ -n "${RUNNER_HOME:-}" ]]; then
printf '%s\n' "$RUNNER_HOME"
elif [[ -n "${FORGEJO_RUNNER_HOME:-}" ]]; then
printf '%s\n' "$FORGEJO_RUNNER_HOME"
elif [[ -n "${HOME:-}" ]]; then
printf '%s\n' "$HOME"
elif [[ -n "${FORGEJO_RUNNER_WORKDIR:-}" ]]; then
printf '%s\n' "${FORGEJO_RUNNER_WORKDIR}/home"
else
printf '%s\n' "/tmp/forgejo-runner/home"
fi
}
is_ssh_private_key() {
local candidate="$1"
[[ -n "$candidate" && -f "$candidate" && -s "$candidate" ]] || return 1
chmod 600 "$candidate" 2>/dev/null || true
ssh-keygen -y -f "$candidate" >/dev/null 2>&1
}
candidate_path() {
local path="$1"
if is_ssh_private_key "$path"; then
printf '%s\n' "$path"
return 0
fi
return 1
}
decode_base64() {
local value="$1"
local path="$2"
if command -v base64 >/dev/null 2>&1; then
if printf '%s' "$value" | base64 -d > "$path" 2>/dev/null; then
return 0
fi
if printf '%s' "$value" | base64 -D > "$path" 2>/dev/null; then
return 0
fi
fi
return 1
}
materialize_secret() {
local raw="$1"
local home="$2"
local path="${home}/.ssh/burrow_forge_ed25519"
install -d -m 700 "${home}/.ssh"
printf '%s\n' "$raw" > "$path"
if candidate_path "$path"; then
return 0
fi
if [[ "$raw" == *"\\n"* ]]; then
printf '%b' "$raw" > "$path"
if candidate_path "$path"; then
return 0
fi
fi
if decode_base64 "$raw" "$path" && candidate_path "$path"; then
return 0
fi
rm -f "$path"
echo "::warning ::Injected Forge SSH key was present but not parseable; trying file candidates" >&2
return 1
}
resolve_key() {
local home
home="$(preferred_home)"
if [[ -n "${BURROW_FORGE_SSH_KEY:-}" ]]; then
if candidate_path "$BURROW_FORGE_SSH_KEY"; then
return 0
fi
echo "::error ::BURROW_FORGE_SSH_KEY is set but is not an SSH private key: ${BURROW_FORGE_SSH_KEY}" >&2
return 1
fi
if [[ -n "${AGE_FORGE_SSH_KEY:-}" ]]; then
materialize_secret "$AGE_FORGE_SSH_KEY" "$home" && return 0
fi
local candidate
while IFS= read -r candidate; do
candidate_path "$candidate" && return 0
done <<EOF
${AGE_IDENTITY_PATH:-}
${BURROW_RUNNER_AGE_IDENTITY_PATH:-}
${RUNNER_HOME:-}/.ssh/burrow_forge_ed25519
${FORGEJO_RUNNER_HOME:-}/.ssh/burrow_forge_ed25519
${HOME:-}/.ssh/burrow_forge_ed25519
${HOME:-}/.ssh/agent_at_burrow_net_ed25519
${HOME:-}/.ssh/burrow_forgejo_ed25519
/var/lib/forgejo-runner-agent/age_keystore
${RUNNER_HOME:-}/.ssh/age_keystore
${FORGEJO_RUNNER_HOME:-}/.ssh/age_keystore
${HOME:-}/.ssh/age_keystore
${FORGEJO_RUNNER_WORKDIR:-}/home/.ssh/age_keystore
${FORGEJO_RUNNER_WORKDIR:-}/age_keystore
/tmp/forgejo-runner/home/.ssh/age_keystore
/tmp/forgejo-runner/age_keystore
EOF
return 1
}
if resolved="$(resolve_key)"; then
printf 'BURROW_FORGE_SSH_KEY=%s\n' "$resolved"
exit 0
fi
echo "::error ::Forge SSH key not found. Set BURROW_FORGE_SSH_KEY or AGE_FORGE_SSH_KEY." >&2
exit 1

261
Scripts/seal-release-secrets.sh Executable file
View file

@ -0,0 +1,261 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
usage() {
cat <<'EOF'
Usage: Scripts/seal-release-secrets.sh [--source-dir <dir>] [--only <secret>]
Encrypt Burrow release inputs into the agenix secrets consumed by CI.
Expected files in --source-dir (default: intake/release):
appstore-key-id.txt
appstore-key-issuer-id.txt
appstore-key.p8
distribution-cert.p12
distribution-cert-password.txt
sparkle-eddsa.key
Pre-composed appstore-connect.env, distribution-signing.env, and sparkle.env
files are also accepted.
Provisioning profiles are synced from App Store Connect credentials in CI.
The temporary keychain password is always the distribution certificate password.
Secrets:
all
appstore-connect
distribution-signing
sparkle
EOF
}
SOURCE_DIR="${BURROW_RELEASE_SECRET_SOURCE_DIR:-${REPO_ROOT}/intake/release}"
ONLY="all"
while [[ $# -gt 0 ]]; do
case "$1" in
--source-dir)
SOURCE_DIR="${2:?missing value for --source-dir}"
shift 2
;;
--only)
ONLY="${2:?missing value for --only}"
shift 2
;;
-h|--help)
usage
exit 0
;;
*)
echo "unknown option: $1" >&2
usage >&2
exit 64
;;
esac
done
case "$ONLY" in
all|appstore-connect|app-store|appstore|distribution-signing|apple-signing|sparkle) ;;
*)
echo "unknown release secret selector: $ONLY" >&2
usage >&2
exit 64
;;
esac
require_cmd() {
if ! command -v "$1" >/dev/null 2>&1; then
echo "missing required command: $1" >&2
exit 1
fi
}
require_cmd age
require_cmd nix
require_cmd python3
if [[ ! -d "$SOURCE_DIR" ]]; then
echo "release secret source directory does not exist: $SOURCE_DIR" >&2
exit 1
fi
tmpdir="$(mktemp -d)"
cleanup() {
rm -rf "$tmpdir"
}
trap cleanup EXIT
seal_secret() {
local target="$1"
local source_path="$2"
local required="${3:-required}"
local recipients_file="${tmpdir}/$(basename "${target}").recipients"
if [[ ! -s "$source_path" ]]; then
if [[ "$required" == "optional" ]]; then
return 0
fi
echo "required release secret source missing or empty: $source_path" >&2
exit 1
fi
mkdir -p "${REPO_ROOT}/$(dirname "$target")"
nix eval --impure --json --expr "let s = import ${REPO_ROOT}/secrets.nix; in s.\"${target}\".publicKeys" \
| python3 -c 'import json, sys; [print(item) for item in json.load(sys.stdin)]' \
> "$recipients_file"
age -R "$recipients_file" -o "${REPO_ROOT}/${target}" "$source_path"
chmod 600 "${REPO_ROOT}/${target}"
echo "Sealed ${target}"
}
base64_file() {
python3 -c 'import base64, pathlib, sys; print(base64.b64encode(pathlib.Path(sys.argv[1]).read_bytes()).decode())' "$1"
}
read_secret_text() {
python3 -c 'import pathlib, sys; sys.stdout.write(pathlib.Path(sys.argv[1]).read_text().strip())' "$1"
}
base64_text() {
python3 -c 'import base64, sys; print(base64.b64encode(sys.stdin.buffer.read()).decode())'
}
reject_placeholder() {
local path="$1"
if grep -Eq '^(TODO|PASTE_|REPLACE_|#)' "$path"; then
echo "intake file still contains placeholder text: $path" >&2
exit 1
fi
}
reject_placeholder_text() {
local path="$1"
if grep -Iq . "$path"; then
reject_placeholder "$path"
fi
}
first_existing() {
local path
for path in "$@"; do
if [[ -s "$path" ]]; then
printf '%s\n' "$path"
return 0
fi
done
return 1
}
compose_appstore_connect_env() {
local direct="${SOURCE_DIR}/appstore-connect.env"
if [[ -s "$direct" ]]; then
printf '%s\n' "$direct"
return 0
fi
local key_id_file="${SOURCE_DIR}/appstore-key-id.txt"
local issuer_id_file="${SOURCE_DIR}/appstore-key-issuer-id.txt"
local key_file="${SOURCE_DIR}/appstore-key.p8"
if [[ ! -s "$key_file" ]]; then
echo "required App Store Connect source missing or empty: appstore-key.p8" >&2
exit 1
fi
for path in "$key_id_file" "$issuer_id_file" "$key_file"; do
if [[ ! -s "$path" ]]; then
echo "required App Store Connect source missing or empty: $path" >&2
exit 1
fi
reject_placeholder_text "$path"
done
local out="${tmpdir}/appstore-connect.env"
{
printf 'ASC_API_KEY_ID=%s\n' "$(read_secret_text "$key_id_file")"
printf 'ASC_API_ISSUER_ID=%s\n' "$(read_secret_text "$issuer_id_file")"
printf 'ASC_API_KEY_BASE64=%s\n' "$(base64_file "$key_file")"
} > "$out"
printf '%s\n' "$out"
}
compose_distribution_signing_env() {
local direct="${SOURCE_DIR}/distribution-signing.env"
if [[ -s "$direct" ]]; then
printf '%s\n' "$direct"
return 0
fi
local cert_file
cert_file="${SOURCE_DIR}/distribution-cert.p12"
local password_file="${SOURCE_DIR}/distribution-cert-password.txt"
if [[ ! -s "$cert_file" ]]; then
echo "required distribution signing source missing or empty: distribution-cert.p12" >&2
exit 1
fi
for path in "$password_file"; do
if [[ ! -s "$path" ]]; then
echo "required distribution signing source missing or empty: $path" >&2
exit 1
fi
reject_placeholder_text "$path"
done
local out="${tmpdir}/distribution-signing.env"
{
printf 'APPLE_SIGNING_CERTIFICATE_BASE64=%s\n' "$(base64_file "$cert_file")"
printf 'APPLE_SIGNING_CERTIFICATE_PASSWORD_BASE64=%s\n' "$(read_secret_text "$password_file" | base64_text)"
} > "$out"
printf '%s\n' "$out"
}
compose_sparkle_env() {
local direct="${SOURCE_DIR}/sparkle.env"
if [[ -s "$direct" ]]; then
printf '%s\n' "$direct"
return 0
fi
local key_file
key_file="${SOURCE_DIR}/sparkle-eddsa.key"
if [[ ! -s "$key_file" ]]; then
return 1
fi
reject_placeholder_text "$key_file"
local out="${tmpdir}/sparkle.env"
printf 'SPARKLE_EDDSA_KEY_BASE64=%s\n' "$(base64_file "$key_file")" > "$out"
printf '%s\n' "$out"
}
case "$ONLY" in
all)
appstore_env="$(compose_appstore_connect_env)"
seal_secret "secrets/apple/appstore-connect.env.age" "$appstore_env"
distribution_env="$(compose_distribution_signing_env)"
seal_secret "secrets/apple/distribution-signing.env.age" "$distribution_env"
if sparkle_env="$(compose_sparkle_env)"; then
seal_secret "secrets/apple/sparkle.env.age" "$sparkle_env"
fi
;;
appstore-connect|app-store|appstore)
appstore_env="$(compose_appstore_connect_env)"
seal_secret "secrets/apple/appstore-connect.env.age" "$appstore_env"
;;
distribution-signing|apple-signing)
distribution_env="$(compose_distribution_signing_env)"
seal_secret "secrets/apple/distribution-signing.env.age" "$distribution_env"
;;
sparkle)
if sparkle_env="$(compose_sparkle_env)"; then
seal_secret "secrets/apple/sparkle.env.age" "$sparkle_env"
else
echo "required Sparkle source missing or empty: sparkle-eddsa.key" >&2
exit 1
fi
;;
esac
echo "Release secrets are sealed. Commit the .age files and deploy/use CI with an authorized age identity."

View file

@ -0,0 +1,107 @@
#!/usr/bin/env python3
"""Sign Sparkle appcast enclosures with a Google Cloud KMS Ed25519 key."""
from __future__ import annotations
import argparse
import base64
import os
import subprocess
import sys
import tempfile
import urllib.parse
import xml.etree.ElementTree as ET
from pathlib import Path
SPARKLE_NS = "http://www.andymatuschak.org/xml-namespaces/sparkle"
DEFAULT_PROJECT = "project-88c23ce9-918a-470a-b33"
DEFAULT_LOCATION = "global"
DEFAULT_KEYRING = "burrow-identity"
DEFAULT_KEY = "sparkle-ed25519"
DEFAULT_VERSION = "1"
def run(command: list[str]) -> None:
subprocess.run(command, check=True)
def kms_sign(args: argparse.Namespace, payload: Path) -> bytes:
with tempfile.TemporaryDirectory(prefix="burrow-sparkle-kms.") as tmp:
signature_path = Path(tmp) / "signature.bin"
command = [
args.gcloud,
"kms",
"asymmetric-sign",
"--location",
args.kms_location,
"--keyring",
args.kms_keyring,
"--key",
args.kms_key,
"--version",
args.kms_version,
"--input-file",
str(payload),
"--signature-file",
str(signature_path),
]
if args.gcloud_project:
command.extend(["--project", args.gcloud_project])
run(command)
return signature_path.read_bytes()
def enclosure_path(enclosure: ET.Element, artifact_dir: Path) -> Path:
url = enclosure.attrib.get("url")
if not url:
raise ValueError("Sparkle enclosure is missing a url attribute")
parsed = urllib.parse.urlparse(url)
name = Path(parsed.path).name
if not name:
raise ValueError(f"Sparkle enclosure URL has no file name: {url}")
path = artifact_dir / name
if not path.is_file():
raise FileNotFoundError(f"Sparkle enclosure artifact not found: {path}")
return path
def sign_appcast(args: argparse.Namespace) -> None:
appcast = Path(args.appcast)
artifact_dir = Path(args.artifact_dir) if args.artifact_dir else appcast.parent
ET.register_namespace("sparkle", SPARKLE_NS)
tree = ET.parse(appcast)
root = tree.getroot()
enclosures = root.findall(".//enclosure")
if not enclosures:
raise ValueError(f"no Sparkle enclosures found in {appcast}")
for enclosure in enclosures:
artifact = enclosure_path(enclosure, artifact_dir)
signature = kms_sign(args, artifact)
enclosure.attrib[f"{{{SPARKLE_NS}}}edSignature"] = base64.b64encode(signature).decode("ascii")
tree.write(appcast, encoding="utf-8", xml_declaration=True)
print(f"signed {len(enclosures)} enclosure(s) in {appcast}")
def parse_args(argv: list[str]) -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Sign Sparkle appcast enclosures with Google Cloud KMS.")
parser.add_argument("--appcast", required=True)
parser.add_argument("--artifact-dir")
parser.add_argument("--gcloud-project", default=os.environ.get("GOOGLE_CLOUD_PROJECT") or os.environ.get("BURROW_GOOGLE_PROJECT_ID") or DEFAULT_PROJECT)
parser.add_argument("--kms-location", default=os.environ.get("BURROW_KMS_LOCATION", DEFAULT_LOCATION))
parser.add_argument("--kms-keyring", default=os.environ.get("BURROW_KMS_KEYRING", DEFAULT_KEYRING))
parser.add_argument("--kms-key", default=os.environ.get("BURROW_SPARKLE_KMS_KEY", DEFAULT_KEY))
parser.add_argument("--kms-version", default=os.environ.get("BURROW_SPARKLE_KMS_VERSION", DEFAULT_VERSION))
parser.add_argument("--gcloud", default=os.environ.get("GCLOUD", "gcloud"))
return parser.parse_args(argv)
def main(argv: list[str]) -> int:
sign_appcast(parse_args(argv))
return 0
if __name__ == "__main__":
raise SystemExit(main(sys.argv[1:]))

146
Scripts/version.sh Executable file
View file

@ -0,0 +1,146 @@
#!/usr/bin/env bash
set -euo pipefail
export PATH="$PATH:/opt/homebrew/bin:/usr/local/bin:/etc/profiles/per-user/$USER/bin"
cd "$(dirname "${BASH_SOURCE[0]}")/.."
TAG_PREFIX="builds/"
DEFAULT_VERSION_REMOTE="origin"
if git remote get-url forgejo >/dev/null 2>&1; then
DEFAULT_VERSION_REMOTE="forgejo"
fi
VERSION_REMOTE="${BURROW_VERSION_REMOTE:-$DEFAULT_VERSION_REMOTE}"
VERSION_REQUIRE_REMOTE="${BURROW_VERSION_REQUIRE_REMOTE:-false}"
COMMAND="${1:-}"
truthy() {
case "$1" in
1|true|TRUE|True|yes|YES|Yes|y|Y|on|ON|On) return 0 ;;
*) return 1 ;;
esac
}
list_local_build_tags() {
git tag -l "${TAG_PREFIX}[0-9]*"
}
latest_local_build_tag() {
list_local_build_tags | sort -n -t'/' -k2 | tail -n 1
}
latest_remote_build_record() {
local raw rc=0
raw="$(git ls-remote --tags --refs "$VERSION_REMOTE" "refs/tags/${TAG_PREFIX}[0-9]*" 2>/dev/null)" || rc=$?
if [[ $rc -ne 0 ]]; then
return $rc
fi
if [[ -z "$raw" ]]; then
return 0
fi
printf '%s\n' "$raw" | awk -F'\t' -v prefix="refs/tags/${TAG_PREFIX}" '
$2 ~ ("^" prefix "[0-9]+$") {
build = $2
sub("^refs/tags/", "", build)
print build "\t" $1
}
' | sort -n -t'/' -k2 | tail -n 1
}
resolve_build_state() {
local remote_record local_latest remote_status=0
BUILD_STATE_SOURCE="local"
LATEST_BUILD=""
LATEST_BUILD_COMMIT=""
remote_record="$(latest_remote_build_record)" || remote_status=$?
if [[ $remote_status -eq 0 ]]; then
BUILD_STATE_SOURCE="remote"
if [[ -n "$remote_record" ]]; then
LATEST_BUILD="$(printf '%s\n' "$remote_record" | cut -f1)"
LATEST_BUILD_COMMIT="$(printf '%s\n' "$remote_record" | cut -f2)"
fi
else
if truthy "$VERSION_REQUIRE_REMOTE"; then
echo "error: unable to resolve authoritative build tags from ${VERSION_REMOTE}" >&2
exit "$remote_status"
fi
echo "warning: unable to resolve authoritative build tags from ${VERSION_REMOTE}; falling back to local tags" >&2
local_latest="$(latest_local_build_tag)"
if [[ -n "$local_latest" ]]; then
LATEST_BUILD="$local_latest"
LATEST_BUILD_COMMIT="$(git rev-parse -q --verify "refs/tags/${LATEST_BUILD}^{commit}" 2>/dev/null || true)"
fi
fi
if [[ -z "$LATEST_BUILD" ]]; then
LATEST_BUILD_NUMBER="0"
else
LATEST_BUILD_NUMBER="${LATEST_BUILD#$TAG_PREFIX}"
fi
}
resolve_build_state
HEAD_COMMIT="$(git rev-parse HEAD)"
ANCESTRY_STATE="ok"
if [[ -n "${LATEST_BUILD_NUMBER}" && "${LATEST_BUILD_NUMBER}" != "0" ]]; then
if [[ -n "${LATEST_BUILD_COMMIT}" ]] && git cat-file -e "${LATEST_BUILD_COMMIT}^{commit}" 2>/dev/null; then
if ! git merge-base --is-ancestor "${LATEST_BUILD_COMMIT}" HEAD; then
ANCESTRY_STATE="not-descended"
if [[ "$COMMAND" != "status" ]]; then
echo "error: HEAD is not descended from build ${LATEST_BUILD_NUMBER}" >&2
exit 1
fi
fi
else
ANCESTRY_STATE="missing-tag-commit"
echo "notice: skipping build ancestry check (missing ${LATEST_BUILD} commit, likely shallow checkout)" >&2
fi
fi
BUILD_NUMBER="$LATEST_BUILD_NUMBER"
if [[ "$COMMAND" == "increment" || "$COMMAND" == "pre-increment" ]]; then
if [[ -n "${BURROW_BUILD_NUMBER_OVERRIDE:-}" ]]; then
if [[ ! "${BURROW_BUILD_NUMBER_OVERRIDE}" =~ ^[0-9]+$ ]]; then
echo "error: BURROW_BUILD_NUMBER_OVERRIDE must be an integer" >&2
exit 1
fi
if (( BURROW_BUILD_NUMBER_OVERRIDE <= LATEST_BUILD_NUMBER )); then
echo "error: BURROW_BUILD_NUMBER_OVERRIDE must be greater than ${LATEST_BUILD_NUMBER}" >&2
exit 1
fi
NEW_BUILD_NUMBER="${BURROW_BUILD_NUMBER_OVERRIDE}"
else
NEW_BUILD_NUMBER=$((LATEST_BUILD_NUMBER + 1))
fi
NEW_TAG="$TAG_PREFIX$NEW_BUILD_NUMBER"
BUILD_NUMBER="$NEW_BUILD_NUMBER"
fi
if [[ "$COMMAND" == "increment" ]]; then
git tag "$NEW_TAG"
git push --quiet "$VERSION_REMOTE" "$NEW_TAG"
fi
CONFIG_PATH="Apple/Configuration/Version.xcconfig"
if [[ "$COMMAND" != "status" && -f "$CONFIG_PATH" ]]; then
current_config_version="$(sed -n 's/^[[:space:]]*CURRENT_PROJECT_VERSION[[:space:]]*=[[:space:]]*//p' "$CONFIG_PATH" 2>/dev/null | tail -n 1 || true)"
if [[ "$current_config_version" != "$BUILD_NUMBER" ]]; then
echo "CURRENT_PROJECT_VERSION = $BUILD_NUMBER" > "$CONFIG_PATH"
fi
git update-index --assume-unchanged "$CONFIG_PATH" 2>/dev/null || true
fi
if [[ "$COMMAND" == "status" ]]; then
if [[ "${ANCESTRY_STATE}" == "ok" && "${LATEST_BUILD_NUMBER}" != "0" && -n "${LATEST_BUILD_COMMIT}" && "${HEAD_COMMIT}" == "${LATEST_BUILD_COMMIT}" ]]; then
echo "clean"
else
echo "dirty"
fi
exit 0
fi
echo "$BUILD_NUMBER"