Skip to content
Open
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
2 changes: 2 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@
# Toplevel build bits
!Makefile
!Cargo.*
# cargo alias config (defines `cargo xtask` shorthand used in Dockerfile stages)
!.cargo/config.toml
# License and doc files needed for RPM
!LICENSE-*
!README.md
Expand Down
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,6 @@ bootc.tar.zst

# Added by cargo
/target

# Git worktrees
.worktrees/
18 changes: 18 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,24 @@ RUN --network=none --mount=type=tmpfs,target=/run --mount=type=tmpfs,target=/tmp
FROM buildroot as validate
RUN --network=none --mount=type=tmpfs,target=/run --mount=type=tmpfs,target=/tmp --mount=type=cache,target=/src/target --mount=type=cache,target=/var/roothome make validate

FROM validate as validate-post-build
RUN --network=none --mount=type=tmpfs,target=/run --mount=type=tmpfs,target=/tmp --mount=type=cache,target=/src/target --mount=type=cache,target=/var/roothome \
cargo xtask update-generated from-code --check

# Stage for updating generated docs files (man pages, JSON schemas) on the host.
# Usage: podman build --target update-generated-from-code --output type=local,dest=. .
# This runs `from-code` inside the container (where ostree is available) and
# exports only the generated files back to the host working directory.
FROM buildroot as update-generated-from-code
RUN --network=none --mount=type=tmpfs,target=/run --mount=type=tmpfs,target=/tmp --mount=type=cache,target=/src/target --mount=type=cache,target=/var/roothome \
cargo xtask update-generated from-code
# Export only the generated docs — not the entire container filesystem.
# Glob patterns here automatically pick up any new schemas added to JSON_SCHEMAS
# in crates/xtask/src/xtask.rs without requiring Dockerfile changes.
FROM scratch as update-generated-from-code-output
COPY --from=update-generated-from-code /src/docs/src/man/ /docs/src/man/
COPY --from=update-generated-from-code /src/docs/src/*.schema.json /docs/src/

# ----
# Stages for the final image
# ----
Expand Down
10 changes: 7 additions & 3 deletions Justfile
Original file line number Diff line number Diff line change
Expand Up @@ -169,10 +169,11 @@ test-upgrade *ARGS: build _build-upgrade-source-image
"${composefs_args[@]}" \
{{upgrade_source_img}} {{ARGS}} readonly

# Run cargo fmt and clippy checks in container
# Run all validation checks: tmt plan staleness (local), then fmt/clippy/man/schema (container)
[group('core')]
validate:
podman build {{base_buildargs}} --target validate .
cargo xtask update-generated direct --check
podman build {{base_buildargs}} --target validate-post-build .

# Test container export via Anaconda liveimg install in a QEMU VM
[group('testing')]
Expand Down Expand Up @@ -291,9 +292,12 @@ pullspec-for-os TYPE NAME:
# ============================================================================

# Update generated files (man pages, JSON schemas)
# tmt plans are updated directly; man pages + JSON schemas are regenerated
# inside a container (so ostree is available) and written back via --output.
[group('maintenance')]
update-generated:
cargo run -p xtask update-generated
cargo xtask update-generated direct
podman build {{base_buildargs}} --target update-generated-from-code-output --output type=local,dest=. .

# Remove all locally-built test container images
[group('maintenance')]
Expand Down
3 changes: 0 additions & 3 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -129,9 +129,6 @@ fix-rust:
cargo clippy --fix --allow-dirty -- $(CLIPPY_CONFIG)
.PHONY: fix-rust

update-generated:
cargo xtask update-generated
.PHONY: update-generated

vendor:
cargo xtask $@
Expand Down
195 changes: 171 additions & 24 deletions crates/xtask/src/man.rs
Original file line number Diff line number Diff line change
Expand Up @@ -174,20 +174,19 @@ fn format_options_as_markdown(options: &[CliOption], positionals: &[CliPositiona
result
}

/// Update markdown file with generated subcommands
pub fn update_markdown_with_subcommands(
/// Compute what `docs/src/man/<file>` should look like after regenerating its subcommands section.
/// Returns `None` if the file has no subcommands marker (nothing to do).
fn compute_markdown_with_subcommands(
markdown_path: &Utf8Path,
content: &str,
subcommands: &[CliCommand],
parent_path: &[&str],
) -> Result<()> {
let content =
fs::read_to_string(markdown_path).with_context(|| format!("Reading {}", markdown_path))?;

) -> Result<Option<String>> {
let begin_marker = "<!-- BEGIN GENERATED SUBCOMMANDS -->";
let end_marker = "<!-- END GENERATED SUBCOMMANDS -->";

let Some((before, rest)) = content.split_once(begin_marker) else {
return Ok(()); // Skip files without markers
return Ok(None); // Skip files without markers
};

let Some((_, after)) = rest.split_once(end_marker) else {
Expand All @@ -202,34 +201,25 @@ pub fn update_markdown_with_subcommands(
// Trim trailing whitespace from before section and ensure exactly one blank line
let before = before.trim_end();

let new_content = format!(
Ok(Some(format!(
"{}\n\n{}\n{}{}{}",
before, begin_marker, generated_subcommands, end_marker, after
);

// Only write if content has changed to avoid updating mtime unnecessarily
if new_content != content {
fs::write(markdown_path, new_content)
.with_context(|| format!("Writing to {}", markdown_path))?;
println!("Updated subcommands in {}", markdown_path);
}
Ok(())
)))
}

/// Update markdown file with generated options
pub fn update_markdown_with_options(
/// Compute what `docs/src/man/<file>` should look like after regenerating its options section.
/// Returns `None` if the file has no options marker (nothing to do).
fn compute_markdown_with_options(
markdown_path: &Utf8Path,
content: &str,
options: &[CliOption],
positionals: &[CliPositional],
) -> Result<()> {
let content =
fs::read_to_string(markdown_path).with_context(|| format!("Reading {}", markdown_path))?;

) -> Result<Option<String>> {
let begin_marker = "<!-- BEGIN GENERATED OPTIONS -->";
let end_marker = "<!-- END GENERATED OPTIONS -->";

let Some((before, rest)) = content.split_once(begin_marker) else {
return Ok(()); // Skip files without markers
return Ok(None); // Skip files without markers
};

let Some((_, after)) = rest.split_once(end_marker) else {
Expand All @@ -256,6 +246,48 @@ pub fn update_markdown_with_options(
format!("{}\n\n{}\n{}{}", before, begin_marker, end_marker, after)
};

Ok(Some(new_content))
}

/// Update markdown file with generated subcommands
pub fn update_markdown_with_subcommands(
markdown_path: &Utf8Path,
subcommands: &[CliCommand],
parent_path: &[&str],
) -> Result<()> {
let content =
fs::read_to_string(markdown_path).with_context(|| format!("Reading {}", markdown_path))?;

let Some(new_content) =
compute_markdown_with_subcommands(markdown_path, &content, subcommands, parent_path)?
else {
return Ok(());
};

// Only write if content has changed to avoid updating mtime unnecessarily
if new_content != content {
fs::write(markdown_path, new_content)
.with_context(|| format!("Writing to {}", markdown_path))?;
println!("Updated subcommands in {}", markdown_path);
}
Ok(())
}

/// Update markdown file with generated options
pub fn update_markdown_with_options(
markdown_path: &Utf8Path,
options: &[CliOption],
positionals: &[CliPositional],
) -> Result<()> {
let content =
fs::read_to_string(markdown_path).with_context(|| format!("Reading {}", markdown_path))?;

let Some(new_content) =
compute_markdown_with_options(markdown_path, &content, options, positionals)?
else {
return Ok(());
};

// Only write if content has changed to avoid updating mtime unnecessarily
if new_content != content {
fs::write(markdown_path, new_content)
Expand Down Expand Up @@ -611,6 +643,121 @@ TODO: Add practical examples showing how to use this command.
Ok(())
}

/// Check that all man page markdown files are up to date.
/// Fails with an error if any file would change, similar to `cargo fmt --check`.
#[context("Checking man pages")]
pub fn check_manpages(sh: &Shell) -> Result<()> {
let cli_structure = extract_cli_json(sh)?;

// First: check no man pages are missing
fn collect_commands(cmd: &CliCommand, path: Vec<String>, acc: &mut Vec<Vec<String>>) {
for sub in &cmd.subcommands {
let mut sub_path = path.clone();
sub_path.push(sub.name.clone());
acc.push(sub_path.clone());
collect_commands(sub, sub_path, acc);
}
}
let mut commands_to_check = Vec::new();
collect_commands(&cli_structure, Vec::new(), &mut commands_to_check);
for command_parts in &commands_to_check {
let filename = format!("bootc-{}.8.md", command_parts.join("-"));
let filepath = Utf8Path::new("docs/src/man").join(&filename);
if !filepath.exists() {
anyhow::bail!(
"{} is missing; run `cargo xtask update-generated` to create it",
filepath
);
}
}

let mappings = discover_man_page_mappings(&cli_structure)?;

for (filename, subcommand_path) in mappings {
let markdown_path = Utf8Path::new("docs/src/man").join(&filename);
if !markdown_path.exists() {
continue;
}

let target_cmd = if let Some(ref path) = subcommand_path {
let path_refs: Vec<&str> = path.iter().map(|s| s.as_str()).collect();
find_subcommand(&cli_structure, &path_refs)
.ok_or_else(|| anyhow::anyhow!("Subcommand {:?} not found", path))?
} else {
&cli_structure
};

let content = fs::read_to_string(&markdown_path)
.with_context(|| format!("Reading {}", markdown_path))?;

if content.contains("<!-- BEGIN GENERATED OPTIONS -->") {
check_markdown_options(
&markdown_path,
&content,
&target_cmd.options,
&target_cmd.positionals,
)?;
}
if content.contains("<!-- BEGIN GENERATED SUBCOMMANDS -->") {
let parent_path: Vec<&str> = if let Some(path) = &subcommand_path {
path.iter().map(|s| s.as_str()).collect()
} else {
vec![]
};
check_markdown_subcommands(
&markdown_path,
&content,
&target_cmd.subcommands,
&parent_path,
)?;
}
}

Ok(())
}

/// Compare-only variant of `update_markdown_with_options`.
fn check_markdown_options(
markdown_path: &Utf8Path,
content: &str,
options: &[CliOption],
positionals: &[CliPositional],
) -> Result<()> {
let Some(new_content) =
compute_markdown_with_options(markdown_path, content, options, positionals)?
else {
return Ok(());
};
if new_content != content {
anyhow::bail!(
"{} is out of date; run `cargo xtask update-generated` to update it",
markdown_path
);
}
Ok(())
}

/// Compare-only variant of `update_markdown_with_subcommands`.
fn check_markdown_subcommands(
markdown_path: &Utf8Path,
content: &str,
subcommands: &[CliCommand],
parent_path: &[&str],
) -> Result<()> {
let Some(new_content) =
compute_markdown_with_subcommands(markdown_path, content, subcommands, parent_path)?
else {
return Ok(());
};
if new_content != content {
anyhow::bail!(
"{} is out of date; run `cargo xtask update-generated` to update it",
markdown_path
);
}
Ok(())
}

/// Apply post-processing fixes to generated man pages
#[context("Fixing man pages")]
fn apply_man_page_fixes(sh: &Shell, dir: &Utf8Path) -> Result<()> {
Expand Down
Loading