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!(