Skip to content

Commit 7263452

Browse files
authored
CI - License checks (#3197)
# Description of Changes - Checks that all `LICENSE`/`LICENSE.txt` files are symlinks to something in `licenses/` - Checks that all license symlinks are valid - Adds Tyler as a codeowner for `LICENSE` # API and ABI breaking changes None. # Expected complexity level and risk 2 # Testing - [x] new CI fails on this PR (because #3193 isn't merged yet) - [x] new CI passes on a test PR with #3193 merged in (#3198) --------- Co-authored-by: Zeke Foppa <[email protected]>
1 parent e4735d7 commit 7263452

File tree

6 files changed

+174
-1
lines changed

6 files changed

+174
-1
lines changed

.github/CODEOWNERS

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
/crates/core/src/db/datastore/traits.rs @cloutiertyler
22
/rust-toolchain.toml @cloutiertyler
33
/.github/CODEOWNERS @cloutiertyler
4+
LICENSE @cloutiertyler
45
LICENSE.txt @cloutiertyler
6+
/licenses/ @cloutiertyler
57
/crates/client-api-messages/src/websocket.rs @centril @gefjon
68

79
/crates/cli/src/ @bfops @cloutiertyler @jdetter

.github/workflows/ci.yml

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -274,3 +274,30 @@ jobs:
274274
echo "It looks like the CLI docs have changed:"
275275
exit 1
276276
fi
277+
278+
license_check:
279+
name: Check licenses
280+
permissions: read-all
281+
runs-on: ubuntu-latest
282+
steps:
283+
- name: Find Git ref
284+
env:
285+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
286+
shell: bash
287+
run: |
288+
PR_NUMBER="${{ github.event.inputs.pr_number || null }}"
289+
if test -n "${PR_NUMBER}"; then
290+
GIT_REF="$( gh pr view --repo clockworklabs/SpacetimeDB $PR_NUMBER --json headRefName --jq .headRefName )"
291+
else
292+
GIT_REF="${{ github.ref }}"
293+
fi
294+
echo "GIT_REF=${GIT_REF}" >>"$GITHUB_ENV"
295+
- name: Checkout sources
296+
uses: actions/checkout@v4
297+
with:
298+
ref: ${{ env.GIT_REF }}
299+
- uses: dsherret/rust-toolchain-file@v1
300+
- name: Check for invalid licenses
301+
run: |
302+
cd tools/license-check
303+
cargo run

Cargo.lock

Lines changed: 9 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,9 @@ members = [
4444
"sdks/rust/tests/test-client",
4545
"sdks/rust/tests/test-counter",
4646
"sdks/rust/tests/connect_disconnect_client",
47-
"tools/upgrade-version", "crates/codegen",
47+
"tools/upgrade-version",
48+
"tools/license-check",
49+
"crates/codegen",
4850
]
4951
default-members = ["crates/cli", "crates/standalone", "crates/update"]
5052
# cargo feature graph resolver. v3 is default in edition2024 but workspace

tools/license-check/Cargo.toml

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
[package]
2+
name = "check-license-symlinks"
3+
version = "0.1.0"
4+
edition = "2021"
5+
6+
[[bin]]
7+
name = "license-check"
8+
path = "main.rs"
9+
10+
[dependencies]
11+
anyhow = "1.0"
12+
log.workspace = true
13+
walkdir = "2.5"

tools/license-check/main.rs

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
use anyhow::{bail, Context, Result};
2+
use std::env;
3+
use std::fs;
4+
use std::path::{Path, PathBuf};
5+
use walkdir::WalkDir;
6+
7+
fn main() -> Result<()> {
8+
let repo_root = find_repo_root().context("Could not locate repo root (looked for `.git/` or `licenses/`)")?;
9+
10+
check_license_symlinks(&repo_root)?;
11+
log::info!("All LICENSE files are valid symlinks into `licenses/`.");
12+
Ok(())
13+
}
14+
15+
fn find_repo_root() -> Option<PathBuf> {
16+
let mut dir = env::current_dir().ok()?.to_path_buf();
17+
loop {
18+
if dir.join(".git").is_dir() || dir.join("licenses").is_dir() {
19+
return Some(dir);
20+
}
21+
if !dir.pop() {
22+
return None;
23+
}
24+
}
25+
}
26+
27+
fn relative_to(path: &Path, root: &Path) -> String {
28+
match path.strip_prefix(root) {
29+
Ok(rel) => rel.display().to_string(),
30+
Err(_) => path.display().to_string(), // fallback if not under repo_root
31+
}
32+
}
33+
34+
fn check_license_symlinks(repo_root: &Path) -> Result<()> {
35+
let licenses_dir = repo_root.join("licenses");
36+
if !licenses_dir.is_dir() {
37+
bail!(
38+
"Required directory 'licenses/' not found at the repo root: {}",
39+
licenses_dir.display()
40+
);
41+
}
42+
43+
let licenses_dir_canon = fs::canonicalize(&licenses_dir)
44+
.with_context(|| format!("Could not canonicalize licenses dir: {}", licenses_dir.display()))?;
45+
46+
let ignore_list = ["LICENSE.txt", "crates/sqltest/standards/LICENSE"];
47+
let mut errors: Vec<String> = Vec::new();
48+
49+
for entry in WalkDir::new(repo_root).into_iter().filter_map(Result::ok) {
50+
let name = entry.file_name().to_string_lossy();
51+
if name != "LICENSE" && name != "LICENSE.txt" {
52+
continue;
53+
}
54+
55+
let path = entry.into_path();
56+
let rel_str = relative_to(&path, repo_root);
57+
if ignore_list.contains(&rel_str.as_str()) {
58+
continue;
59+
}
60+
61+
if let Err(e) = validate_one_license(path, repo_root, &licenses_dir_canon) {
62+
// include the relative path to make the report easy to scan
63+
errors.push(e.to_string());
64+
}
65+
}
66+
67+
if !errors.is_empty() {
68+
bail!("Found invalid LICENSE symlinks:\n{}", errors.join("\n"));
69+
}
70+
71+
Ok(())
72+
}
73+
74+
fn validate_one_license(path: PathBuf, repo_root: &Path, licenses_dir_canon: &Path) -> Result<()> {
75+
let meta = fs::symlink_metadata(&path)
76+
.with_context(|| format!("Could not stat file {}", relative_to(&path, repo_root)))?;
77+
78+
if !meta.file_type().is_symlink() {
79+
bail!(
80+
"{}: Must be a symlink pointing into 'licenses/'.",
81+
relative_to(&path, repo_root)
82+
);
83+
}
84+
85+
let raw_target = fs::read_link(&path)
86+
.with_context(|| format!("Could not read symlink target {}", relative_to(&path, repo_root)))?;
87+
88+
let resolved =
89+
fs::canonicalize(resolve_relative_target(&raw_target, path.parent().unwrap())).with_context(|| {
90+
format!(
91+
"{}: Broken symlink (target {}).",
92+
relative_to(&path, repo_root),
93+
raw_target.display()
94+
)
95+
})?;
96+
97+
if !is_within(&resolved, licenses_dir_canon) {
98+
bail!(
99+
"{}: Symlink target must be inside 'licenses/' (got {}).",
100+
relative_to(&path, repo_root),
101+
relative_to(&resolved, repo_root)
102+
);
103+
}
104+
105+
Ok(())
106+
}
107+
108+
/// Is `child` inside `base`?
109+
fn is_within(child: &Path, base: &Path) -> bool {
110+
child.ancestors().any(|a| a == base)
111+
}
112+
113+
/// Resolve a relative symlink target against its parent directory.
114+
fn resolve_relative_target(target: &Path, link_parent: &Path) -> PathBuf {
115+
if target.is_absolute() {
116+
target.to_path_buf()
117+
} else {
118+
link_parent.join(target)
119+
}
120+
}

0 commit comments

Comments
 (0)