Skip to content
Closed
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
6 changes: 6 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -153,3 +153,9 @@ jobs:

- name: run WP5 secret scanner smoke
run: CARGO_BUILD=0 bash tests/e2e/wp5_secret_scan.sh

- name: Sprint 2 MCP surface
run: CARGO_BUILD=0 bash tests/e2e/sprint_2_mcp_surface.sh

- name: Phase 3 subsystems
run: CARGO_BUILD=0 bash tests/e2e/phase3_subsystems.sh
82 changes: 81 additions & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,14 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
with:
fetch-depth: 0

- name: Assert tagged commit is on main
run: |
git fetch origin main
git merge-base --is-ancestor "$GITHUB_SHA" origin/main || \
{ echo "::error::tag does not point to a commit on main"; exit 1; }

- uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8
with:
Expand Down Expand Up @@ -117,6 +125,12 @@ jobs:
- name: WP5 secret scanner smoke
run: CARGO_BUILD=0 bash tests/e2e/wp5_secret_scan.sh

- name: Sprint 2 MCP surface
run: CARGO_BUILD=0 bash tests/e2e/sprint_2_mcp_surface.sh

- name: Phase 3 subsystems
run: CARGO_BUILD=0 bash tests/e2e/phase3_subsystems.sh

release-governance:
name: GitHub release governance
runs-on: ubuntu-latest
Expand Down Expand Up @@ -220,7 +234,7 @@ jobs:
while IFS= read -r -d '' asset; do
digest=$(sha256sum "$asset" | cut -d' ' -f1)
printf '%s %s\n' "$digest" "$(basename "$asset")"
done < <(find artifacts -name 'clarion-*.tar.gz' -print0 | sort -z) > rust-subjects.txt
done < <(find artifacts \( -name 'clarion-*.tar.gz' -o -name 'clarion_plugin_python*.tar.gz' \) -print0 | sort -z) > rust-subjects.txt
test -s rust-subjects.txt
echo "rust-subjects=$(base64 -w0 rust-subjects.txt)" >> "$GITHUB_OUTPUT"

Expand Down Expand Up @@ -407,3 +421,69 @@ jobs:
upload-assets: true
upload-tag-name: ${{ github.ref_name }}
provenance-name: clarion-rust-binaries.intoto.jsonl

verify-published-release:
name: Verify published release assets
needs: [release]
runs-on: ubuntu-latest
# Post-publish gate: prove the assets actually served from the public
# GitHub Release match the signed local copies. The in-`release`-job
# `cosign verify-blob` only proves signing worked on the build runner; it
# does not prove the published download matches. This job downloads the
# public assets, recomputes SHA256 against the published `.sha256`
# sidecars, and re-verifies the cosign signatures against the public Rekor
# entries.
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v')
permissions:
contents: read
steps:
- name: install cosign
uses: sigstore/cosign-installer@398d4b0eeef1380460a10c8013a76f728fb906ac

- name: download published assets
env:
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
mkdir -p published
cd published
gh release download "${GITHUB_REF_NAME}" \
--repo "${GITHUB_REPOSITORY}" \
--pattern '*.tar.gz' \
--pattern '*.tar.gz.sha256' \
--pattern '*.tar.gz.sig' \
--pattern '*.tar.gz.pem'
ls -la

