From f3f2b046b765564c03fa30b37bcfe95063a135d8 Mon Sep 17 00:00:00 2001 From: "every.channel" Date: Fri, 27 Feb 2026 23:34:42 -0800 Subject: [PATCH] ops: document deploy secrets and enforce main branch protection --- docs/BRANCH_PROTECTION.md | 35 ++++++ docs/DEPLOY_CLOUDFLARE.md | 16 ++- scripts/fj-enforce-branch-protection.sh | 136 ++++++++++++++++++++++++ 3 files changed, 178 insertions(+), 9 deletions(-) create mode 100644 docs/BRANCH_PROTECTION.md create mode 100755 scripts/fj-enforce-branch-protection.sh diff --git a/docs/BRANCH_PROTECTION.md b/docs/BRANCH_PROTECTION.md new file mode 100644 index 0000000..6c32c38 --- /dev/null +++ b/docs/BRANCH_PROTECTION.md @@ -0,0 +1,35 @@ +# Branch Protection (Codeberg) + +`main` should be protected to satisfy constitutional governance (`all changes merge through pull requests`) and to require CI before merge. + +## Required settings + +- Protected branch: `main` +- Direct pushes disabled +- Required approvals: `1` (or stricter) +- Required status checks: + - `ci-gates / checks` +- Require signed commits: enabled + +## Apply via script + +```sh +./scripts/fj-enforce-branch-protection.sh +``` + +Optional overrides: + +```sh +EVERY_CHANNEL_FORGE_REPO=every-channel/every.channel \ +EVERY_CHANNEL_PROTECTED_BRANCH=main \ +EVERY_CHANNEL_REQUIRED_CHECKS="ci-gates / checks" \ +EVERY_CHANNEL_REQUIRED_APPROVALS=1 \ +./scripts/fj-enforce-branch-protection.sh +``` + +Token source order: + +1. `CODEBERG_TOKEN` env var +2. `secrets/codeberg-token.age` via `agenix` or `age` + +The token must have repository admin scope to edit branch protection. diff --git a/docs/DEPLOY_CLOUDFLARE.md b/docs/DEPLOY_CLOUDFLARE.md index 6993c60..18df044 100644 --- a/docs/DEPLOY_CLOUDFLARE.md +++ b/docs/DEPLOY_CLOUDFLARE.md @@ -5,18 +5,16 @@ This repo deploys `https://every.channel` via Wrangler. ## Prereqs - Forgejo Actions enabled on the repo. -- A Cloudflare API token stored as a Forgejo Actions secret: - - name: `CLOUDFLARE_API_TOKEN` +- Forgejo Actions secret `AGE_FORGE_SSH_KEY` set to the SSH private key used to decrypt repo-encrypted age secrets. +- `secrets/cloudflare-api-token.age` present in-repo and decryptable by `AGE_FORGE_SSH_KEY`. -The workflow is defined in `.forgejo/workflows/deploy-cloudflare.yml`. +CI and deploy workflows: + +- PR/main checks: `.forgejo/workflows/ci-gates.yml` +- Deploy (main only, depends on checks): `.forgejo/workflows/deploy-cloudflare.yml` ## Manual deploy (local) ```sh -cd apps/tauri/ui -trunk build --release --public-url / - -cd deploy/cloudflare-worker -npm ci -npm run deploy +./scripts/deploy-workers.sh ``` diff --git a/scripts/fj-enforce-branch-protection.sh b/scripts/fj-enforce-branch-protection.sh new file mode 100755 index 0000000..adf5fe7 --- /dev/null +++ b/scripts/fj-enforce-branch-protection.sh @@ -0,0 +1,136 @@ +#!/usr/bin/env bash +set -euo pipefail + +root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "${root}" + +host="${EVERY_CHANNEL_FORGE_HOST:-https://codeberg.org}" +repo="${EVERY_CHANNEL_FORGE_REPO:-every-channel/every.channel}" +branch="${EVERY_CHANNEL_PROTECTED_BRANCH:-main}" +required_checks_csv="${EVERY_CHANNEL_REQUIRED_CHECKS:-ci-gates / checks}" +required_approvals="${EVERY_CHANNEL_REQUIRED_APPROVALS:-1}" +require_signed_commits_raw="${EVERY_CHANNEL_REQUIRE_SIGNED_COMMITS:-true}" + +rules_file="${EVERY_CHANNEL_AGE_RULES_FILE:-./secrets.nix}" +identity_file="${EVERY_CHANNEL_AGE_IDENTITY_FILE:-$HOME/.config/every.channel/keys/founder_ed25519}" + +if [[ -z "${CODEBERG_TOKEN:-}" && -f secrets/codeberg-token.age && -f "${identity_file}" ]]; then + if command -v agenix >/dev/null 2>&1; then + token="$(RULES="${rules_file}" agenix -d secrets/codeberg-token.age -i "${identity_file}" 2>/dev/null || true)" + if [[ -n "${token}" ]]; then + export CODEBERG_TOKEN + CODEBERG_TOKEN="${token}" + fi + elif command -v age >/dev/null 2>&1; then + token="$(age -d -i "${identity_file}" secrets/codeberg-token.age 2>/dev/null || true)" + if [[ -n "${token}" ]]; then + export CODEBERG_TOKEN + CODEBERG_TOKEN="${token}" + fi + fi +fi + +if [[ -z "${CODEBERG_TOKEN:-}" ]]; then + echo "error: CODEBERG_TOKEN is not set" >&2 + echo "hint: set CODEBERG_TOKEN or configure secrets/codeberg-token.age" >&2 + exit 2 +fi + +if [[ ! "${repo}" =~ ^[^/]+/[^/]+$ ]]; then + echo "error: EVERY_CHANNEL_FORGE_REPO must be '/' (got '${repo}')" >&2 + exit 2 +fi + +case "${required_approvals}" in + ''|*[!0-9]*) + echo "error: EVERY_CHANNEL_REQUIRED_APPROVALS must be a non-negative integer" >&2 + exit 2 + ;; +esac + +require_signed_commits_raw_lc="$(printf '%s' "${require_signed_commits_raw}" | tr '[:upper:]' '[:lower:]')" +require_signed_commits="false" +if [[ "${require_signed_commits_raw_lc}" == "true" || "${require_signed_commits_raw}" == "1" ]]; then + require_signed_commits="true" +fi + +owner="${repo%%/*}" +repo_name="${repo#*/}" +api="${host%/}/api/v1/repos/${owner}/${repo_name}/branch_protections" + +contexts_json="" +IFS=',' read -r -a contexts <<< "${required_checks_csv}" +for ctx in "${contexts[@]}"; do + trimmed="$(echo "${ctx}" | sed -E 's/^[[:space:]]+//; s/[[:space:]]+$//')" + [[ -n "${trimmed}" ]] || continue + escaped="$(printf '%s' "${trimmed}" | sed 's/\\/\\\\/g; s/"/\\"/g')" + if [[ -n "${contexts_json}" ]]; then + contexts_json+=", " + fi + contexts_json+="\"${escaped}\"" +done + +if [[ -z "${contexts_json}" ]]; then + echo "error: no required status checks specified (EVERY_CHANNEL_REQUIRED_CHECKS)" >&2 + exit 2 +fi + +payload="$(cat </dev/null +elif [[ "${status}" == "200" ]]; then + curl -fsSL -X PATCH \ + -H "Authorization: token ${CODEBERG_TOKEN}" \ + -H "content-type: application/json" \ + "${api}/${branch}" \ + -d "${payload}" >/dev/null +else + echo "error: unexpected status while reading branch protection: ${status}" >&2 + exit 2 +fi + +current="$(curl -fsSL \ + -H "Authorization: token ${CODEBERG_TOKEN}" \ + "${api}/${branch}")" + +if ! printf '%s' "${current}" | rg -q '"enable_status_check":\s*true'; then + echo "error: branch protection update did not enable status checks" >&2 + exit 2 +fi +if ! printf '%s' "${current}" | rg -q "\"required_approvals\":\\s*${required_approvals}"; then + echo "error: branch protection update did not set required approvals to ${required_approvals}" >&2 + exit 2 +fi +for ctx in "${contexts[@]}"; do + trimmed="$(echo "${ctx}" | sed -E 's/^[[:space:]]+//; s/[[:space:]]+$//')" + [[ -n "${trimmed}" ]] || continue + if ! printf '%s' "${current}" | rg -F -q "\"${trimmed}\""; then + echo "error: required status check context missing after update: ${trimmed}" >&2 + exit 2 + fi +done + +echo "ok: enforced branch protection for ${repo}:${branch}" +echo "ok: required checks: ${required_checks_csv}" +echo "ok: required approvals: ${required_approvals}"