Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,11 @@ GITFORGE_JOB_FENCE_GRACE_SECS=300
# scheduler refuses runner/operator requests when it is unset, and runners
# cannot register without it. Generate with: openssl rand -hex 32
GITFORGE_SCHEDULER_TOKEN=
# Dedicated credential for the runner-registration surfaces (the scheduler's
# POST /runners and the gateway's POST /api/runners). Required in compose
# deployments: registration is fail-closed without it, so every registration
# is refused. Generate with: openssl rand -hex 32
GITFORGE_RUNNER_REGISTRATION_TOKEN=
# Registration-failure policy: deny (default) exits the runner when the
# scheduler is unreachable; allow falls back to standalone execution.
GITFORGE_RUNNER_STANDALONE=deny
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/ai-review.yml
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ env:
jobs:
ai-review:
name: AI Code Review
runs-on: [self-hosted, linux, x64, gitforge-github]
runs-on: ${{ !github.event.pull_request.head.repo.fork && fromJSON('["self-hosted", "linux", "x64", "gitforge-github"]') || 'ubuntu-latest' }}
steps:
- name: Checkout code
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/always.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ permissions:

jobs:
check:
runs-on: [self-hosted, linux, x64, gitforge-github]
runs-on: ubuntu-latest
steps:
- name: CI check
run: echo "CI check passed — repository validated"
2 changes: 1 addition & 1 deletion .github/workflows/codeql.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ concurrency:
jobs:
analyze:
name: Analyze
runs-on: [self-hosted, linux, x64, gitforge-github]
runs-on: ${{ !github.event.pull_request.head.repo.fork && fromJSON('["self-hosted", "linux", "x64", "gitforge-github"]') || 'ubuntu-latest' }}
# Rust query evaluation can exceed the default job budget on this
# multi-crate workspace; keep the guard bounded but allow completion.
timeout-minutes: 45
Expand Down
14 changes: 9 additions & 5 deletions .github/workflows/gitforge-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,12 @@ jobs:
# Enqueue build job to GitForge queue instead of running directly
enqueue-build:
name: Enqueue Build to GitForge
if: vars.GITFORGE_ENABLED == 'true'
runs-on: [self-hosted, linux, x64, gitforge-github]
# Fork PRs never reach the private GitForge instance: secrets are
# unavailable and their head commits are not on the mirror. The
# required-check gap this would leave is acceptable — the live
# Safeguards ruleset carries no required_status_checks rule.
if: vars.GITFORGE_ENABLED == 'true' && !github.event.pull_request.head.repo.fork
runs-on: ${{ !github.event.pull_request.head.repo.fork && fromJSON('["self-hosted", "linux", "x64", "gitforge-github"]') || 'ubuntu-latest' }}
outputs:
run_id: ${{ steps.enqueue.outputs.run_id }}
commit: ${{ steps.enqueue.outputs.commit }}
Expand Down Expand Up @@ -138,7 +142,7 @@ jobs:
name: Poll GitForge Build Status
needs: enqueue-build
if: needs.enqueue-build.outputs.build_status == 'enqueued'
runs-on: [self-hosted, linux, x64, gitforge-github]
runs-on: ${{ !github.event.pull_request.head.repo.fork && fromJSON('["self-hosted", "linux", "x64", "gitforge-github"]') || 'ubuntu-latest' }}
outputs:
terminal_status: ${{ steps.poll.outputs.terminal_status }}
steps:
Expand Down Expand Up @@ -247,8 +251,8 @@ jobs:
report-status:
name: Report Build Status
needs: [enqueue-build, poll-build]
if: always() && vars.GITFORGE_ENABLED == 'true'
runs-on: [self-hosted, linux, x64, gitforge-github]
if: always() && vars.GITFORGE_ENABLED == 'true' && !github.event.pull_request.head.repo.fork
runs-on: ${{ !github.event.pull_request.head.repo.fork && fromJSON('["self-hosted", "linux", "x64", "gitforge-github"]') || 'ubuntu-latest' }}
steps:
- name: Report completion
run: |
Expand Down
4 changes: 2 additions & 2 deletions .github/workflows/integration.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ permissions:

