From 4dbd831d0b227733b4a1775efb5a8224cd607717 Mon Sep 17 00:00:00 2001 From: "every.channel" Date: Mon, 16 Feb 2026 00:59:52 -0500 Subject: [PATCH] ci: switch deploy secrets to age key workflow --- .forgejo/workflows/deploy-cloudflare.yml | 82 +++++++++++++++---- .../proposals/ECP-0061-agenix-secrets.md | 2 + .../proposals/ECP-0062-ci-age-key-secrets.md | 42 ++++++++++ flake.nix | 5 ++ scripts/fj-auth-codeberg.sh | 12 ++- scripts/fj-set-age-key-secret.sh | 33 ++++++++ secrets.nix | 7 +- secrets/README.md | 16 +++- secrets/cloudflare-api-token.age | 7 ++ secrets/codeberg-token.age | 10 ++- 10 files changed, 186 insertions(+), 30 deletions(-) create mode 100644 evolution/proposals/ECP-0062-ci-age-key-secrets.md create mode 100755 scripts/fj-set-age-key-secret.sh create mode 100644 secrets/cloudflare-api-token.age diff --git a/.forgejo/workflows/deploy-cloudflare.yml b/.forgejo/workflows/deploy-cloudflare.yml index b0955d7..d5632a0 100644 --- a/.forgejo/workflows/deploy-cloudflare.yml +++ b/.forgejo/workflows/deploy-cloudflare.yml @@ -11,25 +11,76 @@ concurrency: jobs: deploy: - # Use Codeberg global hosted runners by label. - # Available labels in this repo are: codeberg-tiny, codeberg-small, codeberg-medium. runs-on: codeberg-medium steps: - - name: Deploy website + worker - env: - CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} - CODEBERG_TOKEN: ${{ secrets.CODEBERG_TOKEN }} + - name: Checkout + uses: https://code.forgejo.org/actions/checkout@v4 + with: + token: ${{ github.token }} + fetch-depth: 0 + lfs: false + + - name: Bootstrap runner deps + shell: bash run: | set -euo pipefail - if [[ ! -f "./scripts/deploy-workers.sh" ]]; then - if [[ -z "${CODEBERG_TOKEN:-}" ]]; then - echo "error: workspace missing repo files and CODEBERG_TOKEN is not set" + as_root() { + if [[ "$(id -u)" = "0" ]]; then + "$@" + elif command -v sudo >/dev/null 2>&1; then + sudo "$@" + else + echo "error: need root or sudo to install runner dependencies" exit 2 fi - git clone "https://every-channel:${CODEBERG_TOKEN}@codeberg.org/every-channel/every.channel.git" .repo - cd .repo + } + if command -v apt-get >/dev/null 2>&1; then + as_root apt-get update + as_root apt-get install -y curl ca-certificates nodejs npm age + elif command -v apk >/dev/null 2>&1; then + as_root apk add --no-cache curl ca-certificates nodejs npm age fi + - name: Configure CI Age identity + env: + AGE_FORGE_SSH_KEY: ${{ secrets.AGE_FORGE_SSH_KEY }} + shell: bash + run: | + set -euo pipefail + if [[ -z "${AGE_FORGE_SSH_KEY:-}" ]]; then + echo "error: missing Actions secret AGE_FORGE_SSH_KEY" + exit 2 + fi + install -d -m 700 "$HOME/.ssh" + if [[ "${AGE_FORGE_SSH_KEY}" == "-----BEGIN OPENSSH PRIVATE KEY-----"* ]]; then + printf '%s\n' "${AGE_FORGE_SSH_KEY}" > "$HOME/.ssh/age_forge_ed25519" + else + printf '%s' "${AGE_FORGE_SSH_KEY}" | base64 -d > "$HOME/.ssh/age_forge_ed25519" + fi + chmod 600 "$HOME/.ssh/age_forge_ed25519" + + - name: Decrypt CI secrets from repo + shell: bash + run: | + set -euo pipefail + key_file="$HOME/.ssh/age_forge_ed25519" + secret_file="secrets/cloudflare-api-token.age" + if [[ ! -f "$secret_file" ]]; then + echo "error: missing ${secret_file}" + exit 2 + fi + CLOUDFLARE_API_TOKEN="$(age -d -i "$key_file" "$secret_file")" + if [[ -z "${CLOUDFLARE_API_TOKEN}" ]]; then + echo "error: decrypted CLOUDFLARE_API_TOKEN is empty" + exit 2 + fi + echo "::add-mask::${CLOUDFLARE_API_TOKEN}" + echo "CLOUDFLARE_API_TOKEN=${CLOUDFLARE_API_TOKEN}" >> "$GITHUB_ENV" + + - name: Build site (Dioxus web) + shell: bash + run: | + set -euo pipefail if ! command -v cargo >/dev/null 2>&1; then curl -fsSL https://sh.rustup.rs | sh -s -- -y --profile minimal . "$HOME/.cargo/env" @@ -41,14 +92,13 @@ jobs: cargo install trunk --locked fi - if ! command -v npm >/dev/null 2>&1; then - echo "error: npm is not available on this runner" - exit 2 - fi - cd apps/tauri/ui trunk build --release --public-url / + - name: Deploy worker + shell: bash + run: | + set -euo pipefail cd ../../../deploy/cloudflare-worker npm ci npx wrangler deploy diff --git a/evolution/proposals/ECP-0061-agenix-secrets.md b/evolution/proposals/ECP-0061-agenix-secrets.md index 2438271..18633a6 100644 --- a/evolution/proposals/ECP-0061-agenix-secrets.md +++ b/evolution/proposals/ECP-0061-agenix-secrets.md @@ -2,6 +2,8 @@ Status: Draft +Note: CI handling in this proposal is superseded by `ECP-0062` (single SSH identity + repo-encrypted secrets for deploy). + ## Goal Provide a simple, repo-native way to manage a small set of long-lived tokens for local development without committing plaintext secrets: diff --git a/evolution/proposals/ECP-0062-ci-age-key-secrets.md b/evolution/proposals/ECP-0062-ci-age-key-secrets.md new file mode 100644 index 0000000..9ad5658 --- /dev/null +++ b/evolution/proposals/ECP-0062-ci-age-key-secrets.md @@ -0,0 +1,42 @@ +# ECP-0062: CI Secrets via Single SSH Identity + Repo-Encrypted Age Files + +Status: Draft + +## Goal + +Keep CI secret handling minimal and auditable: + +- one Forgejo Actions secret containing an SSH private key (`AGE_FORGE_SSH_KEY`), +- all runtime credentials stored in git as encrypted `.age` files, +- no CI dependence on repo cloning tokens (`CODEBERG_TOKEN`) for deploy. + +## Non-Goals + +- Replacing local developer token helpers (`scripts/fj-auth-codeberg.sh`). +- Defining protocol-level stream key distribution. + +## Proposal + +1. Deploy workflow uses `actions/checkout` with `github.token` and drops the clone fallback path. +2. Deploy workflow requires one secret only: `AGE_FORGE_SSH_KEY`. +3. Deploy workflow decrypts `secrets/cloudflare-api-token.age` at runtime via `age -d -i `. +4. `CLOUDFLARE_API_TOKEN` is exported into `GITHUB_ENV` only for the current job. +5. `CODEBERG_TOKEN` is removed from deploy workflow requirements. + +## Rationale + +This matches the key.store operational model: + +- one root automation identity in Forgejo, +- encrypted secrets versioned in-repo, +- no plaintext token files in CI configuration. + +It reduces secret sprawl, removes accidental token coupling, and keeps deploy bootstrap deterministic. + +## Rollout / Reversibility + +- Additive migration: + - set `AGE_FORGE_SSH_KEY` in Forgejo, + - commit encrypted `secrets/cloudflare-api-token.age`, + - run deploy. +- Reversible by reintroducing direct Actions secret env injection if needed. diff --git a/flake.nix b/flake.nix index 3c5bd0e..3dda17c 100644 --- a/flake.nix +++ b/flake.nix @@ -37,6 +37,11 @@ ]; in { + packages = { + agenix = agenixPkg; + fj = pkgs.forgejo-cli; + }; + devShells.default = pkgs.mkShell { buildInputs = with pkgs; [ rust diff --git a/scripts/fj-auth-codeberg.sh b/scripts/fj-auth-codeberg.sh index 94d49e1..60f95e5 100755 --- a/scripts/fj-auth-codeberg.sh +++ b/scripts/fj-auth-codeberg.sh @@ -9,13 +9,19 @@ cd "${root}" # Auth token source order: # 1) CODEBERG_TOKEN env var # 2) `agenix -d secrets/codeberg-token.age` (optional) +# 3) `age -d -i secrets/codeberg-token.age` (optional) 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 && -x "$(command -v agenix)" ]]; then - export CODEBERG_TOKEN - CODEBERG_TOKEN="$(RULES="${rules_file}" agenix -d secrets/codeberg-token.age -i "${identity_file}")" +if [[ -z "${CODEBERG_TOKEN:-}" && -f secrets/codeberg-token.age ]]; then + if command -v agenix >/dev/null 2>&1; then + export CODEBERG_TOKEN + CODEBERG_TOKEN="$(RULES="${rules_file}" agenix -d secrets/codeberg-token.age -i "${identity_file}")" + elif command -v age >/dev/null 2>&1; then + export CODEBERG_TOKEN + CODEBERG_TOKEN="$(age -d -i "${identity_file}" secrets/codeberg-token.age)" + fi fi if [[ -z "${CODEBERG_TOKEN:-}" ]]; then diff --git a/scripts/fj-set-age-key-secret.sh b/scripts/fj-set-age-key-secret.sh new file mode 100755 index 0000000..29e2a66 --- /dev/null +++ b/scripts/fj-set-age-key-secret.sh @@ -0,0 +1,33 @@ +#!/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}" +secret_name="${EVERY_CHANNEL_FORGE_AGE_SECRET_NAME:-AGE_FORGE_SSH_KEY}" +key_path="${1:-$HOME/.config/every.channel/keys/founder_ed25519}" + +if [[ ! -f "${key_path}" ]]; then + echo "error: key file not found: ${key_path}" >&2 + exit 2 +fi +if ! command -v fj >/dev/null 2>&1; then + echo "error: fj not found in PATH (run: nix develop)" >&2 + exit 2 +fi + +"${root}/scripts/fj-auth-codeberg.sh" >/dev/null + +key_data="$(base64 < "${key_path}" | tr -d '\n')" +if [[ -z "${key_data}" ]]; then + echo "error: key file is empty: ${key_path}" >&2 + exit 2 +fi + +# Upsert by delete/create because fj currently exposes create/delete. +fj -H "${host}" actions -r "${repo}" secrets delete "${secret_name}" >/dev/null 2>&1 || true +fj -H "${host}" actions -r "${repo}" secrets create "${secret_name}" "${key_data}" >/dev/null + +echo "ok: set ${secret_name} on ${repo} via ${host} (base64-encoded)" diff --git a/secrets.nix b/secrets.nix index 59e4b6b..9a38352 100644 --- a/secrets.nix +++ b/secrets.nix @@ -1,9 +1,10 @@ let # Founder SSH public key (recipient). Safe to commit. founder = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIJCBTSEEcBOhOkf3WF1e8xmblAZHvgTibFsqck2GY8D/"; + # Forge automation SSH public key (recipient). Safe to commit. + forge = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFmKJt5+uilix5Ldiaaq1BhrYNjmV5lHcW7D/5inCCnO forge@every.channel"; in { - "secrets/cloudflare-api-token.age".publicKeys = [ founder ]; - "secrets/codeberg-token.age".publicKeys = [ founder ]; + "secrets/cloudflare-api-token.age".publicKeys = [ founder forge ]; + "secrets/codeberg-token.age".publicKeys = [ founder forge ]; } - diff --git a/secrets/README.md b/secrets/README.md index 7459f35..c9b9fd9 100644 --- a/secrets/README.md +++ b/secrets/README.md @@ -1,13 +1,21 @@ # Secrets (agenix) -This repo supports optional local secrets management via `agenix`. +This repo supports local + CI secrets management via `agenix`/`age`. -CI should prefer Forgejo Actions secrets (e.g. `CLOUDFLARE_API_TOKEN`) rather than decrypting secrets in runners. +CI deploys use one Forgejo Actions secret: + +- `AGE_FORGE_SSH_KEY`: SSH private key used to decrypt repo-tracked `.age` files. + +Set/update it with: + +```sh +nix develop -c ./scripts/fj-set-age-key-secret.sh ~/.config/every.channel/keys/forge_ci_ed25519 +``` ## Files - `secrets/secrets.nix`: recipients + secret file mapping -- `secrets/cloudflare-api-token.age`: encrypted Cloudflare API token (optional) +- `secrets/cloudflare-api-token.age`: encrypted Cloudflare API token (used by deploy workflow) - `secrets/codeberg-token.age`: encrypted Codeberg/Forgejo token for `fj` (optional) ## Create / edit secrets (local) @@ -32,4 +40,4 @@ agenix -d secrets/cloudflare-api-token.age ## Decryption identity -`agenix` decrypts using your local SSH key material. The private key must be available locally but is never committed to the repo. +`agenix`/`age` decrypts using SSH private key material. The private key must be available locally (or injected as CI secret) and is never committed to the repo. diff --git a/secrets/cloudflare-api-token.age b/secrets/cloudflare-api-token.age new file mode 100644 index 0000000..330f4a2 --- /dev/null +++ b/secrets/cloudflare-api-token.age @@ -0,0 +1,7 @@ +age-encryption.org/v1 +-> ssh-ed25519 29OJ4A zgIVFl3ybukZblX6BIQwL+safny154q0FzRS4KTXV3w +4u7A8ymx0dZQE7oKnzdzWtObT+BZV1HtPiWDHW9WGWA +-> ssh-ed25519 E6Q+Lw WwMbUn454lqkAZtt9GOGWZAl4dvZ2YEQatK3rViz6DM +VD3c+PxIwZ9cZmv7U2bXFiN1UlTQYImbeap1v2MvnBw +--- gH+1U1LQN7CB7L2Tk7oLwgWjnqfFTNVau3NJSJUAEJ8 +,),MM!4ޯi gďGj⛽R :vjoկtlʬZ)Nju۩ \ No newline at end of file diff --git a/secrets/codeberg-token.age b/secrets/codeberg-token.age index 7623509..eb067a4 100644 --- a/secrets/codeberg-token.age +++ b/secrets/codeberg-token.age @@ -1,5 +1,7 @@ age-encryption.org/v1 --> ssh-ed25519 29OJ4A G6byj6PhWofxSh8K5FGSqBs5W5uKtyJ2MGY1JFb+STc -d25eWVNmz2+0zKVVRZ/Pib4YZClhJrML6s3hbLh9rMU ---- t/6aoMSRLI8vay71VugNOGwKHjHteiC+SinD6gQARYM -8E/?ᢿw JlSzi 8)`;B4LT?;z\ \ No newline at end of file +-> ssh-ed25519 29OJ4A 0tKPVpGaDJTMMhyy9/+rswkNttrVhttZFM6ORiMapjk +/EVUgqbde8gJQubCzOeTfVIeE1VYF4W681LxfA1fp0k +-> ssh-ed25519 E6Q+Lw Wn4IY+Flb2mpZFWg/iMGSua298EEiBJgMqdnv+BCkA4 +hZBMEYsq9GCLLAh6KXaJbNSs3sks/oH74hoQUPDvMKM +--- m5RYGCTBkYSET5SfeqdOhfLij9sPDzglIPwVnmaEeGA +c$2tҐ03U+u 7kUo")ZBiTA*yL|=lњ/BP \ No newline at end of file