Keep the files many PRs add to conflict-free (#1564)

Message catalogs sorted one message per line (cargo xtask lists [--fix], checked in cargo xtask ci and the Lists workflow); the language list one per line, English first then by id; THIRD-PARTY-LICENSES.txt and docs/features.md rewritten on main by the Generated files workflow instead of in PRs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
bherbruckandClaude Opus 5.5 authored and GitHub committed 2026-10-15 21:59:52 -04:00
1 parent 342305705d
commit f08ed74617
18 files changed
+6001 -5817

No files matched your search

+48
View File
@@ -0,0 +1,48 @@
# Files generated from the code are rewritten here, on main, after each merge, so pull requests
# never edit them (two pull requests that each regenerated one would always conflict):
# THIRD-PARTY-LICENSES.txt (`cargo xtask licences`) and docs/features.md (`cargo xtask features`).
name: Generated files
on:
push:
branches: [main]
workflow_dispatch:
permissions:
contents: write
# One run at a time; a burst of merges leaves one run waiting, which sees all of them.
concurrency:
group: generated-files
cancel-in-progress: false
jobs:
regenerate:
name: Regenerate and commit
runs-on: ubuntu-24.04
timeout-minutes: 60
steps:
# Always main itself, never pull request code, since this job can push.
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: main
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
key: generated-files
- name: THIRD-PARTY-LICENSES.txt
run: cargo xtask licences
- name: docs/features.md
run: cargo xtask features
- name: Commit if changed
run: |
if git diff --quiet -- THIRD-PARTY-LICENSES.txt docs/features.md; then echo "unchanged"; exit 0; fi
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git add THIRD-PARTY-LICENSES.txt docs/features.md
git commit -m "Regenerate THIRD-PARTY-LICENSES.txt and docs/features.md [skip ci]"
for _ in 1 2 3; do
git pull --rebase origin main && git push origin HEAD:main && exit 0
sleep 10
done
exit 1
+12
View File
@@ -18,3 +18,15 @@ jobs:
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- run: bash scripts/test-format-pending.sh
sorted:
name: Lists are sorted one item per line
runs-on: ubuntu-24.04
timeout-minutes: 15
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
key: lists
- run: cargo xtask lists
+8 -2
View File
@@ -160,5 +160,11 @@ before the draft is verified.
## Roadmap
`ROADMAP.md` (committed) tracks status, milestones and estimates. Update it whenever a
milestone task lands. `cargo xtask features` regenerates `docs/features.md` (SolidCraft's
commands by area, from the registry) when commands are added or renamed.
milestone task lands. `docs/features.md` (SolidCraft's commands by area, from the registry) and
`THIRD-PARTY-LICENSES.txt` are regenerated on `main` after each merge by the Generated files
workflow; never regenerate them in a PR.
## Lists many PRs add to
Keep them conflict-free (CONTRIBUTING.md, "Lists many changes add to"): message catalogs sorted
(`cargo xtask lists --fix`), `mod` lines in their sorted group, the language list English first
then by id, generated files left to `main`, and a design-format change as one new file.
+8 -2
View File
@@ -133,5 +133,11 @@ before the draft is verified.
## Roadmap
`ROADMAP.md` (committed) tracks status, milestones and estimates. Update it whenever a
milestone task lands. `cargo xtask features` regenerates `docs/features.md` (SolidCraft's
commands by area, from the registry) when commands are added or renamed.
milestone task lands. `docs/features.md` (SolidCraft's commands by area, from the registry) and
`THIRD-PARTY-LICENSES.txt` are regenerated on `main` after each merge by the Generated files
workflow; never regenerate them in a PR.
## Lists many PRs add to
Keep them conflict-free (CONTRIBUTING.md, "Lists many changes add to"): message catalogs sorted
(`cargo xtask lists --fix`), `mod` lines in their sorted group, the language list English first
then by id, generated files left to `main`, and a design-format change as one new file.
+20 -2
View File
@@ -98,8 +98,8 @@ The [README](README.md#architecture) has the layer table. The short version:
MIT OR Apache-2.0, and its solver and blends are written from the maths. (Running an
open-source application to get reference values for a test is fine; reading its source is
not.) Permissive
dependencies are fine. Note which one you're adding and why in the PR, and run
`cargo xtask licences` so THIRD-PARTY-LICENSES.txt lists it (CI checks that it is current).
dependencies are fine. Note which one you're adding and why in the PR. THIRD-PARTY-LICENSES.txt
lists it once the change lands: the Generated files workflow rewrites it on `main`.
Nightly/manual CI also scans advisories, bans and sources with cargo-deny; see
[the local command and coverage limits](docs/book/src/dev/testing.md#dependency-advisories).
3. **Every asset is original or openly licensed and has a row in
@@ -224,6 +224,24 @@ a conflict. `crates/doc/src/format/pending/README.md` has an example.
files into `crates/doc/src/format/v<N>/` and fixes that number for good (see
[docs/releasing.md](docs/releasing.md)). Never add a file to a `v<N>/` folder afterwards.
## Lists many changes add to
Some files gain an item in many pull requests at once. They are kept so that two changes that
each add an item only meet when their items are neighbours, which spares everyone a conflict
and a fresh CI run:
- **Message catalogs** (`assets/locales/*.json`): one message per line, sorted by id. Add your
message anywhere and run `cargo xtask lists --fix`; `cargo xtask lists` (in `cargo xtask ci`
and the Lists workflow) fails on an unsorted catalog or an id that appears twice.
- **`mod` lines**: one per line, in the sorted group they belong to (`cargo fmt` keeps each group
sorted). Add a new module into the existing group, not as a new group at the end.
- **Languages** (`crates/ui-egui/src/i18n.rs`): one per line, English first, then by id (a test
checks it).
- **Generated files**: `THIRD-PARTY-LICENSES.txt` and `docs/features.md` are rewritten on `main`
after each merge by the Generated files workflow (`cargo xtask licences`, `cargo xtask
features`). Don't regenerate or edit them in a pull request.
- **Design format**: one file per change, never an edit to a shared number (above).
## Sending a change
1. Work is tracked in [GitHub issues](https://github.com/bherbruck/solidcraft/issues), labelled
+870 -871
View File
File diff suppressed because it is too large. Load diff
+648 -649
View File
File diff suppressed because it is too large. Load diff
+562 -564
View File
File diff suppressed because it is too large. Load diff
+648 -649
View File
File diff suppressed because it is too large. Load diff
+870 -871
View File
File diff suppressed because it is too large. Load diff
+495 -495
View File
File diff suppressed because it is too large. Load diff
+611 -611
View File
File diff suppressed because it is too large. Load diff
+438 -438
View File
File diff suppressed because it is too large. Load diff
+654 -655
View File
File diff suppressed because it is too large. Load diff
+25 -3
View File
@@ -32,15 +32,28 @@ impl Catalog {
}
static ENGLISH: Catalog = Catalog::new("en", "English", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/../../assets/locales/en.json")));
static JAPANESE: Catalog = Catalog::new("ja", "日本語", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/../../assets/locales/ja.json")));
static GERMAN: Catalog = Catalog::new("de", "Deutsch", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/../../assets/locales/de.json")));
static FRENCH: Catalog = Catalog::new("fr", "Français", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/../../assets/locales/fr.json")));
static JAPANESE: Catalog = Catalog::new("ja", "日本語", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/../../assets/locales/ja.json")));
static KOREAN: Catalog = Catalog::new("ko", "한국어", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/../../assets/locales/ko.json")));
static RUSSIAN: Catalog = Catalog::new("ru", "Русский", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/../../assets/locales/ru.json")));
static UKRAINIAN: Catalog = Catalog::new("uk", "Українська", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/../../assets/locales/uk.json")));
static TURKISH: Catalog = Catalog::new("tr", "Türkçe", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/../../assets/locales/tr.json")));
static UKRAINIAN: Catalog = Catalog::new("uk", "Українська", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/../../assets/locales/uk.json")));
static CHINESE: Catalog = Catalog::new("zh", "中文", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/../../assets/locales/zh.json")));
pub const LOCALES: &[&Catalog] = &[&ENGLISH, &JAPANESE, &GERMAN, &FRENCH, &KOREAN, &RUSSIAN, &UKRAINIAN, &TURKISH, &CHINESE];
/// English first (the fallback), then by language id, one per line so that adding a language
/// doesn't edit a line another addition edits.
#[rustfmt::skip]
pub const LOCALES: &[&Catalog] = &[
&ENGLISH,
&GERMAN,
&FRENCH,
&JAPANESE,
&KOREAN,
&RUSSIAN,
&TURKISH,
&UKRAINIAN,
&CHINESE,
];
pub fn is_supported(id: &str) -> bool {
LOCALES.iter().any(|catalog| catalog.id == id)
@@ -188,6 +201,15 @@ pub fn tr_name(text: &str) -> String {
#[cfg(test)]
mod tests {
use super::*;
/// The languages are listed English first, then by id, so adding one doesn't edit the
/// line another addition edits.
#[test]
fn locales_are_listed_english_first_then_by_id() {
let ids: Vec<&str> = LOCALES.iter().map(|c| c.id).collect();
assert_eq!(ids.first(), Some(&"en"));
assert!(ids[1..].is_sorted(), "{ids:?}");
}
use std::collections::BTreeSet;
fn placeholders(text: &str) -> BTreeSet<&str> {
+8 -3
View File
@@ -2,8 +2,10 @@
//! into the shipped programs (solidcraft, solidcraft-cli, solidcraft-web), read from
//! `cargo metadata` and the crates' own licence files. The programs embed it (Help ▸ About ▸
//! Licences, `solidcraft-cli licences`), so a release download needs no licence files beside it.
//! `--check` (part of `cargo xtask ci`) fails when the committed file is out of date, e.g. after a
//! dependency changed.
//! `--check` (part of `cargo xtask ci`) generates it without writing it: it fails when the
//! dependency graph can't be read, and only reports a file that is out of date, since pull
//! requests leave it to the Generated files workflow (`.github/workflows/generated.yml`), which
//! rewrites it on main after each merge.
use std::collections::{BTreeMap, BTreeSet, VecDeque};
use std::path::Path;
@@ -21,7 +23,10 @@ pub fn run(root: &Path, check: bool) -> Result<(), String> {
if check {
let old = std::fs::read_to_string(&path).unwrap_or_default();
if old.replace("\r\n", "\n") != text {
return Err(format!("{FILE} is out of date (dependencies changed): run `cargo xtask licences` and commit it"));
// Not an error: pull requests don't edit the file (two that did would always conflict);
// the Generated files workflow rewrites it on main after the merge.
println!("licences: {FILE} will change when this lands (the Generated files workflow rewrites it on main)");
return Ok(());
}
println!("licences: {FILE} is up to date");
return Ok(());
+68
View File
@@ -0,0 +1,68 @@
//! `cargo xtask lists [--fix]`: files that many pull requests add to are kept in a canonical,
//! sorted form, so two changes that each add an item meet only when their items are neighbours
//! (CONTRIBUTING.md, "Lists many changes add to"). Checked by `cargo xtask ci` and the Lists
//! workflow; `--fix` rewrites them.
//!
//! - The message catalogs (`assets/locales/*.json`): one message per line, sorted by id, no
//! duplicate ids, as `serde_json` pretty-prints them.
use std::path::Path;
use serde_json::{Map, Value};
const LOCALES: &str = "assets/locales";
pub fn run(root: &Path, fix: bool) -> Result<(), String> {
let dir = root.join(LOCALES);
let entries = std::fs::read_dir(&dir).map_err(|e| format!("{}: {e}", dir.display()))?;
let mut files: Vec<_> = entries.filter_map(Result::ok).map(|e| e.path()).filter(|p| p.extension().is_some_and(|x| x == "json")).collect();
files.sort();
let mut stale = Vec::new();
for path in files {
let text = std::fs::read_to_string(&path).map_err(|e| format!("{}: {e}", path.display()))?;
let canonical = canonical(&text).map_err(|e| format!("{}: {e}", path.display()))?;
if canonical == text.replace("\r\n", "\n") {
continue;
}
if fix {
std::fs::write(&path, &canonical).map_err(|e| format!("{}: {e}", path.display()))?;
println!("lists: sorted {}", path.display());
} else {
stale.push(path.strip_prefix(root).unwrap_or(&path).display().to_string());
}
}
if !stale.is_empty() {
return Err(format!(
"not sorted one message per line (or an id appears twice): {}\nrun `cargo xtask lists --fix` and commit the result",
stale.join(", ")
));
}
println!("lists: message catalogs are sorted");
Ok(())
}
/// A catalog as it should be written: its messages sorted by id, one per line. A repeated id
/// keeps its last message, the one the app reads.
pub fn canonical(text: &str) -> Result<String, String> {
let map: Map<String, Value> = match serde_json::from_str(text).map_err(|e| e.to_string())? {
Value::Object(map) => map,
_ => return Err("a catalog must be a JSON object".into()),
};
if let Some((id, _)) = map.iter().find(|(_, v)| !v.is_string()) {
return Err(format!("message `{id}` is not a string"));
}
Ok(serde_json::to_string_pretty(&Value::Object(map)).map_err(|e| e.to_string())? + "\n")
}
#[cfg(test)]
mod tests {
use super::canonical;
#[test]
fn catalogs_are_sorted_one_per_line_and_a_repeated_id_keeps_its_last_message() {
let text = "{\n \"b\": \"B\",\n \"a\": \"A\",\n \"b\": \"B2\"\n}";
assert_eq!(canonical(text).unwrap(), "{\n \"a\": \"A\",\n \"b\": \"B2\"\n}\n");
assert!(canonical("[]").is_err());
assert!(canonical("{\"a\": 1}").is_err());
}
}
+8 -2
View File
@@ -10,6 +10,7 @@ mod features;
mod i18n;
mod layers;
mod licences;
mod lists;
mod test;
mod web_offline;
mod web_offline_test;
@@ -46,7 +47,10 @@ commands:
into target/book
licences [--check]
write THIRD-PARTY-LICENSES.txt (the licences of every shipped crate, embedded
in the programs); --check fails when it is out of date
in the programs); --check only reports when it is out of date: pull requests
leave it to the Generated files workflow on main
lists [--fix] check that files many changes add to are sorted one item per line (the
message catalogs in assets/locales); --fix sorts them
test [CARGO ARGS] [-- TEST ARGS]
build the tests with the ci profile, then run every test binary at once
(and the doc tests alongside)
@@ -57,7 +61,7 @@ commands:
web-offline-test [--site DIR]
check in headless Chrome that the web app works offline and updates to a new
build (a stand-in app, or the web build in DIR such as dist/web)
ci fmt --check, assets, licences --check, layers, clippy -D warnings, then the tests
ci fmt --check, assets, licences --check, lists, layers, clippy -D warnings, then the tests
with the wasm check alongside, then web-offline-test when Chrome is installed
(stops at the first failing step)
";
@@ -78,6 +82,7 @@ fn main() -> ExitCode {
}
Some("book") => book::run(&root(), !rest.contains(&"--no-build")),
Some("licences") => licences::run(&root(), rest.contains(&"--check")),
Some("lists") => lists::run(&root(), rest.contains(&"--fix")),
Some("test") => {
let (cargo_args, test_args) = rest.iter().position(|a| *a == "--").map_or((&rest[..], &[][..]), |i| (&rest[..i], &rest[i + 1..]));
test::run(cargo_args, test_args, vec![test::doc_tests(cargo_args, test_args)])
@@ -358,6 +363,7 @@ fn cmd_ci() -> Result<(), String> {
run(c, "cargo fmt --check")?;
assets::run(&root())?;
licences::run(&root(), true)?;
lists::run(&root(), false)?;
cmd_layers()?;
let mut c = cargo();
c.args(["clippy", "--workspace", "--all-targets", "--profile", "ci", "--", "-D", "warnings"]);