RedFlag/.gitea/workflows/ci.yml
Fimeg 1370ae9c76 projection: repin the Rust toolchain action
The public workflows retain a reachable immutable Rust setup action for the named Linux capability.

Source-Sha: e8c500c91232b888d35662313553c9332bb98636

Policy-Sha: e8c500c91232b888d35662313553c9332bb98636

Tree-Digest: 881641293edc6e190f34e290fc37c81d1b3cc39341824afbe99b17c9fab21a2d
2026-09-09 20:49:08 -04:00

541 lines
24 KiB
YAML

name: ci
on:
push:
branches: [main, public]
pull_request:
branches: [main, public]
workflow_dispatch:
inputs:
custody_admission:
description: Stage a synthetic candidate for non-publishing internal admission
type: boolean
default: false
publish_source:
description: Explicitly publish the current internal public projection
type: boolean
default: false
public_projection_sha:
description: Exact 40-character internal public SHA authorized for publication
type: string
default: ""
mirror_downstreams:
description: Mirror the published Forge projection to optional downstreams
type: boolean
default: false
jobs:
release-contract:
runs-on: redflag-linux-build
container:
image: debian:13-slim@sha256:d7e12182ce18b85b93007c1dedf31f2d29e01ccf3182cc4017c709b6259bc132
steps:
- name: Install distro runtime
run: apt-get update -qq && apt-get install -y --no-install-recommends ca-certificates curl git nodejs python3
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- name: Verify candidate policy and custody rejection
run: python3 scripts/test-release-contract.py -v
- name: Stage synthetic custody admission fixture
if: github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/main' && github.event.inputs.custody_admission == 'true'
env:
PACKAGE_WRITE_TOKEN: ${{ secrets.PACKAGE_WRITE_TOKEN }}
run: |
set -euo pipefail
python3 scripts/test-release-contract.py --export-fixture admission-fixture
scripts/stage-release-candidate.sh admission-fixture artifacts "admission-${GITHUB_RUN_ID}"
echo "ADMISSION_SHELF_VERSION=admission-${GITHUB_RUN_ID}"
go-vet:
runs-on: redflag-linux-build
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
with:
go-version-file: agent/go.mod
cache: true
- name: go vet (server)
run: cd server && go vet ./...
- name: go vet (agent)
run: cd agent && go vet ./...
go-test:
runs-on: redflag-linux-build
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
with:
go-version-file: agent/go.mod
cache: true
- name: go test -race (server)
run: cd server && go test -race -count=1 ./...
- name: go test -race (agent)
run: cd agent && go test -race -count=1 ./...
rust-test:
runs-on: redflag-linux-build
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: dtolnay/rust-toolchain@6bed0761d98439e5a578e2877258200ad565ba87 # stable
with:
components: clippy
- name: cargo test
run: cd helper && cargo test
- name: Check package-managed binary update refusal
run: cd helper && REDFLAG_PACKAGE_BIN_DIR=/usr/bin cargo test --locked package_managed_binary_updates
- name: cargo clippy
run: cd helper && cargo clippy -- -D warnings
# Cross-compile check: does it build for every target platform?
# Tests run only on native linux-amd64 above; this catches portability
# regressions (cfg(target_os), FFI, path assumptions) without needing
# a runner per OS.
cross-compile:
runs-on: redflag-linux-build
strategy:
fail-fast: false
matrix:
include:
- goos: linux
goarch: arm64
rust_target: aarch64-unknown-linux-gnu
linker: gcc-aarch64-linux-gnu
use_zigbuild: false
skip_helper: false
- goos: windows
goarch: amd64
rust_target: ""
linker: gcc-mingw-w64-x86-64
use_zigbuild: false
skip_helper: true
- goos: darwin
goarch: arm64
rust_target: aarch64-apple-darwin
linker: ""
use_zigbuild: true
skip_helper: false
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
with:
go-version-file: agent/go.mod
cache: true
- uses: dtolnay/rust-toolchain@6bed0761d98439e5a578e2877258200ad565ba87 # stable
with:
targets: ${{ matrix.rust_target }}
- name: Install cross-linker
if: matrix.linker != ''
run: sudo apt-get update && sudo apt-get install -y ${{ matrix.linker }}
- name: Install cargo-zigbuild
if: matrix.use_zigbuild
run: pip3 install --break-system-packages cargo-zigbuild
- name: Cross-compile Go (server)
env:
GOOS: ${{ matrix.goos }}
GOARCH: ${{ matrix.goarch }}
CGO_ENABLED: "0"
run: cd server && go build -o /dev/null ./cmd/server/
- name: Cross-compile Go (agent)
env:
GOOS: ${{ matrix.goos }}
GOARCH: ${{ matrix.goarch }}
CGO_ENABLED: "0"
run: cd agent && go build -o /dev/null ./cmd/agent/
- name: Cross-compile Rust (helper)
if: "!matrix.skip_helper"
run: |
cd helper
if [ "${{ matrix.use_zigbuild }}" = "true" ]; then
cargo zigbuild --release --target ${{ matrix.rust_target }}
else
cargo build --release --target ${{ matrix.rust_target }}
fi
web-build:
runs-on: redflag-linux-build
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: 20
cache: npm
cache-dependency-path: web/package-lock.json
# npm run build = tsc && vite build — type errors and bundling failures
# both surface here, not at release time.
- name: Build web UI
run: cd web && npm ci && npm run build
desktop-check:
runs-on: redflag-linux-build
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
with:
go-version-file: agent/go.mod
cache: true
- uses: dtolnay/rust-toolchain@6bed0761d98439e5a578e2877258200ad565ba87 # stable
- name: Install Qt 6 build dependencies
run: |
sudo apt-get update -qq
sudo apt-get install -y -qq qt6-base-dev qt6-declarative-dev qt6-declarative-dev-tools libgl1-mesa-dev
# A release build subsumes the old `cargo check` and leaves behind
# something a human can install. Nothing else in CI emitted a Desktop
# binary, so a one-line fix could only be tried by cutting a release tag.
- name: Build native RedFlag Desktop
run: cd desktop && cargo build --release --locked
- name: Prove the binary links and names its version
run: desktop/target/release/redflag-desktop --version
- name: Package the matching Linux Agent, helper and Desktop
run: |
set -euo pipefail
VERSION=$(desktop/target/release/redflag-desktop --version | awk '{sub(/^v/, "", $2); print $2}')
mkdir -p dist
(cd agent && go build -trimpath -ldflags "-X github.com/Fimeg/RedFlag/agent/internal/version.Version=$VERSION" -o ../dist/redflag-agent-linux-amd64 ./cmd/agent)
(cd helper && REDFLAG_PACKAGE_BIN_DIR=/usr/bin REDFLAG_RELEASE_VERSION="$VERSION" cargo build --release --locked)
cp helper/target/release/redflag-helper dist/redflag-helper-linux-amd64
cp desktop/target/release/redflag-desktop dist/redflag-desktop-linux-amd64
bash installer/linux/build-deb.sh --version "$VERSION" --bindir dist --outdir dist
bash installer/linux/inspect-deb.sh "dist/redflag_${VERSION}_amd64.deb" --version "$VERSION"
sha256sum dist/redflag_*_amd64.deb > dist/desktop-package.sha256
- uses: actions/upload-artifact@ff15f0306b3f739f7b6fd43fb5d26cd321bd4de5 # v3
with:
name: redflag-desktop-linux-amd64-package
path: |
dist/redflag_*_amd64.deb
dist/desktop-package.sha256
if-no-files-found: error
retention-days: 30
- uses: actions/upload-artifact@ff15f0306b3f739f7b6fd43fb5d26cd321bd4de5 # v3
with:
name: redflag-desktop-linux-amd64
path: desktop/target/release/redflag-desktop
retention-days: 30
installer-integrity:
runs-on: redflag-linux-build
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
with:
go-version-file: agent/go.mod
cache: true
- name: Install template integrity
run: cd server && go test -run 'TestInstallTemplateRenders|TestFreshInstallConfigKeys|TestInstallTemplateScriptletSyntax' -v -count=1 ./internal/services/
- name: Package ownership guards
run: python3 -m unittest discover -s installer/linux -p 'test_*.py' -v
# Dependency vulnerability scanning — RedFlag held to the supply-chain standard
# it enforces on the fleet. Tools installed directly (no third-party actions) so
# the socket-mounted runner's surface stays small. Go is reachability-gated via a
# documented allowlist (.govulncheck-allow, mirrored in SECURITY.md); npm gates the
# production tree and treats dev-only advisories as warnings; cargo gates outright.
dep-scan:
runs-on: redflag-linux-build
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
with:
go-version-file: agent/go.mod
cache: true
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: 20
cache: npm
cache-dependency-path: web/package-lock.json
- uses: dtolnay/rust-toolchain@6bed0761d98439e5a578e2877258200ad565ba87 # stable
# Provenance first: record the substrate even if a later scan fails the job.
# "Are we using hacked programs to build it?" — this is how we SEE the answer.
# Floor enforcement (fail on an out-of-date engine/toolchain) is the next layer.
- name: Record build substrate
run: |
{
echo "## Build substrate"
echo '```'
echo "go: $(go version)"
echo "rustc: $(rustc --version)"
echo "cargo: $(cargo --version)"
echo "node: $(node --version)"
echo "npm: $(npm --version)"
echo "docker: $(docker version --format '{{.Server.Version}}' 2>&1 || echo 'no engine reachable')"
echo "runner: ${RUNNER_NAME:-unknown} / $(uname -srm)"
echo '```'
} | tee -a "${GITHUB_STEP_SUMMARY:-/dev/stdout}"
# Scanners run latest on purpose — an old scanner misses new advisories.
- name: Install scanners
run: |
go install golang.org/x/vuln/cmd/govulncheck@latest
cargo install cargo-audit --locked
# One audited script gates Go (reachability + allowlist), web (prod tree),
# and the Rust helper. release.yml runs the same script with --posture-out
# to emit the attested posture — CI and release can't drift on the verdict.
- name: Dependency gate
run: scripts/dep-scan.sh
commit-voice:
runs-on: redflag-linux-build
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
fetch-depth: 0
- name: Test and enforce the public commit voice
run: |
python3 -m unittest discover -s .publication -p 'test_commit_voice.py'
if [ "${{ github.ref }}" = "refs/heads/public" ] || [ "${{ github.base_ref }}" = "public" ]; then
RANGE="${{ github.sha }}"
elif [ "${{ github.event_name }}" = "pull_request" ]; then
RANGE="${{ github.event.pull_request.base.sha }}..${{ github.sha }}"
else
RANGE="${{ github.event.before }}..${{ github.sha }}"
# A manual re-run carries no before sha, and neither does a first
# push. Checking HEAD alone would let one clean tip commit launder
# the history behind it and report green, so both fall back to a
# window rather than a single commit.
if [ "${{ github.event_name }}" = "workflow_dispatch" ] || \
[ "${{ github.event.before }}" = "0000000000000000000000000000000000000000" ]; then
# A run owns what its branch adds to the trunk, so measure from
# the merge base. A counted window reaches past that base and
# fails on commits the run did not introduce.
BASE="$(git merge-base origin/main HEAD 2>/dev/null || true)"
if [ -n "$BASE" ] && [ "$BASE" != "$(git rev-parse HEAD)" ]; then
RANGE="$BASE..HEAD"
else
RANGE="$(git rev-list --max-count=10 HEAD | tail -1)^..HEAD"
git rev-parse --verify "${RANGE%%..*}" >/dev/null 2>&1 || RANGE="HEAD"
fi
fi
fi
python3 .publication/commit_voice.py --range "$RANGE" \
--allowlist .publication/commit-voice-allowlist.json \
--repository Fimeg/RedFlag
action-pins:
runs-on: redflag-linux-build
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- name: Check for floating action refs
run: |
if grep -rE 'uses:.*@(v[0-9]+|stable|main|master)(\s|$)' .gitea/workflows/; then
echo "::error::Floating action refs found — run scripts/update-action-pins.sh"
exit 1
fi
echo "All action refs are SHA-pinned."
public-history:
runs-on: redflag-linux-build
if: github.ref == 'refs/heads/public' || github.base_ref == 'public'
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
fetch-depth: 0
- name: Fetch pinned gitleaks
run: |
command -v curl || (apt-get update -qq && apt-get install -y -qq curl)
curl -fsSL -o /tmp/gitleaks.tar.gz \
https://github.com/gitleaks/gitleaks/releases/download/v8.30.1/gitleaks_8.30.1_linux_x64.tar.gz
echo '551f6fc83ea457d62a0d98237cbad105af8d557003051f41f3e7ca7b3f2470eb /tmp/gitleaks.tar.gz' |
sha256sum -c -
tar -xzf /tmp/gitleaks.tar.gz -C /tmp gitleaks
- name: Scan complete public history
run: |
/tmp/gitleaks git . --redact --no-banner --log-opts="$GITHUB_SHA"
scripts/check-public-history.sh HEAD
# Absence of known-secret content is not authorization to publish. This job
# asks the other question: does this tree belong outside at all. The manifest
# it reads is the authority for what may cross, and changing that manifest is
# a public-surface decision that shows up in this diff rather than in nobody's
# memory.
public-surface:
runs-on: redflag-linux-build
if: github.ref == 'refs/heads/public' || github.base_ref == 'public'
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
fetch-depth: 0
- name: Gate the public surface
run: |
set -euo pipefail
python3 -m unittest discover -s .publication -p 'test_surface_gate.py'
python3 .publication/surface_gate.py \
--repo . --sha "$GITHUB_SHA" \
--manifest .publication/surface.json \
--out /tmp/public-surface.md
cat /tmp/public-surface.md
grep -q '^\*\*Verdict: \(PASS\|REVIEW\)\*\*' /tmp/public-surface.md
# An internal public push proves that a projection is safe to disclose; it
# does not disclose it. Publication requires a manual dispatch on the public
# branch with both the boolean authority and the exact tested SHA. The
# internal forge remains the only writer, and the result is read back
# anonymously before any optional downstream moves.
publish-forge:
runs-on: redflag-linux-build
needs: [go-vet, go-test, rust-test, cross-compile, web-build, desktop-check, installer-integrity, dep-scan, commit-voice, action-pins, public-history, public-surface]
if: github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/public' && github.event.inputs.publish_source == 'true'
env:
PUBLIC_FORGE_TOKEN: ${{ secrets.PUBLIC_FORGE_TOKEN }}
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
fetch-depth: 0
- name: Publish and verify exact SHA
env:
AUTHORIZED_SHA: ${{ github.event.inputs.public_projection_sha }}
run: |
set -euo pipefail
: "${PUBLIC_FORGE_TOKEN:?PUBLIC_FORGE_TOKEN is required}"
expected="${GITHUB_SHA}"
[[ "$AUTHORIZED_SHA" =~ ^[0-9a-f]{40}$ ]]
if [ "$AUTHORIZED_SHA" != "$expected" ]; then
echo "::error::authorized projection $AUTHORIZED_SHA does not equal tested workflow SHA $expected"
exit 1
fi
forge_url='https://forge.caseytunturi.com/Fimeg/RedFlag.git'
# Ordinary publication is fast-forward only. .publication/EPOCH may
# authorise exactly one replacement, and only of the SHA it names, so
# the authorisation is spent the moment it is used.
epoch_replaces() {
[ -f .publication/EPOCH ] || return 1
awk '$1 == "replaces" { print $2 }' .publication/EPOCH
}
check_publishable() {
url="$1"
remote_sha="$(git ls-remote "$url" refs/heads/public | awk '{print $1}')"
if [ -z "$remote_sha" ]; then
return 0
fi
if git cat-file -e "${remote_sha}^{commit}" 2>/dev/null &&
git merge-base --is-ancestor "$remote_sha" "$expected"; then
return 0
fi
authorised="$(epoch_replaces || true)"
if [ -n "$authorised" ] && [ "$authorised" = "$remote_sha" ]; then
echo "[publish] epoch authorised to replace $remote_sha"
EPOCH_LEASE="$remote_sha"
return 0
fi
echo "[publish] refusing non-fast-forward public history: $url" >&2
echo "[publish] remote is $remote_sha; .publication/EPOCH authorises ${authorised:-nothing}" >&2
exit 1
}
EPOCH_LEASE=""
check_publishable "$forge_url"
forge_auth="$(printf 'publisher-redflag:%s' "$PUBLIC_FORGE_TOKEN" | base64 -w0)"
if [ -n "$EPOCH_LEASE" ]; then
git -c "http.https://forge.caseytunturi.com/.extraheader=Authorization: Basic $forge_auth" \
push --force-with-lease="refs/heads/public:$EPOCH_LEASE" "$forge_url" public:public
else
git -c "http.https://forge.caseytunturi.com/.extraheader=Authorization: Basic $forge_auth" \
push "$forge_url" public:public
fi
forge_sha="$(git ls-remote "$forge_url" refs/heads/public | awk '{print $1}')"
test "$forge_sha" = "$expected"
# Fetch the anonymous Forgejo ref back into the checkout. Downstream
# receives this ref, not the internal checkout ref that happened to
# produce it.
git fetch --force --no-tags "$forge_url" \
refs/heads/public:refs/remotes/public-forge/public
test "$(git rev-parse refs/remotes/public-forge/public)" = "$forge_sha"
echo "[publish] Forgejo anonymously serves exact tested SHA: $forge_sha"
# Downstreams reproduce the anonymously fetched Forgejo ref. They are
# deliberately best-effort: a missing credential or divergent history is a
# visible degraded mirror, never a failure of the canonical publication.
mirror-downstreams:
runs-on: redflag-linux-build
needs: [publish-forge]
if: github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/public' && github.event.inputs.publish_source == 'true' && github.event.inputs.mirror_downstreams == 'true'
env:
CODEBERG_TOKEN: ${{ secrets.CODEBERG_TOKEN }}
MIRROR_GITHUB_TOKEN: ${{ secrets.MIRROR_GITHUB_TOKEN }}
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
fetch-depth: 0
- name: Mirror optional downstreams from Forgejo
run: |
set -euo pipefail
expected="${GITHUB_SHA}"
forge_url='https://forge.caseytunturi.com/Fimeg/RedFlag.git'
forge_sha="$(git ls-remote "$forge_url" refs/heads/public | awk '{print $1}')"
test "$forge_sha" = "$expected"
git fetch --force --no-tags "$forge_url" \
refs/heads/public:refs/remotes/public-forge/public
test "$(git rev-parse refs/remotes/public-forge/public)" = "$forge_sha"
mirror_downstream() {
label="$1"
url="$2"
host="$3"
user="$4"
token="$5"
if [ -z "$token" ]; then
echo "::warning::[mirror] $label credential absent; Forgejo is published, $label is degraded"
return 0
fi
if ! remote_sha="$(git ls-remote "$url" refs/heads/public | awk '{print $1}')"; then
echo "::warning::[mirror] cannot read $label public ref; Forgejo remains authoritative"
return 0
fi
lease=""
if [ -n "$remote_sha" ]; then
if ! git fetch --force --no-tags "$url" \
"refs/heads/public:refs/remotes/mirror-check/$label"; then
echo "::warning::[mirror] cannot fetch $label public ref; leaving it unchanged"
return 0
fi
if ! git merge-base --is-ancestor "$remote_sha" "$forge_sha"; then
# A mirror may follow the same epoch the forge just accepted,
# and only from the SHA that epoch names. Anything else is the
# divergence this branch has always refused.
authorised="$(awk '$1 == "replaces" { print $2 }' .publication/EPOCH 2>/dev/null || true)"
if [ -n "$authorised" ] && [ "$authorised" = "$remote_sha" ]; then
echo "[mirror] $label follows the authorised epoch from $remote_sha"
lease="$remote_sha"
else
echo "::warning::[mirror] refusing non-fast-forward $label history; recovery ref and explicit alignment required"
return 0
fi
fi
fi
auth="$(printf '%s:%s' "$user" "$token" | base64 -w0)"
if [ -n "$lease" ]; then
if ! git -c "http.https://$host/.extraheader=Authorization: Basic $auth" \
push --force-with-lease="refs/heads/public:$lease" \
"$url" refs/remotes/public-forge/public:public; then
echo "::warning::[mirror] $label epoch push failed; Forgejo remains authoritative"
return 0
fi
elif ! git -c "http.https://$host/.extraheader=Authorization: Basic $auth" \
push "$url" refs/remotes/public-forge/public:public; then
echo "::warning::[mirror] $label push failed; Forgejo remains authoritative"
return 0
fi
mirrored_sha="$(git ls-remote "$url" refs/heads/public | awk '{print $1}')"
if [ "$mirrored_sha" != "$forge_sha" ]; then
echo "::warning::[mirror] $label SHA mismatch after push; Forgejo remains authoritative"
return 0
fi
echo "[mirror] $label agrees with Forgejo: $forge_sha"
}
mirror_downstream codeberg 'https://codeberg.org/Fimeg/RedFlag.git' codeberg.org Fimeg "$CODEBERG_TOKEN"
mirror_downstream github 'https://github.com/Fimeg/RedFlag.git' github.com Fimeg "$MIRROR_GITHUB_TOKEN"