diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cea82cfe..b59f6427 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -150,8 +150,9 @@ jobs: - name: Changelog fragments (docs/changes) run: cargo xtask changelog --check # The pages of the documentation site: every link goes to a page, a - # heading or a file that exists, and SUMMARY.md lists every guide. A - # change to the crates can break them too (a link to a renamed file). + # heading or a file that exists, and SUMMARY.md and docs/README.md + # list every guide. A change to the crates can break them too (a link + # to a renamed file). - name: Documentation links (docs/SUMMARY.md) run: cargo xtask docs --check - name: cargo test -p xtask diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 239b76e5..04db39f3 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -1101,7 +1101,10 @@ them. The staging rewrites a link to another page into that page of the site, and a link into the source tree (`../crates/…`) into the file on GitHub, on `main`. It fails on a link to a file or heading that does not exist, and on a Markdown file directly in `docs/` that SUMMARY.md does not -list: a new guide goes into SUMMARY.md, under its part. `cargo xtask docs +list: a new guide goes into SUMMARY.md, under its part, and into +[docs/README.md](docs/README.md), the index GitHub shows for the folder. That +index is no page of the site, but its links are checked as a page's, and the +staging fails when it does not link a page SUMMARY.md lists. `cargo xtask docs --check` runs the staging without mdBook; CI's `fmt` job runs it for a change to the pages or to the Rust code, whose files the pages link. diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 00000000..d66be0d5 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,51 @@ +# Forge documentation + +The guides to running Forge and publishing to it. They are also a site, with +navigation and search: ****. + +The [README](../README.md) says what Forge is and starts a first instance in +its [quickstart](../README.md#quickstart); +[DEVELOPMENT.md](../DEVELOPMENT.md) is the guide to working on Forge itself. + +## Running Forge + +- **[Production deployment](deployment.md)**: required settings, TLS and the + reverse proxy, secret encryption, health probes, backup and restore, and + upgrades. +- **[Retention size budgets](retention.md)**: keep a repository under a + logical size: the budget, how size is counted, and what goes first. +- **[Importing from Nexus](nexus-import.md)**: copy a Nexus hosted repository + (NuGet, Docker) into Forge, with its publish dates. + +## Formats + +- **NuGet**: the README's [quickstart](../README.md#quickstart) points a + `dotnet` client at a feed, and the CI guides below publish to one. +- **[Docker / OCI registry](docker.md)**: image repositories (hosted, proxy, + group), naming, `docker login`, retention and garbage collection. +- **[npm registries](npm.md)**: npm packages for npm, pnpm, Yarn and Bun: + `.npmrc`, logins, publishing, dist-tags, proxies of `registry.npmjs.org` + and audits. +- **[Raw file repositories](raw.md)**: files under arbitrary paths: uploads + with curl, ranges and checksum sidecars, listings, and proxies of release + downloads. +- **[Format plugins](format-plugins.md)**: further package formats, built + into the server: design, operation, and writing one. + +## CI and publishing + +- **[Trusted publishing](trusted-publishing.md)**: short-lived workload + identity for GitHub Actions, GitLab CI and other OIDC issuers, and the + exchange of a job's token for an API key. +- **[GitLab CI with workload identity](gitlab-ci.md)**: step by step, every + job of a GitLab project restores from Forge, and only the release job + pushes. No secret in GitLab. +- **[CI with service accounts](ci-service-accounts.md)**: for pipelines that + cannot use workload identity, a read token and a release token in GitLab's + CI/CD variables. + +## Releases + +- **[Changelog](../CHANGELOG.md)**: release notes, upgrade notes and known + limitations. A change adds its entry as a fragment under + [changes/](changes/README.md). diff --git a/docs/changes/2.1.1/628.docs.md b/docs/changes/2.1.1/628.docs.md new file mode 100644 index 00000000..8200f695 --- /dev/null +++ b/docs/changes/2.1.1/628.docs.md @@ -0,0 +1,3 @@ +The `docs/` folder on GitHub has an index, [docs/README.md](docs/README.md): +the guides by part, with what each covers. `cargo xtask docs` checks its +links and fails when it leaves out a page of the site. diff --git a/xtask/src/docs.rs b/xtask/src/docs.rs index 0e1cba7c..23055aa7 100644 --- a/xtask/src/docs.rs +++ b/xtask/src/docs.rs @@ -12,6 +12,10 @@ //! * a link to a file or to a heading that does not exist is an error, and so //! is a Markdown file directly under `docs/` that SUMMARY.md does not list. //! +//! [`DOCS_INDEX`], the index GitHub shows for `docs/`, is no page of the site, +//! which has a start page and a sidebar of its own. Its links are checked as a +//! page's, and it must link every page, so that no guide is missing from it. +//! //! `cargo xtask docs --check` stops after the staging, without mdBook. use crate::changelog::repo_url; @@ -25,6 +29,10 @@ use std::path::Path; const DOCS: &str = "docs"; /// The table of contents: the site's pages, in their order. const SUMMARY: &str = "docs/SUMMARY.md"; +/// The index of the guides GitHub shows under the files of `docs/`. On the +/// site it would be `readme.md`, which mdBook turns into `index.md`, the +/// README's name there. +const DOCS_INDEX: &str = "docs/README.md"; /// mdBook's configuration, copied next to the staged pages. const BOOK_TOML: &str = "docs/book.toml"; /// Where the pages are staged (`src/`, the book's `src`) and the site is @@ -169,7 +177,7 @@ fn stage(tree: &impl Tree, repo: &str) -> Result, String> target.to_string() }); for path in tree.markdown_in(DOCS)? { - if path != SUMMARY && !pages.contains_key(&path) { + if path != SUMMARY && path != DOCS_INDEX && !pages.contains_key(&path) { errors.push(format!("{path}: no page of the site; list it in {SUMMARY}")); } } @@ -194,6 +202,27 @@ fn stage(tree: &impl Tree, repo: &str) -> Result, String> site.rewrite(SUMMARY, &summary, &mut errors), )); + // The index of docs/ is not staged: only its links and its list count. + if tree.entry(DOCS_INDEX) == Some(Entry::File) { + let index = tree.read(DOCS_INDEX)?; + site.rewrite(DOCS_INDEX, &index, &mut errors); + let mut linked = BTreeSet::new(); + map_links(&index, &mut |target| { + linked.extend(resolve( + DOCS_INDEX, + target.split('#').next().unwrap_or(target), + )); + target.to_string() + }); + for path in &order { + if !linked.contains(path) { + errors.push(format!( + "{DOCS_INDEX}: links no {path}, which {SUMMARY} lists" + )); + } + } + } + if errors.is_empty() { Ok(files) } else { @@ -651,6 +680,52 @@ mod tests { } } + #[test] + fn the_docs_index_is_no_page_but_links_every_page() { + let summary = "[Home](../README.md)\n\n- [Guide](guide.md)\n- [Other](other.md)\n"; + let pages = [ + ("docs/SUMMARY.md", summary), + ("README.md", "# Home\n## Quickstart\n"), + ("docs/guide.md", "# Guide\n"), + ("docs/other.md", "# Other\n"), + ]; + let tree = repo( + &[ + &pages[..], + &[( + "docs/README.md", + "# Guides\n\n[Home](../README.md#quickstart), [the guide](guide.md), \ + [the other](other.md#other).\n", + )], + ] + .concat(), + ); + let files = stage(&tree, REPO).expect("stages"); + let names: Vec<_> = files.iter().map(|(n, _)| n.as_str()).collect(); + assert_eq!(names, ["index.md", "guide.md", "other.md", "SUMMARY.md"]); + + let tree = repo( + &[ + &pages[..], + &[( + "docs/README.md", + "# Guides\n\n[the guide](guide.md#set-up), [gone](gone.md).\n", + )], + ] + .concat(), + ); + let err = stage(&tree, REPO).expect_err("incomplete index"); + for problem in [ + "docs/README.md: guide.md#set-up: docs/guide.md has no heading #set-up", + "docs/README.md: gone.md links to docs/gone.md, which does not exist", + "docs/README.md: links no README.md, which docs/SUMMARY.md lists", + "docs/README.md: links no docs/other.md, which docs/SUMMARY.md lists", + ] { + assert!(err.contains(problem), "{problem} missing from:\n{err}"); + } + assert!(!err.contains("no page of the site"), "{err}"); + } + #[test] fn resolves_relative_paths() { assert_eq!(