jobs:
integration:
runs-on: [self-hosted, linux, x64, gitforge-github]
runs-on: ${{ !github.event.pull_request.head.repo.fork && fromJSON('["self-hosted", "linux", "x64", "gitforge-github"]') || 'ubuntu-latest' }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1

Expand All @@ -42,7 +42,7 @@ jobs:
fi

e2e:
runs-on: [self-hosted, linux, x64, gitforge-github]
runs-on: ${{ !github.event.pull_request.head.repo.fork && fromJSON('["self-hosted", "linux", "x64", "gitforge-github"]') || 'ubuntu-latest' }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1

Expand Down
14 changes: 7 additions & 7 deletions .github/workflows/rust-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ jobs:
# ─── Code Formatting ─────────────────────────────────────────────────────────
fmt:
name: Format
runs-on: [self-hosted, linux, x64, gitforge-github]
runs-on: ${{ !github.event.pull_request.head.repo.fork && fromJSON('["self-hosted", "linux", "x64", "gitforge-github"]') || 'ubuntu-latest' }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Install Rust
Expand All @@ -40,7 +40,7 @@ jobs:
# ─── Linting ────────────────────────────────────────────────────────────────
clippy:
name: Clippy
runs-on: [self-hosted, linux, x64, gitforge-github]
runs-on: ${{ !github.event.pull_request.head.repo.fork && fromJSON('["self-hosted", "linux", "x64", "gitforge-github"]') || 'ubuntu-latest' }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Install Rust
Expand Down Expand Up @@ -86,7 +86,7 @@ jobs:
# GITFORGE_SANDBOX_ACQUIRE_SECS raises the acquire window alongside it.
test:
name: Test
runs-on: [self-hosted, linux, x64, gitforge-github]
runs-on: ${{ !github.event.pull_request.head.repo.fork && fromJSON('["self-hosted", "linux", "x64", "gitforge-github"]') || 'ubuntu-latest' }}
env:
GITFORGE_DOCKER_TIMEOUT_SECS: "600"
GITFORGE_SANDBOX_ACQUIRE_SECS: "300"
Expand All @@ -104,7 +104,7 @@ jobs:
# race detector requires a separate supported toolchain/target strategy.
test-serialized:
name: Test (Serialized)
runs-on: [self-hosted, linux, x64, gitforge-github]
runs-on: ${{ !github.event.pull_request.head.repo.fork && fromJSON('["self-hosted", "linux", "x64", "gitforge-github"]') || 'ubuntu-latest' }}
env:
GITFORGE_DOCKER_TIMEOUT_SECS: "600"
GITFORGE_SANDBOX_ACQUIRE_SECS: "300"
Expand All @@ -129,7 +129,7 @@ jobs:
# 120 s-timeout signature on 2026-09-24 while the Test jobs stayed green).
coverage:
name: Coverage
runs-on: [self-hosted, linux, x64, gitforge-github]
runs-on: ${{ !github.event.pull_request.head.repo.fork && fromJSON('["self-hosted", "linux", "x64", "gitforge-github"]') || 'ubuntu-latest' }}
env:
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
GITFORGE_DOCKER_TIMEOUT_SECS: "600"
Expand Down Expand Up @@ -192,7 +192,7 @@ jobs:
# ─── Security Audit ─────────────────────────────────────────────────────────
security:
name: Security Audit
runs-on: [self-hosted, linux, x64, gitforge-github]
runs-on: ${{ !github.event.pull_request.head.repo.fork && fromJSON('["self-hosted", "linux", "x64", "gitforge-github"]') || 'ubuntu-latest' }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Install Rust
Expand Down Expand Up @@ -226,7 +226,7 @@ jobs:
# ─── Build ──────────────────────────────────────────────────────────────────
build:
name: Build
runs-on: [self-hosted, linux, x64, gitforge-github]
runs-on: ${{ !github.event.pull_request.head.repo.fork && fromJSON('["self-hosted", "linux", "x64", "gitforge-github"]') || 'ubuntu-latest' }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Install Rust
Expand Down
10 changes: 5 additions & 5 deletions .github/workflows/security.yml
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ jobs:
# ─── Security Audit (Rust) ─────────────────────────────────────────────────
cargo-audit:
name: Cargo Audit
runs-on: [self-hosted, linux, x64, gitforge-github]
runs-on: ${{ !github.event.pull_request.head.repo.fork && fromJSON('["self-hosted", "linux", "x64", "gitforge-github"]') || 'ubuntu-latest' }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1

Expand All @@ -49,7 +49,7 @@ jobs:

# ─── Secrets scan (all repos — always runs) ─────────────────────────────────
secrets-scan:
runs-on: [self-hosted, linux, x64, gitforge-github]
runs-on: ${{ !github.event.pull_request.head.repo.fork && fromJSON('["self-hosted", "linux", "x64", "gitforge-github"]') || 'ubuntu-latest' }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
Expand All @@ -75,7 +75,7 @@ jobs:
# dependency-graph capability is enabled. Cargo Audit remains mandatory;
# opt this supplementary gate in only after GitHub reports it supported.
if: vars.DEPENDENCY_REVIEW_ENABLED == 'true'
runs-on: [self-hosted, linux, x64, gitforge-github]
runs-on: ${{ !github.event.pull_request.head.repo.fork && fromJSON('["self-hosted", "linux", "x64", "gitforge-github"]') || 'ubuntu-latest' }}
permissions:
contents: read
steps:
Expand All @@ -89,7 +89,7 @@ jobs:
# an unreviewable baseline. New high/critical findings fail the workflow.
aegis-scan:
name: Aegis Changed-Line Scan
runs-on: [self-hosted, linux, x64, gitforge-github]
runs-on: ${{ !github.event.pull_request.head.repo.fork && fromJSON('["self-hosted", "linux", "x64", "gitforge-github"]') || 'ubuntu-latest' }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
Expand Down Expand Up @@ -192,7 +192,7 @@ jobs:
# ─── Rust-specific Security Checks ─────────────────────────────────────────
rust-security:
name: Rust Security
runs-on: [self-hosted, linux, x64, gitforge-github]
runs-on: ${{ !github.event.pull_request.head.repo.fork && fromJSON('["self-hosted", "linux", "x64", "gitforge-github"]') || 'ubuntu-latest' }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1

Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ docker-compose up -d
| **User Guides** | [Deployment](docs/DEPLOYMENT.md) · [Runbook](docs/RUNBOOK.md) · [API Reference](docs/API.md) |
| **Architecture** | [Architecture Overview](docs/ARCHITECTURE.md) · [Hooks](docs/HOOKS.md) · [Testing Strategy](docs/TESTING_STRATEGY.md) |
| **Development** | [Contributing](docs/CONTRIBUTING.md) · [Branch Strategy](docs/BRANCH_STRATEGY.md) |
| **Project** | [Changelog](docs/CHANGELOG_RECENT.md) · [Security](docs/SECURITY.md) · [Release readiness](docs/RELEASE_READINESS.md) |
| **Project** | [Changelog](docs/CHANGELOG_RECENT.md) · [Security](docs/SECURITY.md) · [Release readiness](docs/RELEASE_READINESS.md) · [CI runner trust policy](docs/CI_RUNNER_TRUST_POLICY.md) |

## Project Structure

Expand Down
2 changes: 1 addition & 1 deletion crates/gitforge-api/src/routes/ci.rs
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,7 @@ fn can_manage_jobs(claims: &Claims) -> bool {
}

/// Require ownership of a repository, with admin/maintainer override.
async fn authorize_repo(
pub(crate) async fn authorize_repo(
pool: &Pool,
claims: &Claims,
repo_id: gitforge_common::RepoId,
Expand Down
117 changes: 110 additions & 7 deletions crates/gitforge-api/src/routes/runners.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
use crate::middleware::AuthenticatedUser;
use axum::{
extract::{Extension, Path},
http::StatusCode,
http::{header::AUTHORIZATION, HeaderMap, StatusCode},
response::IntoResponse,
routing::get,
Json, Router,
Expand All @@ -18,6 +18,36 @@ use serde::{Deserialize, Serialize};
use std::sync::Arc;
use uuid::Uuid;

/// Shared secret a runner must present to register or refresh its identity
/// (`Authorization: Bearer <token>`, `GITFORGE_RUNNER_REGISTRATION_TOKEN` on
/// both sides). Fail-closed: with the variable unset the endpoint rejects
/// every registration rather than degrading to the historical open write
/// surface (#357).
///
/// The value is read once at server construction and injected as an
/// `Extension`, so the handler never reads process-global state at request
/// time.
pub fn registration_token_from_env() -> Option<Arc<String>> {
std::env::var("GITFORGE_RUNNER_REGISTRATION_TOKEN")
.ok()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
.map(Arc::new)
}

/// Constant-time equality so a network adversary cannot recover the secret
/// byte-by-byte from timing.
fn token_matches(provided: &str, expected: &str) -> bool {
if provided.len() != expected.len() {
return false;
}
provided
.bytes()
.zip(expected.bytes())
.fold(0u8, |acc, (a, b)| acc | (a ^ b))
== 0
}

/// Runner response
#[derive(Debug, Serialize, Deserialize)]
pub struct RunnerResponse {
Expand Down Expand Up @@ -59,8 +89,23 @@ pub fn runner_routes<S: Clone + Send + Sync + 'static>() -> Router<S> {

/// Runner registration is a bootstrap endpoint used before a runner has a
/// user JWT. It is mounted separately from protected runner administration.
pub fn public_runner_routes<S: Clone + Send + Sync + 'static>() -> Router<S> {
Router::new().route("/runners", axum::routing::post(register_runner))
///
/// `registration` is the shared secret a runner must present
/// (`GITFORGE_RUNNER_REGISTRATION_TOKEN`). `None` means registration is
/// disabled and every call is refused (#357).
pub fn public_runner_routes<S: Clone + Send + Sync + 'static>(
registration: Option<Arc<String>>,
) -> Router<S> {
Router::new().route(
"/runners",
axum::routing::post(
move |Extension(pool): Extension<Arc<Pool>>,
headers: HeaderMap,
Json(payload): Json<serde_json::Value>| {
register_runner(pool, registration.clone(), headers, payload)
},
),
)
}

/// List runners
Expand Down Expand Up @@ -88,11 +133,47 @@ async fn list_runners(
}
}

/// Register a runner (also used by runner agent - allows unauthenticated)
/// Register a runner. Bootstrap surface: the runner has no user JWT yet, so
/// it proves itself with the shared registration token instead (#357).
/// Adoption of an existing identity by name rides on the same proof — the
/// token is what separates the operator's restarted runner from an attacker
/// overwriting a live runner's row.
async fn register_runner(
Extension(pool): Extension<Arc<Pool>>,
Json(payload): Json<serde_json::Value>,
) -> impl IntoResponse {
pool: Arc<Pool>,
registration: Option<Arc<String>>,
headers: HeaderMap,
payload: serde_json::Value,
) -> axum::response::Response {
let Some(expected) = registration else {
tracing::error!(
"runner registration refused: GITFORGE_RUNNER_REGISTRATION_TOKEN is not set"
);
return (
StatusCode::SERVICE_UNAVAILABLE,
Json(serde_json::json!({
"error": "registration_disabled",
"message": "runner registration token is not configured"
})),
)
.into_response();
};
let provided = headers
.get(AUTHORIZATION)
.and_then(|value| value.to_str().ok())
.and_then(|value| value.strip_prefix("Bearer "));
let authorized = provided.is_some_and(|token| token_matches(token, &expected));
if !authorized {
tracing::warn!("runner registration refused: missing or invalid bearer token");
return (
StatusCode::UNAUTHORIZED,
Json(serde_json::json!({
"error": "invalid_token",
"message": "a valid runner registration token is required"
})),
)
.into_response();
}

tracing::debug!("register runner: {:?}", payload);

let name = payload["name"].as_str().unwrap_or("runner").to_string();
Expand Down Expand Up @@ -247,6 +328,28 @@ async fn retire_runner(
mod tests {
use super::*;

/// Registration token comparison is constant-time and length-safe.
#[test]
fn token_matches_contract() {
assert!(token_matches("abc123", "abc123"));
assert!(!token_matches("abc123", "abc124"));
assert!(!token_matches("abc123", "abc1234"));
assert!(!token_matches("", "x"));
assert!(token_matches("", ""));
}

/// Fail-closed contract: with no token configured, the injected
/// registration value is `None` and the handler must refuse rather than
/// fall back to open registration. The environment is read once at
/// server construction, so the decision here is on injected data.
#[test]
fn registration_token_absent_means_disabled() {
assert!(
registration_token_from_env().is_none()
|| std::env::var("GITFORGE_RUNNER_REGISTRATION_TOKEN").is_ok()
);
}

/// The runner listing wire contract: exact field names (note the
/// `type` rename) and the heartbeat timestamp present only when the
/// runner has reported one.
Expand Down
Loading
Loading