Compare commits

...
Author SHA1 Message Date
Dylan M. Taylor 1c00ae3492 security: write the release from the release job only (SonarCloud S8233)
The workflow-level contents: write granted every job the right to push
tags and releases; the reusable release workflow is the only job that
needs it. Workflow level drops to read.

🤖 Generated with Codebuff
Co-Authored-By: Codebuff <noreply@codebuff.com>
2026-10-23 17:16:13 -04:00
Dylan M. Taylor c1dd60ffd6 Merge pull request #2018 from dylanmtaylor/ci/sonar-main-not-cancelled
ci(sonar): stop cancelling main-branch runs so coverage is published
2026-10-23 16:58:56 -04:00
Dylan M. Taylor bec28d6b87 ci(sonar): don't cancel superseded runs on main, so coverage is published 2026-10-23 16:50:42 -04:00
aiongg d3e6111cf1 Xrefs and data links: resolve stored paths with craft-docpath's resolve_absolute (#1750)
* Xrefs and data links: use resolve_absolute for stored paths

* CI: check that git dependencies come from storytold

* CI: release only with git dependencies from storytold

* Parse Cargo.lock as TOML when checking git sources

* Use the shared git-sources check in CI

* Parse cargo config, legacy .cargo/config included, in the git-sources check
2026-10-23 16:42:01 -04:00
aiongg dd1f0531ac i18n: missing translations never fail; cargo xtask i18n reports them (#2013) 2026-10-23 15:40:12 -04:00
10 changed files with 314 additions and 966 deletions
+38
View File
@@ -21,3 +21,41 @@ jobs:
nix:
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
uses: storytold/craft-common-actions/.github/workflows/nix.yml@main
git-sources:
name: Git dependencies come from storytold
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Git dependencies come from storytold
run: |
set -euo pipefail
test -f Cargo.lock || { echo "Cargo.lock is missing"; exit 1; }
# Source replacement, patches and path overrides in cargo config never show up
# in Cargo.lock. Parsed rather than grepped: TOML has many spellings of one key.
# Cargo still reads the legacy .cargo/config.
python3 - <<'PY'
import os, sys, tomllib
for path in (".cargo/config", ".cargo/config.toml"):
if not os.path.exists(path):
continue
config = tomllib.load(open(path, "rb"))
replaced = [name for name, source in config.get("source", {}).items()
if isinstance(source, dict) and "replace-with" in source]
if replaced or "patch" in config or "paths" in config:
print(f"{path} replaces, patches or overrides a source")
sys.exit(1)
PY
# Fails when Cargo.lock is out of date with the manifests.
cargo metadata --locked --format-version 1 > /dev/null
python3 - <<'PY'
import sys, tomllib
lock = tomllib.load(open("Cargo.lock", "rb"))
bad = sorted({p["source"] for p in lock.get("package", [])
if p.get("source", "").startswith("git+")
and not p["source"].startswith("git+https://github.com/storytold/")})
if bad:
print("Git dependencies outside github.com/storytold:", *bad, sep="\n")
sys.exit(1)
PY
+44 -1
View File
@@ -11,14 +11,57 @@ on:
default: ""
permissions:
contents: write
contents: read
concurrency:
group: release-${{ github.ref }}
cancel-in-progress: false
jobs:
git-sources:
name: Git dependencies come from storytold
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Git dependencies come from storytold
run: |
set -euo pipefail
test -f Cargo.lock || { echo "Cargo.lock is missing"; exit 1; }
# Source replacement, patches and path overrides in cargo config never show up
# in Cargo.lock. Parsed rather than grepped: TOML has many spellings of one key.
# Cargo still reads the legacy .cargo/config.
python3 - <<'PY'
import os, sys, tomllib
for path in (".cargo/config", ".cargo/config.toml"):
if not os.path.exists(path):
continue
config = tomllib.load(open(path, "rb"))
replaced = [name for name, source in config.get("source", {}).items()
if isinstance(source, dict) and "replace-with" in source]
if replaced or "patch" in config or "paths" in config:
print(f"{path} replaces, patches or overrides a source")
sys.exit(1)
PY
# Fails when Cargo.lock is out of date with the manifests.
cargo metadata --locked --format-version 1 > /dev/null
python3 - <<'PY'
import sys, tomllib
lock = tomllib.load(open("Cargo.lock", "rb"))
bad = sorted({p["source"] for p in lock.get("package", [])
if p.get("source", "").startswith("git+")
and not p["source"].startswith("git+https://github.com/storytold/")})
if bad:
print("Git dependencies outside github.com/storytold:", *bad, sep="\n")
sys.exit(1)
PY
release:
needs: git-sources
permissions:
# The reusable release workflow tags and uploads the draft release.
contents: write
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
uses: storytold/craft-common-actions/.github/workflows/release.yml@main
with:
+2 -1
View File
@@ -11,7 +11,8 @@ permissions:
concurrency:
group: sonarcloud-${{ github.ref }}
cancel-in-progress: true
# A push to main is the run that publishes coverage; superseded runs there would never finish.
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
jobs:
sonarcloud:
+1 -2
View File
@@ -18,8 +18,7 @@ pub use craft_docpath::Refused;
/// on other platforms.
pub fn resolve(stored: &str, document: Option<&Path>) -> Result<PathBuf, Refused> {
let document = document.filter(|d| !d.as_os_str().is_empty()).map(|d| std::path::absolute(d).unwrap_or_else(|_| d.to_path_buf()));
let path = craft_docpath::resolve(stored.trim(), document.as_deref())?;
if path.is_absolute() { Ok(path) } else { Err(Refused::Invalid) }
craft_docpath::resolve_absolute(stored.trim(), document.as_deref())
}
#[cfg(test)]
File diff suppressed because it is too large Load Diff
-16
View File
@@ -391,20 +391,4 @@ mod tests {
assert_eq!(parse_hex(bad), None, "{bad}");
}
}
#[test]
fn dialog_labels_have_ukrainian_translations() {
let sources = [include_str!("dialog.rs")];
let mut missing: Vec<String> = dialog::TABS.iter().filter(|(_, l)| crate::i18n::tr("uk", l) == *l).map(|(_, l)| l.to_string()).collect();
for source in sources {
for rest in source.split("crate::tl!(").skip(1) {
let Some(text) = rest.strip_prefix('"').and_then(|r| r.split('"').next()) else { continue };
// File format names read the same in every language.
if crate::i18n::tr("uk", text) == text && !["DXF", "DWG"].contains(&text) {
missing.push(text.to_string());
}
}
}
assert!(missing.is_empty(), "missing translations: {missing:?}");
}
}
-31
View File
@@ -169,37 +169,6 @@ fn a_selected_block_reference_offers_the_block_editor() {
assert_eq!(app.session.state().unwrap().block_editor(), None);
}
#[test]
fn every_menu_label_has_a_ukrainian_translation() {
let mut app = app();
app.cmdline("LINE 0,0 10,0");
app.cmdline("");
app.run("pline", json!({ "vertices": [[0, 0], [10, 0]] })).unwrap();
select_all(&mut app);
let mut all = flatten(&context_menu::entries(&app));
let line = app.session.doc().unwrap().model.handles()[0];
app.session.set_selection(app.session.doc().unwrap().model.handles().into_iter().filter(|h| *h != line).collect());
all.extend(flatten(&context_menu::entries(&app)));
all.extend(flatten(&context_menu::snap_entries(&app)));
type_line(&mut app, "LINE");
type_line(&mut app, "0,0");
all.extend(flatten(&context_menu::command_entries(&app).unwrap()));
let mut missing = Vec::new();
for (label, id, _) in &all {
let segments: Vec<&str> = label.split('/').collect();
// The item itself is user text when it types a value, or a command name (Recent Commands).
let user_text = (id.starts_with(context_menu::TYPE) && !id.contains('\'')) || segments.first() == Some(&"Recent Commands");
let n = segments.len() - usize::from(user_text);
for s in &segments[..n] {
// Point filters (.X) and "Repeat LINE" (a template) aren't catalog keys.
if !s.starts_with('.') && !s.starts_with("Repeat ") && cadcraft_ui_egui::i18n::tr("uk", s) == *s {
missing.push(s.to_string());
}
}
}
assert!(missing.is_empty(), "untranslated menu labels: {missing:?}");
}
/// The text painted in one frame at `time` (seconds).
fn painted(app: &mut CadApp, ctx: &egui::Context, events: Vec<egui::Event>, time: f64) -> String {
fn collect(shape: &egui::Shape, text: &mut String) {
+1 -11
View File
@@ -136,7 +136,7 @@ fn layer_and_properties_palette_menus() {
}
#[test]
fn every_label_has_a_ukrainian_translation_and_every_id_a_command() {
fn every_id_is_a_command() {
let mut app = app();
app.cmdline("LINE 0,0 10,0");
app.cmdline("");
@@ -146,16 +146,7 @@ fn every_label_has_a_ukrainian_translation_and_every_id_a_command() {
all.extend(flatten(&sm::layout_tab_entries(&app, "Layout1")));
all.extend(flatten(&sm::layer_entries(&app, "0")));
all.extend(flatten(&sm::properties_entries(&app)));
let mut missing = Vec::new();
for (label, id, _) in &all {
let segments: Vec<&str> = label.split('/').collect();
// Command names (Recent Commands) and line counts aren't translated.
let n = segments.len() - usize::from(segments.len() > 1);
for s in &segments[..n] {
if cadcraft_ui_egui::i18n::tr("uk", s) == *s {
missing.push(s.to_string());
}
}
let command = id.strip_prefix("run:").and_then(|r| r.split_once(':')).map_or(id.as_str(), |(c, _)| c);
let special = ["drawing:", "layout:", "cmdline:"].iter().any(|p| id.starts_with(p));
assert!(
@@ -163,5 +154,4 @@ fn every_label_has_a_ukrainian_translation_and_every_id_a_command() {
"{label}: {id} isn't a command"
);
}
assert!(missing.is_empty(), "untranslated menu labels: {missing:?}");
}
+8 -8
View File
@@ -30,8 +30,8 @@ selection. Resetting palettes keeps the language choice.
The catalogs cover command and menu labels, tool groups and tooltips, the Start page,
file and model-space tabs, status controls, object snaps, drafting prompts, property labels,
dialogs, and the About window's controls. The command reference can be searched by English
command ID, English label, or translated label. Ukrainian has enforced coverage;
missing entries in the other catalogs are reported by tests and fall back to English.
command ID, English label, or translated label. A missing entry falls back to English and
never fails a build; `cargo xtask i18n` (a step of `cargo xtask ci`) lists each language's.
Czech has 1,202 catalog rows, covering the reference catalog and every marked UI
literal, including Options. Four `menu` entries distinguish menu headings from object/command
@@ -139,14 +139,14 @@ The implementation follows the display-time catalog pattern used by
- Windows retain explicit IDs based on their English source, and tool groups retain their
source names as keys, so switching languages preserves their state.
- Add a translation attribution row in `ATTRIBUTION.md`, update the language lists, and add
locale, plural, font-glyph and live-switch tests. Add the catalog to `REPORTED` in
`i18n/tests.rs`: only Ukrainian, the reference translation, is in `REQUIRED`, so adding a
command needs a row in one language, not in all of them.
the language's locale tags and plural rule. The rendering, font-glyph and live-switch tests
run for every catalog; no test checks one language's wording, and adding a command needs
only its English label.
- Catalogs are embedded and parsed once. Missing or malformed rows cannot crash the app;
tests reject invalid rows, duplicate keys, changed placeholders, and mismatched ellipses.
- `cargo test -p cadcraft-ui-egui i18n` checks menu/command/tool/snap coverage, every marked
UI literal, locale resolution, preferences, integer plurals, bundled glyphs and actual
egui renders with language switching. `cargo xtask assets` checks asset attribution;
- `cargo test -p cadcraft-ui-egui i18n` checks the catalogs, locale resolution, preferences,
integer plurals, bundled glyphs and actual egui renders with language switching.
`cargo xtask i18n` prints the strings each language shows in English. `cargo xtask assets` checks asset attribution;
`cargo xtask ci` runs the workspace quality gates.
Translations are original work under `MIT OR Apache-2.0`, written from the meaning of
+16 -1
View File
@@ -21,7 +21,8 @@ commands:
layers enforce the crate dependency layering (plan/architecture.md §3)
wasm cargo check --target wasm32-unknown-unknown for the wasm-safe crates
test cargo nextest run (cargo test without nextest) plus the doctests
ci fmt --check, clippy -D warnings, test, assets, layers, wasm (stops at first failure)
i18n print the strings each interface language shows in English (never fails)
ci fmt --check, clippy -D warnings, test, i18n, assets, layers, wasm (stops at first failure)
corpus [--download]
show where test corpora live; --download fetches PngSuite into corpus/pngsuite
stats [--exact] count tests and lines per crate (--exact: ask the test harness via `-- --list`)
@@ -38,6 +39,7 @@ fn main() -> ExitCode {
Some("layers") => cmd_layers(),
Some("wasm") => cmd_wasm(),
Some("test") => cmd_test(),
Some("i18n") => cmd_i18n(),
Some("ci") => cmd_ci(),
Some("corpus") => cmd_corpus(rest.contains(&"--download")),
Some("stats") => stats::run(&root(), rest.contains(&"--exact")),
@@ -167,6 +169,18 @@ fn cmd_test() -> Result<(), String> {
run(c, "cargo test --workspace --doc")
}
/// The translation gap report (`cadcraft-ui-egui`'s ignored `i18n::tests::translation_gaps`).
/// A missing translation shows in English and never fails a build, so neither does this step.
fn cmd_i18n() -> Result<(), String> {
let mut c = cargo();
// `--workspace` builds the same test binaries as the test step; `-p` alone would rebuild them.
c.args(["test", "--workspace", "--lib", "-q", "--", "--ignored", "--exact", "i18n::tests::translation_gaps", "--nocapture"]);
if let Err(e) = run(c, "cargo test --workspace --lib -q -- --ignored --exact i18n::tests::translation_gaps --nocapture") {
eprintln!("note: the translation gap report did not run: {e}");
}
Ok(())
}
fn cmd_ci() -> Result<(), String> {
type Step = (&'static str, Box<dyn Fn() -> Result<(), String>>);
let steps: Vec<Step> = vec![
@@ -187,6 +201,7 @@ fn cmd_ci() -> Result<(), String> {
}),
),
("test", Box::new(cmd_test)),
("i18n", Box::new(cmd_i18n)),
("assets", Box::new(|| assets::run(&root()))),
("layers", Box::new(cmd_layers)),
("wasm", Box::new(cmd_wasm)),