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
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:
parent
97c569fb35
commit
002bd382e9
199 changed files with 14268 additions and 185 deletions
164
Scripts/apple/create-asc-certificate.mjs
Executable file
164
Scripts/apple/create-asc-certificate.mjs
Executable 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
249
Scripts/apple/google-kms-csr.py
Executable 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:]))
|
||||
327
Scripts/apple/sync-provisioning-profiles.mjs
Executable file
327
Scripts/apple/sync-provisioning-profiles.mjs
Executable 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);
|
||||
});
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
285
Scripts/authentik-sync-google-wif-oidc.sh
Executable file
285
Scripts/authentik-sync-google-wif-oidc.sh
Executable 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})."
|
||||
332
Scripts/authentik-sync-grafana-oidc.sh
Executable file
332
Scripts/authentik-sync-grafana-oidc.sh
Executable 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})."
|
||||
|
|
@ -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
|
||||
|
|
|
|||
102
Scripts/ci/backup-garage-to-gcs.sh
Executable file
102
Scripts/ci/backup-garage-to-gcs.sh
Executable 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
|
||||
|
|
@ -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}"
|
||||
|
|
|
|||
68
Scripts/ci/check-release-config.sh
Executable file
68
Scripts/ci/check-release-config.sh
Executable 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
|
||||
|
|
@ -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
73
Scripts/ci/ensure-nsc.sh
Executable 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
60
Scripts/ci/ensure-spacectl.sh
Executable 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
126
Scripts/ci/google-wif-auth.sh
Executable 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}."
|
||||
124
Scripts/ci/install-apple-signing-assets.sh
Executable file
124
Scripts/ci/install-apple-signing-assets.sh
Executable 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
225
Scripts/ci/nscloud-cache.sh
Executable 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
|
||||
287
Scripts/ci/package-apple-artifacts.sh
Executable file
287
Scripts/ci/package-apple-artifacts.sh
Executable 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
|
||||
332
Scripts/ci/package-release-artifacts.sh
Executable file
332
Scripts/ci/package-release-artifacts.sh
Executable 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
|
||||
|
|
@ -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
49
Scripts/ci/publish-nix-cache.sh
Executable 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
56
Scripts/ci/resolve-nix-bin.sh
Executable 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
|
||||
72
Scripts/ci/sync-apple-provisioning-profiles.sh
Executable file
72
Scripts/ci/sync-apple-provisioning-profiles.sh
Executable 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[@]}"
|
||||
35
Scripts/ci/upload-package-garage.sh
Executable file
35
Scripts/ci/upload-package-garage.sh
Executable 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
|
||||
28
Scripts/ci/upload-package-repositories.sh
Executable file
28
Scripts/ci/upload-package-repositories.sh
Executable 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"
|
||||
39
Scripts/ci/upload-package-storage.sh
Executable file
39
Scripts/ci/upload-package-storage.sh
Executable 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
|
||||
31
Scripts/ci/upload-release-garage.sh
Executable file
31
Scripts/ci/upload-release-garage.sh
Executable 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
|
||||
25
Scripts/ci/upload-release-gcs.sh
Executable file
25
Scripts/ci/upload-release-gcs.sh
Executable 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}"
|
||||
41
Scripts/ci/upload-release-storage.sh
Executable file
41
Scripts/ci/upload-release-storage.sh
Executable 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
|
||||
205
Scripts/forgejo-dispatch-via-host.sh
Executable file
205
Scripts/forgejo-dispatch-via-host.sh
Executable 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
240
Scripts/forgejo-dispatch.sh
Executable 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
41
Scripts/grafana-tofu.sh
Executable 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
41
Scripts/identity-tofu.sh
Executable 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
41
Scripts/openbao-tofu.sh
Executable 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" "$@"
|
||||
78
Scripts/package/bootstrap-native-daemon.sh
Executable file
78
Scripts/package/bootstrap-native-daemon.sh
Executable 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
|
||||
248
Scripts/package/build-repositories.sh
Executable file
248
Scripts/package/build-repositories.sh
Executable 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
|
||||
313
Scripts/package/google-kms-openpgp.py
Executable file
313
Scripts/package/google-kms-openpgp.py
Executable 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())
|
||||
|
|
@ -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
41
Scripts/releases-tofu.sh
Executable 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
172
Scripts/resolve-age-identity.sh
Executable 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
121
Scripts/resolve-forge-ssh-key.sh
Executable 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
261
Scripts/seal-release-secrets.sh
Executable 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."
|
||||
107
Scripts/sparkle/sign-appcast-kms.py
Executable file
107
Scripts/sparkle/sign-appcast-kms.py
Executable 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
146
Scripts/version.sh
Executable 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"
|
||||
Loading…
Add table
Add a link
Reference in a new issue