Tahinli/forge
public0 watching
★ 0
M6 CI (contract between workers)
Decided with the user (don't re-open):
- Workflow file
.forge/ci.toml. No YAML anywhere. - Jobs run in rootless podman.
- The runner is a separate
forge-runnerbinary that pulls jobs over HTTPS. The forge never connects to a runner. - v1 has secrets, artifacts and live logs. No cache.
Global rules:
- No JS and no inline styles.
- No unwrap in non-test code.
- Tests use
tempfile::TempDir. - Never put cargo target dirs or big files in /tmp.
- New crates go in at their latest version with
cargo add, recorded in the root[workspace.dependencies]. - User values come after
--end-of-optionswhen passed to git.
0. Crates
- New
crates/forge-ci(library; deps: serde, toml, serde_json, globset, thiserror). It is shared by forge-web and forge-runner, and has no turso, topcoat or tokio dependency. - New
crates/forge-runner(binary). Depends on forge-ci, reqwest (rustls, stream), tokio, clap, serde, toml, tempfile, sha2 and tracing. Not forge-core or forge-web. - Add both to the root
members.
1. forge-ci (worker W1)
// config.rs : parse .forge/ci.toml
pub struct CiConfig { pub on: Triggers, pub jobs: BTreeMap<String, JobSpec> } // job names: ^[a-z0-9][a-z0-9_-]{0,39}$, max 20 jobs
pub struct Triggers { pub push: Vec<String> /* branch globs, e.g. "main", "release/*" */, pub tags: Vec<String> /* tag globs */,
pub pull_request: bool, pub manual: bool /* default true */ }
pub struct JobSpec { pub image: String, pub run: String, pub needs: Vec<String>, pub secrets: Vec<String> /* ^[A-Z_][A-Z0-9_]{0,63}$ */,
pub env: BTreeMap<String, String>, pub artifacts: Vec<String> /* relative globs, no "..", no leading "/" */,
pub runs_on: Vec<String> /* runner labels, all must match; default [] */, pub timeout_minutes: u32 /* default 60, 1..=360 */,
pub network: bool /* default true */ }
pub fn parse_config(text: &str) -> Result<CiConfig, ConfigError> // deny_unknown_fields; validates names, needs refer to existing jobs, no cycles, image non-empty without whitespace, run non-empty, ≤ 64 KiB file
pub enum Event<'a> { Push { branch: &'a str }, Tag { tag: &'a str }, PullRequest, Manual }
impl CiConfig { pub fn triggered_by(&self, ev: &Event) -> bool; pub fn job_order(&self) -> Vec<&str> /* topological, stable by name */ }
// proto.rs : runner <-> forge JSON (serde, snake_case)
pub struct RegisterRequest { pub token: String, pub name: String, pub labels: Vec<String> }
pub struct RegisterResponse { pub runner_id: String, pub runner_secret: String }
pub struct ClaimRequest { pub labels: Vec<String> } // POST, long-poll ≤ 30s; 204 = nothing
pub struct JobAssignment { pub job_id: String, pub job_token: String, pub repo: String /* owner/name */, pub clone_url: String,
pub commit: String, pub git_ref: String, pub job_name: String, pub image: String, pub run: String,
pub env: BTreeMap<String,String>, pub secrets: BTreeMap<String,String>, pub artifacts: Vec<String>,
pub timeout_minutes: u32, pub network: bool }
pub struct HeartbeatResponse { pub cancel: bool }
pub struct FinishRequest { pub outcome: Outcome, pub exit_code: Option<i32>, pub message: Option<String> }
pub enum Outcome { Success, Failure, Cancelled, Error }
// mask.rs
pub struct Masker // new(secrets: impl IntoIterator<Item=String>); ignores values shorter than 4 chars; mask(&str) -> String replaces every
// occurrence (and the base64 standard encoding of each value) with "***"; must handle a value split across two
// chunks: `MaskStream` with push(&[u8]) -> Vec<u8> that holds back max_len-1 bytes, and finish() -> Vec<u8>
Unit tests cover every validation error, glob triggers, topological order, cycle detection, and masking across chunk boundaries.
2. Schema and store (migration 0024_ci, worker W2)
CREATE TABLE ci_runners (id TEXT PRIMARY KEY, name TEXT NOT NULL, labels TEXT NOT NULL /* json */, secret_hash TEXT NOT NULL UNIQUE,
created_at TEXT NOT NULL, last_seen_at TEXT, disabled INTEGER NOT NULL DEFAULT 0);
CREATE TABLE ci_registration_tokens (token_hash TEXT PRIMARY KEY, created_by TEXT NOT NULL REFERENCES users(id),
created_at TEXT NOT NULL, expires_at TEXT NOT NULL, used_at TEXT);
CREATE TABLE ci_runs (id TEXT PRIMARY KEY, repo_id TEXT NOT NULL REFERENCES repos(id), number INTEGER NOT NULL,
trigger TEXT NOT NULL /* push|tag|pull_request|manual */, git_ref TEXT NOT NULL, commit_oid TEXT NOT NULL, pull_number INTEGER,
actor_id TEXT NOT NULL, state TEXT NOT NULL /* waiting_approval|queued|running|success|failure|cancelled|error */,
config_error TEXT, created_at TEXT NOT NULL, finished_at TEXT, UNIQUE (repo_id, number));
CREATE TABLE ci_jobs (id TEXT PRIMARY KEY, run_id TEXT NOT NULL REFERENCES ci_runs(id) ON DELETE CASCADE, name TEXT NOT NULL,
spec TEXT NOT NULL /* JobSpec json */, state TEXT NOT NULL /* blocked|queued|running|success|failure|cancelled|error|skipped */,
runner_id TEXT, job_token_hash TEXT, exit_code INTEGER, message TEXT, queued_at TEXT, started_at TEXT, finished_at TEXT,
heartbeat_at TEXT, cancel_requested INTEGER NOT NULL DEFAULT 0, log_bytes INTEGER NOT NULL DEFAULT 0, UNIQUE (run_id, name));
CREATE INDEX ci_jobs_queue ON ci_jobs(state, queued_at);
CREATE TABLE ci_artifacts (id TEXT PRIMARY KEY, job_id TEXT NOT NULL REFERENCES ci_jobs(id) ON DELETE CASCADE, name TEXT NOT NULL,
size INTEGER NOT NULL, sha256 TEXT NOT NULL, created_at TEXT NOT NULL, UNIQUE (job_id, name));
CREATE TABLE ci_secrets (repo_id TEXT NOT NULL REFERENCES repos(id), name TEXT NOT NULL, sealed BLOB NOT NULL,
created_at TEXT NOT NULL, updated_at TEXT NOT NULL, PRIMARY KEY (repo_id, name));
crates/forge-core/src/ci.rs holds the models. crates/forge-core/src/store/ci.rs holds the functions below; they follow the read-pool rules in ~/.cache/forge-orch/read-pool-rules.md, with pure SELECTs on read_conn().
pub enum RunState { WaitingApproval, Queued, Running, Success, Failure, Cancelled, Error } // as_str/FromStr
pub enum JobState { Blocked, Queued, Running, Success, Failure, Cancelled, Error, Skipped } // as_str/FromStr, is_terminal()
pub struct CiRunner { id, name, labels: Vec<String>, created_at, last_seen_at: Option<String>, disabled: bool }
pub struct CiRun { id, repo_id, number: i64, trigger: String, git_ref, commit_oid, pull_number: Option<i64>, actor_id, state: RunState,
config_error: Option<String>, created_at, finished_at: Option<String> }
pub struct CiJob { id, run_id, name, spec_json: String, state: JobState, runner_id: Option<String>, exit_code: Option<i64>,
message: Option<String>, queued_at, started_at, finished_at, heartbeat_at: Option<String>, cancel_requested: bool, log_bytes: i64 }
pub struct CiArtifact { id, job_id, name, size: i64, sha256, created_at }
pub struct NewJob<'a> { pub name: &'a str, pub spec_json: &'a str, pub needs: &'a [String] }
// runners
create_registration_token(created_by, ttl_hours: u32) -> Result<String /*plaintext, shown once*/> // stores sha256
register_runner(token_plain, name, labels: &[String]) -> Result<(CiRunner, String /*runner secret plaintext*/)> // token single-use, not expired; Invalid otherwise
runner_by_secret(secret_plain) -> Result<Option<CiRunner>> // constant-time compare via hash lookup; also updates last_seen_at
list_runners() / set_runner_disabled(id, bool) / delete_runner(id)
// runs and jobs
create_run(repo_id, trigger, git_ref, commit_oid, pull_number: Option<i64>, actor_id, needs_approval: bool, jobs: &[NewJob], config_error: Option<&str>) -> Result<CiRun>
// number = max+1 per repo, in one transaction. Jobs with no needs start Queued (or Blocked when needs_approval), others Blocked.
// With config_error: no jobs, state Error.
approve_run(run_id) -> Result<()> // WaitingApproval -> Queued; needless jobs -> Queued
claim_job(runner: &CiRunner) -> Result<Option<(CiJob, CiRun, String /*job token plaintext*/)>>
// oldest Queued job whose spec.runs_on ⊆ runner.labels, in a transaction; sets Running, runner_id, started_at, heartbeat_at, token hash; run -> Running
job_by_token(job_id, token_plain) -> Result<Option<CiJob>> // only while Running
heartbeat(job_id) -> Result<bool /*cancel_requested*/>
add_log_bytes(job_id, n: i64) -> Result<()>
finish_job(job_id, state: JobState, exit_code: Option<i64>, message: Option<&str>) -> Result<CiRun>
// terminal; then unblock dependants (all needs Success -> Queued; any need not Success -> Skipped, cascading);
// run state recomputed: any running/queued/blocked -> Running; else Failure if any failure/error, Cancelled if any cancelled, else Success
request_cancel(run_id) -> Result<()> // Queued/Blocked jobs -> Cancelled at once; Running jobs get cancel_requested = 1
rerun(run_id, actor_id) -> Result<CiRun> // new run with the same commit, ref, trigger and job specs
reap_lost_jobs(older_than: &str /*stamp*/) -> Result<Vec<CiJob>> // Running jobs with heartbeat_at < older_than -> Error "runner lost"
run_by_number(repo_id, n) / run_by_id(id) / list_runs(repo_id, page, per_page) -> (Vec<CiRun>, total) / jobs_for_run(run_id) / job_by_id(id)
add_artifact(job_id, name, size, sha256) / list_artifacts(job_id) / artifact(job_id, name)
purge_old(logs_before: &str, artifacts_before: &str) -> Result<(Vec<String> /*job ids whose logs to delete*/, Vec<String> /*artifact ids*/)>
// secrets (sealed with the instance key like mirror secrets)
set_ci_secret(repo_id, name, value: &[u8]) / delete_ci_secret(repo_id, name) -> Result<bool> / list_ci_secret_names(repo_id) -> Result<Vec<(String, String /*updated_at*/)>>
ci_secret_values(repo_id, names: &[String]) -> Result<BTreeMap<String, String>> // only for claim; missing names omitted
delete_repodeletes the ci_runs (cascade) and ci_secrets rows.- User deletion reassigns
actor_idandcreated_byto the ghost user.
3. forge-web: scheduling (worker W3), crates/forge-web/src/ci/{mod.rs, schedule.rs, reaper.rs}
pub async fn on_ref_update(state, repo, actor_id, refname, new_oid)is called frompost_update::after_ref_updatefor each updatedrefs/heads/*orrefs/tags/*(not deletions; not pull mirrors pulling? yes, pull mirrors also trigger, with the system actor).- It reads
.forge/ci.tomlatnew_oid(forge-git blob read, capped at 64 KiB), parses it, and checkstriggered_by. - It creates the run with jobs in
job_order, then sets a commit statusci/<job>= pending for each job, withtarget_url= the job page. - A parse error creates a run in state Error with
config_errorand a statusci/config= error. - No file means nothing happens.
- It reads
pub async fn on_pull_event(state, repo, pull, actor_id)runs on PR open, synchronize and reopen (hook into the same places that emit thepull_requestwebhook).- It reads the config at the PR head commit, with event
PullRequest. - If the head repo is a fork, or the author doesn't have Write on the base repo:
needs_approval = true, the state is WaitingApproval, and the statuses are pending with the description "Waiting for approval".
- It reads the config at the PR head commit, with event
pub async fn manual_run(state, repo, branch, actor_id) -> Result<CiRun>runs only whenmanualis true.- Statuses: whenever a job changes state,
crate::ci::sync_status(state, run, job)setsci/<job>:- queued/blocked/running → pending;
- success → success;
- failure → failure;
- error/cancelled → error;
- skipped → error, "skipped".
- Reaper task, started at boot:
- every 30s it runs
reap_lost_jobs(now - 120s)and syncs the statuses; - daily it runs
purge_oldwith[ci] log_retention_days(default 90) andartifact_retention_days(default 30), and deletes the files.
- every 30s it runs
AppState.ci_waker: Arc<Notify>is notified whenever a job becomes Queued, so long-polling claims wake.- Config
[ci]:enabled = truemax_log_mb = 16max_artifact_mb = 512max_artifacts_per_job = 50log_retention_days = 90artifact_retention_days = 30registration_token_hours = 24
4. forge-web: runner API (worker W4), crates/forge-web/src/ci/api.rs
All responses are JSON (forge_ci::proto). Runner auth is Authorization: Bearer <runner_secret>. Job auth is Authorization: Bearer <job_token> on /jobs/{id}/…, checked with job_by_token.
POST /api/v1/ci/runners/registertakes RegisterRequest and returns RegisterResponse.POST /api/v1/ci/runners/claimtakes ClaimRequest. It loopsclaim_job; if nothing is found, it awaitsci_wakeror 25s, then tries once more. It returns 200 with a JobAssignment, or 204.- The labels in the request are ignored; the stored labels are used.
- Disabled runners get 403.
- Secrets come from
ci_secret_values(repo, spec.secrets), except for runs whose trigger is pull_request from a fork (always empty there). clone_urlis the repo's HTTPS clone URL.
POST /api/v1/ci/jobs/{id}/logs: the body is a raw text chunk (≤ 1 MiB), with headerX-Forge-Log-Offset: <byte offset>.- It appends to
<data_dir>/ci/logs/<job id>.logonly if the offset equals the current size (idempotent retry: an equal end returns 200 without writing; a mismatch returns 409 withX-Forge-Log-Size). - The server masks again with
Maskerbuilt from the job's secret values before writing. - The cap is
max_log_mb. Past it, the server writes "[log truncated]" once and drops the rest. - It calls
add_log_bytes.
- It appends to
POST /api/v1/ci/jobs/{id}/heartbeatreturns HeartbeatResponse (cancel_requested).PUT /api/v1/ci/jobs/{id}/artifacts/{name}streams the raw body to the BlobStore (crate::blobs, keyci/<artifact id>), with a sha256 and themax_artifact_mbcap.- The name must be
[A-Za-z0-9._+-]{1,128}and must not start with ".". - At most
max_artifacts_per_job.
- The name must be
POST /api/v1/ci/jobs/{id}/finishtakes FinishRequest and callsfinish_job. It syncs statuses for this job and for every job that became skipped or queued, and notifiesci_waker.- Git read with a job token: smart-HTTP
authenticateingit_http.rsalso accepts Basic auth with usernameci-joband password<job_id>:<job_token>. That gives read-only access to exactly that job's repo while the job is Running. Every write is refused. - Compression is excluded for
/api/v1/ci/paths (add the path tocompression.rs).
5. forge-web UI: runs (worker W5), crates/forge-web/src/pages/ci_runs.rs, ci_job.rs
- "CI" repo tab.
/{o}/{r}/cilists runs, newest first: number, state icon, trigger, ref, short commit, actor, and age.- A "Run workflow" form (Write,
manualallowed) offers a branch select.
/{o}/{r}/ci/runs/{n}shows the jobs table with state, duration and runner name, plusconfig_errorin a<pre>.- Buttons, each a POST with CSRF: Cancel (Write), Re-run (Write), and "Approve and run" (Write, only in WaitingApproval).
/{o}/{r}/ci/jobs/{job_id}shows the job log: the last 512 KiB of the log file in<pre>, with a link to the full log.- No JS: while the job is not terminal, the page has
<meta http-equiv="refresh" content="3">. - Artifacts are listed with download links.
- No JS: while the job is not terminal, the page has
/{o}/{r}/ci/jobs/{job_id}/log.txtservestext/plain; charset=utf-8,X-Content-Type-Options: nosniff, and streams live. It sends the current file, then keeps following it (poll every 1s) until the job is terminal and the file is fully sent. This is the live log; a browser shows it progressively./{o}/{r}/ci/jobs/{job_id}/artifacts/{name}downloads as an attachment, with nosniff.- Read access follows repo Read. Logs of private repos are never on the user-content domain.
- The commit list and PR page already show statuses through the status API, so check that the
ci/*contexts render there.
6. forge-web UI: secrets and runners (worker W6)
/{o}/{r}/settings/secrets(Admin):- lists names and when each was updated, never the values;
- has an add/update form (name plus value textarea; the value is write-only);
- deletes with CSRF;
- records audit events
ci_secret_setandci_secret_deleted(name only).
/admin/runners(instance admin):- lists runners with labels, last seen and disabled state;
- has disable/enable and delete buttons;
- has "Create registration token", which shows the token once along with the exact command
forge-runner register --url <base_url> --token <token> --name <name> --labels linux; - records audit events
ci_runner_registered,ci_runner_deletedandci_registration_token_created.
7. forge-runner (worker W7)
- CLI:
forge-runner register --url URL --token T --name N [--labels a,b] [--config PATH]writes the config (TOML, 0600, default~/.config/forge-runner/runner.toml): url, runner_id, runner_secret, labels,work_dir,concurrency = 1, and the[limits] memory = "4g",cpus = "2"andpids = 4096defaults.forge-runner run [--config PATH]loops claim → execute → finish, withconcurrencyjobs in parallel.
- Executor trait:
#[async_trait-free: use Pin<Box<dyn Future>> or an enum]
trait Executor { async fn run(&self, job: &JobAssignment, workspace: &Path, log: &mut LogSink, cancel: CancellationToken) -> Outcome+exit code }
PodmanExecutorrunspodman run --rm --name forge-job-<job_id> --pull=newer --network=<slirp4netns|none> --memory M --cpus C --pids-limit P --security-opt no-new-privileges --cap-drop ALL -v <ws>:/workspace:Z -w /workspace -e NAME... <image> sh -eu -c <run>.- Env and secret values are passed through the child's environment with
-e NAME(name only), never in argv. - On cancel or timeout it runs
podman kill forge-job-<id>, thenpodman rm -f. - The podman argv construction is unit-tested.
- Env and secret values are passed through the child's environment with
HostExecutorexists only for tests, behind#[cfg(any(test, feature = "test-host-executor"))]. It runssh -eu -cin the workspace with the env, and has no isolation.- Workspace: a fresh
work_dir/<job_id>. Rungit init, thengit fetch --depth=1 <clone_url> <commit>withGIT_CONFIG_*env carryinghttp.extraHeader: Authorization: Basic base64(ci-job:<job_id>:<token>), never in argv, withGIT_TERMINAL_PROMPT=0. Thengit checkout --detach <commit>. The workspace is deleted afterwards. - Logs:
- stdout and stderr are merged line-wise and prefixed with nothing.
- They pass through
MaskStreamand are batched (≤ 64 KiB or 1s), then POSTed with the offset. On 409 the runner resyncs toX-Forge-Log-Size. - The first lines are runner info: runner name, image, and commit.
- Heartbeat every 10s;
cancel: truetriggers the cancellation token. The timeout istimeout_minutes. - Artifacts: after a successful run, expand the globs inside the workspace (the canonicalized path must stay inside it, symlinks must not escape, and only regular files count). Upload each file under its relative path flattened with
/→_, respecting the limits. - Finish: report
Successon exit 0,Failureon a non-zero exit,Cancelled, andErrorfor runner or podman failures, with a message. - Tests: a mock forge (a small hyper or tokio TCP server in tests) plus
HostExecutorcover:- claim → logs → artifacts → finish;
- masking;
- cancel;
- timeout;
- 409 resync.
- Real podman only in an
#[ignore]test.
8. End to end and docs (worker W8, after W1–W7)
crates/forge-web/tests/ci_e2e.rs:- Boot the forge and register a runner through the API.
- Push a repo with
.forge/ci.toml(two jobs, with needs, a secret and an artifact). - Drive the job with forge-runner's library API and
HostExecutor(forge-runner exposeslib.rs). - Assert the commit statuses, the masked secret in the log, the artifact download, the skip on failure, and that a fork PR waits for approval.
docs/ci.md:- the format reference with examples;
- runner install (rootless podman setup: subuid/subgid,
loginctl enable-linger, a systemd user unit); - the security model (fork PRs, secrets, masking limits);
- the runner protocol.