- name: recompute SHA256 against published checksums
run: |
set -euo pipefail
cd published
shopt -s nullglob
checksums=(*.tar.gz.sha256)
if [ ${#checksums[@]} -eq 0 ]; then
echo "::error::no .sha256 sidecars found in the published release" >&2
exit 1
fi
for sum in "${checksums[@]}"; do
sha256sum -c "$sum"
done

- name: cosign verify published archives against Rekor
run: |
set -euo pipefail
cd published
shopt -s nullglob
archives=(*.tar.gz)
if [ ${#archives[@]} -eq 0 ]; then
echo "::error::no .tar.gz archives found in the published release" >&2
exit 1
fi
for asset in "${archives[@]}"; do
cosign verify-blob \
--certificate "$asset.pem" \
--signature "$asset.sig" \
--certificate-identity-regexp "https://github.com/${GITHUB_REPOSITORY}/.github/workflows/release.yml@refs/tags/v.*" \
--certificate-oidc-issuer https://token.actions.githubusercontent.com \
"$asset"
done
5 changes: 4 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -22,4 +22,7 @@ __pycache__/
htmlcov/
.claude/

.env
.env

# Smoke-test result artifacts (per-run; archived separately at tag-cut)
tests/e2e/external-operator-smoke-results-*.md
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,13 @@ normative.
Wardline-aware plugin only; Clarion core and non-Wardline-aware plugins are
unaffected. *Retirement condition*: Wardline ships a stable runtime probe
API.
- **Wardline state-file ingest deferred to a future release (tracked under
`release:v1.1`).** Only the `wardline.core.registry.REGISTRY` version-pin
probe ships in v1.0; the state-file readers for `wardline.yaml` + overlays,
`wardline.fingerprint.json`, `wardline.exceptions.json`, the
`wardline.sarif.baseline.json` translator baseline, and the three-scheme
identity-reconciliation oracle (REQ-INTEG-WARDLINE-02 through -06) all land
with WP9-B / WP10.
- **Pre-WP5 catalogue upgrade requirement.** Briefing-blocked annotations
are stored as a JSON property on file entities at v1.0 (v1.1 promotes
the field to a typed column). A v1.0 binary opening a `.clarion/clarion.db`
Expand Down
33 changes: 33 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ blake3 = "1.8.5"
clap = { version = "4", features = ["derive"] }
deadpool-sqlite = { version = "0.8", features = ["rt_tokio_1"] }
dotenvy = "0.15"
fs2 = "0.4"
ignore = "0.4"
rusqlite = { version = "0.31", features = ["bundled"] }
reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "rustls-tls-native-roots"] }
Expand Down
1 change: 1 addition & 0 deletions crates/clarion-cli/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ clarion-mcp = { path = "../clarion-mcp", version = "1.0.0" }
clarion-scanner = { path = "../clarion-scanner", version = "1.0.0" }
clarion-storage = { path = "../clarion-storage", version = "1.0.0" }
dotenvy.workspace = true
fs2.workspace = true
ignore.workspace = true
rusqlite.workspace = true
serde.workspace = true
Expand Down
6 changes: 6 additions & 0 deletions crates/clarion-cli/src/analyze.rs
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,12 @@ pub(crate) async fn run_with_options(project_path: PathBuf, options: AnalyzeOpti
);
}
let db_path = clarion_dir.join("clarion.db");

// Cross-process advisory lock (STO-01). Must outlive the writer-actor's
// `handle.await` at the bottom of this function — see the drop-order
// note on `AnalyzeLockGuard`. Drop on function exit releases the lock.
let _analyze_lock = crate::analyze_lock::acquire_analyze_lock(&clarion_dir)?;

let analyze_config = AnalyzeConfig::load(&project_root, options.config_path.as_deref())?;
let analyze_config_json = analyze_config.to_json_string()?;

Expand Down
148 changes: 148 additions & 0 deletions crates/clarion-cli/src/analyze_lock.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
//! Cross-process advisory lock for `clarion analyze`.
//!
//! Two concurrent `clarion analyze` processes against the same project
//! corrupt the run-attribution graph: each opens its own writer-actor,
//! each calls `BeginRun` (insert a fresh `runs` row in `status='running'`),
//! and each races on entity/edge inserts under `SQLite` WAL. The in-process
//! `ActorState::current_run` guard (clarion-storage `writer.rs`) prevents
//! a single writer from issuing two `BeginRun`s; it does nothing across
//! processes.
//!
//! This module acquires an exclusive `fs2`-advisory lock on a dedicated
//! sentinel file `.clarion/clarion.lock` for the duration of the analyze
//! run. The lock file is separate from `clarion.db` so `SQLite`'s own
//! locking (per-connection, transaction-scoped) is independent. The
//! guard's `Drop` releases the OS-level lock.

use std::fs::{File, OpenOptions};
use std::path::Path;

use anyhow::{Context, Result, bail};
use fs2::FileExt;

const LOCK_FILE_NAME: &str = "clarion.lock";

/// RAII guard holding the analyze lock. Drop releases the OS lock.
///
/// **Drop order is load-bearing.** The guard must outlive the writer-actor's
/// `JoinHandle::await` in `analyze::run_with_options`; otherwise a second
/// `clarion analyze` can grab the lock while writer-actor 1's final
/// transaction is still landing through WAL. `fs2`'s `File` impl unlocks
/// on file close, so dropping the `File` releases the OS lock; we rely on
/// Drop rather than an explicit unlock so panic and happy paths behave
/// identically.
#[must_use = "Drop releases the analyze lock — bind to a named variable"]
#[derive(Debug)]
pub(crate) struct AnalyzeLockGuard {
_file: File,
}

/// Acquire an exclusive cross-process lock on `<clarion_dir>/clarion.lock`.
///
/// `clarion_dir` is the `.clarion/` directory inside the project root. The
/// lock file is created on first use (0-byte sentinel) and kept across
/// runs. The returned guard holds the lock for its lifetime.
///
/// # Errors
///
/// - The lock file cannot be opened (missing `.clarion/` directory,
/// permission denied, filesystem read-only).
/// - Another `clarion analyze` process already holds the lock. Returns
/// an error containing the lock-file path so the operator can identify
/// the conflict.
pub(crate) fn acquire_analyze_lock(clarion_dir: &Path) -> Result<AnalyzeLockGuard> {
let lock_path = clarion_dir.join(LOCK_FILE_NAME);
let file = OpenOptions::new()
.read(true)
.write(true)
.create(true)
.truncate(false)
.open(&lock_path)
.with_context(|| format!("open analyze lock file {}", lock_path.display()))?;

match file.try_lock_exclusive() {
Ok(()) => Ok(AnalyzeLockGuard { _file: file }),
Err(err) => {
// fs2 returns ErrorKind::WouldBlock when another process holds
// the lock; anything else is a real IO failure (e.g. NFS
// without lockd). Surface both with the path so operators can
// identify the conflict.
let kind = err.kind();
if kind == std::io::ErrorKind::WouldBlock {
bail!(
"another `clarion analyze` is already in progress against this project \
(lock held on {}). Wait for it to finish, or remove the lock file if \
no other process is running.",
lock_path.display()
);
}
Err(err).with_context(|| {
format!(
"acquire exclusive lock on {} (filesystem may not support advisory locks)",
lock_path.display()
)
})
}
}
}

#[cfg(test)]
mod tests {
use super::*;

/// Two concurrent `acquire_analyze_lock` calls on the same `.clarion/`
/// directory must fail the second call. This is the core STO-01
/// invariant: a second analyze cannot start while the first holds
/// the writer.
#[test]
fn second_acquire_fails_while_first_held() {
let tmp = tempfile::tempdir().unwrap();
let clarion_dir = tmp.path();

let first = acquire_analyze_lock(clarion_dir).expect("first acquire");
assert!(
clarion_dir.join(LOCK_FILE_NAME).exists(),
"lock file created on first acquire"
);

let err = acquire_analyze_lock(clarion_dir)
.expect_err("second acquire must fail while first guard is held");
let msg = format!("{err}");
assert!(
msg.contains("another `clarion analyze`"),
"error must name the conflict explicitly: {msg}"
);
drop(first);
}

/// Releasing the first lock (dropping the guard) must let the second
/// acquire succeed. Guards the "we forgot to unlock on Drop" bug.
#[test]
fn second_acquire_succeeds_after_first_drops() {
let tmp = tempfile::tempdir().unwrap();
let clarion_dir = tmp.path();

{
let first = acquire_analyze_lock(clarion_dir).expect("first acquire");
drop(first);
} // lock released on drop

let second = acquire_analyze_lock(clarion_dir)
.expect("second acquire must succeed after first drops");
drop(second);
}

/// Missing `.clarion/` directory must surface as an IO error, not a
/// `WouldBlock` masquerade. (Operator may have skipped `clarion install`.)
#[test]
fn missing_clarion_dir_errors() {
let tmp = tempfile::tempdir().unwrap();
let nonexistent = tmp.path().join("missing-clarion-dir");
let err = acquire_analyze_lock(&nonexistent).expect_err("missing dir must error");
let msg = format!("{err:#}");
assert!(
msg.contains("open analyze lock file"),
"error must mention lock file open path: {msg}"
);
}
}
Loading
Loading