diff --git a/.github/scripts/check-pr-retarget-workflows.py b/.github/scripts/check-pr-retarget-workflows.py index f397eca05..5e1bb0df5 100755 --- a/.github/scripts/check-pr-retarget-workflows.py +++ b/.github/scripts/check-pr-retarget-workflows.py @@ -15,6 +15,7 @@ } EXPECTED_PULL_REQUEST_WORKFLOWS = { 'conflict-markers.yml', + 'live-docs-guard.yml', 'start-cli.yaml', 'start-registry.yaml', 'start-tunnel.yaml', @@ -80,6 +81,12 @@ def job_permissions(source: str, job: str) -> set[tuple[str, str]]: assert 'types: [opened, synchronize, reopened, edited]' in marker_trigger assert not re.search(r'^ workflow_call:\s*$', marker_trigger, re.MULTILINE) +guard_source = (WORKFLOWS / 'live-docs-guard.yml').read_text() +guard_trigger = guard_source.split('\npermissions:', 1)[0] +assert "branches: ['live-docs']" in guard_trigger +assert 'types: [opened, synchronize, reopened, edited]' in guard_trigger +assert not re.search(r'^ workflow_call:\s*$', guard_trigger, re.MULTILINE) + groups = {} for filename in TARGETS: source = (WORKFLOWS / filename).read_text() diff --git a/.github/workflows/docs-sync-on-tag.yml b/.github/workflows/docs-sync-on-tag.yml index ecc86ad14..523791704 100644 --- a/.github/workflows/docs-sync-on-tag.yml +++ b/.github/workflows/docs-sync-on-tag.yml @@ -1,22 +1,27 @@ -name: Sync docs to live-docs on tag - -# Cutting `/v` is what makes that product's documentation -# current, so the tag is what publishes it: this copies the tagged tree's book -# (and the shared start-docs infrastructure it renders through) onto live-docs, -# which docs.start9.com serves. The push then triggers docs-deploy.yml on its -# own — no explicit dispatch needed, because an App installation token raises -# real events. -# -# Projects with no docs/ subtree (start-cli, start-registry) are skipped whole — -# there is no book to publish, so there is no reason to move site infra either. +name: Sync a release onto live-docs + +# live-docs is what each product has published — the branch docs.start9.com +# serves, and the branch a packaging workspace checks out. Cutting +# `/v` is what makes a product current, so the tag is what +# advances it: this takes the tagged tree whole, then puts every *other* +# product back on the release it is on. A tag therefore speaks for its own +# project, the shared libraries beneath it, and the repo root — and for nothing +# another product owns. The push then triggers docs-deploy.yml on its own — no +# explicit dispatch needed, because an App installation token raises real +# events. # -# projects/start-docs/versions.conf rides along with the rest of the infra, so +# The shared site tree (projects/start-docs) rides along only with a product +# that ships a book: a bookless release (start-cli, start-registry) has nothing +# to redeploy, and docs-deploy.yml's paths filter is what keeps it from firing. +# projects/start-docs/versions.conf rides along with the rest of that tree, so # the version each book is published under is whatever the tagged tree said. It # is a deliberate, hand-maintained file: set it in master before you tag. # -# Two guards protect the shared tree: the tagged commit must be an ancestor of -# master, and a tag that predates the infrastructure already published syncs its -# book alone. Between them, a backfilled or re-cut tag cannot roll the site back. +# Two guards protect live-docs: the tagged commit must be an ancestor of master, +# and nothing moves backwards. A product's own tree is judged against that +# product's earlier releases and the shared trees against every product's, so a +# deb release that tags the older commit alpha built still advances its own +# project, while a backfilled or re-cut tag cannot roll anything back. on: push: tags: ['*/v*'] @@ -30,7 +35,7 @@ concurrency: jobs: sync: - name: Sync ${{ github.ref_name }} docs onto live-docs + name: Sync ${{ github.ref_name }} onto live-docs runs-on: ubuntu-latest # live-docs is PR-protected with no bypass for github-actions[bot], so this # needs the docs bot App, which IS a bypass actor. Its private key is an @@ -52,7 +57,7 @@ jobs: fetch-tags: true token: ${{ steps.app-token.outputs.token }} - - name: Copy the tagged docs onto live-docs + - name: Copy the tagged tree onto live-docs env: TAG: ${{ github.ref_name }} run: | @@ -62,9 +67,9 @@ jobs: SRC=$(git rev-list -n1 "$TAG") echo "Tag $TAG -> project $PROJECT, commit $SRC" - if ! git cat-file -e "$TAG:projects/$PROJECT/docs" 2>/dev/null; then - echo "::notice::$PROJECT ships no docs/ book — nothing to publish." - exit 0 + HAS_BOOK=0 + if git cat-file -e "$TAG:projects/$PROJECT/docs" 2>/dev/null; then + HAS_BOOK=1 fi git fetch --no-tags origin master @@ -76,44 +81,85 @@ jobs: exit 1 fi - # The shared projects/start-docs tree (theme, build.sh, versions.conf, - # deploy infra) is not versioned per product, so a backfilled or re-cut - # tag from an old commit would roll the whole site back to that commit's - # infrastructure. Each sync records the commit it published from; if - # this tag predates that, publish the book alone and leave the shared - # tree where it is. Ancestry, not dates — tag order is not chronology. - # Walk back to the newest commit that actually carries a trailer, not - # just the newest commit touching the tree: a hand PR to live-docs - # fixing the shared infrastructure has no trailer, and reading only the - # tip would blank the watermark and re-open the rollback it guards - # against. (Necessarily inert until the first sync writes one.) - # Captured whole, then sliced in the shell, rather than piped through - # `head -n1`: head closes the pipe after one line, and once enough - # trailers exist for sed to flush a second time it takes SIGPIPE and - # `pipefail` aborts the sync. That threshold is passed at ~120 published - # tags, so the pipe form works until it suddenly doesn't. - TRAILERS=$(git log --format=%B -- projects/start-docs | sed -n 's/^Source-Commit: *//p') - PREV=${TRAILERS%%$'\n'*} - SYNC_INFRA=1 - if [ -n "$PREV" ] && git cat-file -e "${PREV}^{commit}" 2>/dev/null \ - && [ "$PREV" != "$SRC" ] && git merge-base --is-ancestor "$SRC" "$PREV"; then - SYNC_INFRA=0 - echo "::warning::$TAG ($SRC) predates the published docs infrastructure ($PREV) — publishing $PROJECT's book only, leaving projects/start-docs untouched." - fi + # Every sync records the commit it published from as a Source-Commit + # trailer. A tag is behind a tree when its commit is a proper ancestor + # of any commit that tree was already synced from — any, not the + # newest, because releases land out of ancestry order: a deb release + # tags the commit alpha built, which can be older than a tag cut + # before it. Ancestry, not dates — tag order is not chronology. Hand + # PRs to live-docs carry no trailer and drop out of the scan. + # (Necessarily inert until the first sync writes one.) + # Captured whole rather than piped through `head -n1`: head closes the + # pipe after one line, and once enough trailers exist for sed to flush + # a second time it takes SIGPIPE and `pipefail` aborts the sync. + behind() { + local synced + for synced in $(git log --format=%B "$@" | sed -n 's/^Source-Commit: *//p'); do + if [ "$synced" != "$SRC" ] && git cat-file -e "${synced}^{commit}" 2>/dev/null \ + && git merge-base --is-ancestor "$SRC" "$synced"; then + echo "$synced" + return 0 + fi + done + return 1 + } + + # Two watermarks. Only a product's own tags write projects/$PROJECT, so + # that tree is behind only when an older release of the same product is + # already on live-docs. shared-libs, the repo root and the site tree are + # written by every product's tag, so they are behind whenever any + # release synced from a newer commit — and then they stay where that + # release put them. + OWN_BEHIND=$(behind -- "projects/$PROJECT" || true) + ALL_BEHIND=$(behind || true) git config user.name "start9-docs-bot[bot]" git config user.email "start9-docs-bot[bot]@users.noreply.github.com" - # Delete then restore from the tag, so files the release removed are - # actually gone rather than left behind by a copy-over. - rm -rf "projects/$PROJECT/docs" - git checkout "$TAG" -- "projects/$PROJECT/docs" - if [ "$SYNC_INFRA" = 1 ]; then - rm -rf projects/start-docs - git checkout "$TAG" -- projects/start-docs + # Put a path back to what live-docs has: gone if live-docs lacks it. + hold() { + git rm -rfq --ignore-unmatch -- "$1" + if git cat-file -e "HEAD:$1" 2>/dev/null; then + git checkout HEAD -- "$1" + fi + } + + if [ -n "$OWN_BEHIND" ]; then + if [ "$HAS_BOOK" = 0 ]; then + echo "::notice::$TAG ($SRC) predates $PROJECT's published release ($OWN_BEHIND) and ships no docs/ book — nothing to publish." + exit 0 + fi + echo "::warning::$TAG ($SRC) predates $PROJECT's published release ($OWN_BEHIND) — publishing its book alone, leaving the rest of live-docs untouched." + # Remove then restore from the tag, so files the release removed are + # actually gone rather than left behind by a copy-over. + git rm -rfq --ignore-unmatch -- "projects/$PROJECT/docs" + git checkout "$TAG" -- "projects/$PROJECT/docs" + else + if [ -n "$ALL_BEHIND" ]; then + echo "::warning::$TAG ($SRC) predates the tree already published ($ALL_BEHIND) — advancing projects/$PROJECT alone, leaving the shared trees where the newer release put them." + fi + # read-tree, not a copy-over: it deletes what the release removed. + # HEAD stays on live-docs, so hold() can put back what this tag does + # not speak for. + git read-tree -u --reset "$TAG" + for p in $( { git ls-tree --name-only HEAD; git ls-tree --name-only "$TAG"; } | sort -u ); do + if [ "$p" != projects ]; then + if [ -n "$ALL_BEHIND" ]; then hold "$p"; fi + continue + fi + for d in $( { git ls-tree --name-only HEAD projects/ + git ls-tree --name-only "$TAG" projects/; } | sort -u ); do + case "$d" in + "projects/$PROJECT") ;; + projects/start-docs) + if [ "$HAS_BOOK" = 0 ] || [ -n "$ALL_BEHIND" ]; then hold "$d"; fi ;; + *) hold "$d" ;; + esac + done + done fi - git add -A "projects/$PROJECT/docs" projects/start-docs + git add -A if git diff --cached --quiet; then echo "::notice::live-docs already matches $TAG." exit 0 @@ -124,6 +170,6 @@ jobs: # rule), so docs-backport.yml will see it. The content came from master # already, so it must not be bounced back. Source-Commit is what the # next run's rollback guard reads. - git commit -m "docs: publish $PROJECT docs from $TAG [skip-backport]" \ + git commit -m "chore: publish $PROJECT from $TAG [skip-backport]" \ -m "Source-Commit: $SRC" git push origin HEAD:live-docs diff --git a/.github/workflows/live-docs-guard.yml b/.github/workflows/live-docs-guard.yml new file mode 100644 index 000000000..a9badb852 --- /dev/null +++ b/.github/workflows/live-docs-guard.yml @@ -0,0 +1,57 @@ +name: live-docs guard + +# live-docs carries published code as well as published books — docs-sync-on-tag.yml +# advances its whole tree when a product is released. That sync is the only thing +# allowed to write the code: a pull request here fixes an already-published page and +# nothing else. +# +# The stakes are not confined to this branch. docs-backport.yml pushes whatever merges +# into live-docs onto master unattended, so a PR that touched code would land a stale +# tree on master with nobody reviewing it as a code change. +on: + # `edited` covers a retarget: the branch filter sees the new base, so a PR + # moved onto live-docs is checked without waiting for its next push. + pull_request: + branches: ['live-docs'] + types: [opened, synchronize, reopened, edited] + +permissions: + contents: read + +jobs: + docs-only: + name: Docs-only + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Reject changes outside the published books + env: + BASE: ${{ github.event.pull_request.base.sha }} + HEAD: ${{ github.event.pull_request.head.sha }} + run: | + set -euo pipefail + + changed=$(git diff --name-only "$BASE...$HEAD") + + # A rule, not a mirror of docs-deploy.yml's paths: a new product's book + # matches the moment it exists, with nothing to keep in step. + rejected=$(printf '%s\n' "$changed" \ + | grep -Ev '^projects/start-docs/|^projects/[^/]+/docs/' || true) + + # The one thing under docs/ that is not a book: the template + # `s9pk init-package` scaffolds from. Its SDK pin is checked against the + # release being cut, and an edit landing here would never face that check. + rejected="$rejected + $(printf '%s\n' "$changed" \ + | grep -E '^projects/start-sdk/docs/package-template/' || true)" + rejected=$(printf '%s\n' "$rejected" | sed 's/^ *//; /^$/d') + + if [ -n "$rejected" ]; then + echo "::error::A live-docs pull request may only change published book files. Everything else on this branch is written by docs-sync-on-tag.yml when a product is released — send the files below to master instead." + printf ' %s\n' $rejected + exit 1 + fi + echo "Docs-only: every changed file is a published book file." diff --git a/AGENTS.md b/AGENTS.md index 5bd7f5686..9508d93d5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -52,13 +52,14 @@ Each product lives under `projects/` as a thin wrapper; the bulk of the code liv - **The debs promote out of `alpha` too.** `release start-cli` / `start-tunnel` / `start-registry` stage from the `alpha` suite (`pull-alpha`), not from a CI run, so the packages testers have been running are the ones that reach `stable`. The whole chain is verified against the suite's signature: `InRelease` against [`apt/start9-alpha.gpg`](apt/start9-alpha.gpg), `Packages` against the hash the signed Release commits to, and each `.deb` against the hash that index commits to. Nothing about the promotion trusts plain HTTPS — the pool key is stable across builds, so an unverified download could be swapped by a republish mid-promotion. It also fetches from the **S3 origin, not the `*.cdn.*` host** in `apt/*.list`, and sends no-cache headers: a cached `InRelease` is still a validly signed one, so every check would pass while promoting whatever build the edge was holding. Signatures prove authenticity, not freshness — don't align those two URLs. That leaves replayed signed metadata theoretically promotable, and the answer to that is **not** a `Valid-Until` on the suite: expiring a rolling channel on a timer unrelated to release cadence breaks `apt update` for everyone during any quiet period. What actually guards it is that the operator is shown the commit being tagged, where a replay appears as an unexpectedly old hash. - **A deb release tags the commit alpha built, which is often not `HEAD`.** The build is identified by the `Git-Hash` control field [`debian/build.sh`](debian/build.sh) writes (`Version` cannot identify it — every master build of a version publishes under the same one). Each product's workflow is **path-filtered**, so master advances on changes elsewhere without rebuilding that product, and `alpha` legitimately holds a build of an older commit while no build of `HEAD` exists or ever will. Rather than demanding one, `pull-alpha` **adopts** alpha's commit and tags there — the tag has to point at the commit that produced the artifact. It says so on the way past, and prints the `git checkout` to put a tree on that commit; `./scripts/manage-release.sh alpha-commit ` prints the same hash for scripting. Adoption is confined to a commit already in the current branch's history, and an explicit `COMMIT=` is never overridden — it fails instead, because that is someone asserting a different intent. start-cli's per-triple binaries are the one thing that cannot be promoted — they are only ever GitHub release assets, so that half still needs the run. To put a CI build on a server without building locally: `make start-os-update-from-gha REMOTE=start9@` (latest master build) or `RUN_ID=` for a specific run. -- **`live-docs` is the published documentation and is not a development branch.** docs.start9.com serves it; master's `docs/` books describe the version that has not shipped yet. Never merge a docs change into `live-docs` that isn't already true of the _published_ software. +- **`live-docs` is what every product has published, and is not a development branch.** docs.start9.com serves it, and `start-cli s9pk init-workspace` clones it — a packaging workspace reads its guide, package template, and SDK source from that branch, so what is on it must be true of shipped software. master's `docs/` books describe the version that has not shipped yet. Never merge a docs change into `live-docs` that isn't already true of the _published_ software. +- **A pull request to `live-docs` may only change published book files.** The code on that branch is written by [`docs-sync-on-tag.yml`](.github/workflows/docs-sync-on-tag.yml) alone, on a release — a PR that carried code would put a tree there that no release produced, and [`docs-backport.yml`](.github/workflows/docs-backport.yml) would then push it onto master unattended, unreviewed as a code change. [`live-docs-guard.yml`](.github/workflows/live-docs-guard.yml) fails any PR touching a path outside `projects//docs/` and `projects/start-docs/`, and rejects `projects/start-sdk/docs/package-template/` as well. That lives under `docs/` but is the code `s9pk init-package` scaffolds from, read out of the workspace's checkout at scaffold time rather than built into `start-cli` — so a merge there changes what every new package is built from at once, without facing the `manage-release.sh pre-check start-sdk` gate on its SDK pin. - **A book change ships with its code on `master`; a book-only fix goes to `live-docs`.** This routing governs the **published books only** — `projects//docs/**` and `projects/start-docs/**`, the paths [`docs-deploy.yml`](.github/workflows/docs-deploy.yml) triggers on. Every other markdown in the repo — `AGENTS.md`, `ARCHITECTURE.md`, `CONTRIBUTING.md`, `README.md`, at any scope — is ordinary repo content that nothing publishes, and lands on master like code. For the books, the test is whether the change accompanies code. A book edit describing a change you are making belongs in the same PR as that change, on master, and reaches the site when that product is tagged. A change that only corrects a book — stale text, a dead link, a wrong path, anything already wrong on the published site — belongs on `live-docs`: it deploys on merge, and [`docs-backport.yml`](.github/workflows/docs-backport.yml) then pushes the same commit to master unattended. A book-only PR opened against master instead sits unpublished until the next tag. Where part of such a change touches book text that exists **only** on master (a section describing unshipped behavior), split that part into its own master PR — never write the same fix in both places, because one of the two will conflict and the conflict falls to a human. - **A green `Conflict Markers` check does not mean a backport is clean.** Where git cannot merge a file line by line — a page deleted on one side, an image changed on both — the backport keeps one side and leaves no marker, so compare the PR against `live-docs` before merging it. ## Releases -- **Cutting `/v` publishes that product's book.** [`.github/workflows/docs-sync-on-tag.yml`](.github/workflows/docs-sync-on-tag.yml) copies the tagged tree's `projects//docs/` — plus the shared `projects/start-docs/` site infrastructure, `versions.conf` included — onto `live-docs` and redeploys. So `versions.conf` must already name the version you are about to publish **before** you tag; it is hand-maintained and the sync will not fix it for you. Projects with no `docs/` subtree (start-cli, start-registry) are skipped. +- **Cutting `/v` is what advances `live-docs` for that product.** [`.github/workflows/docs-sync-on-tag.yml`](.github/workflows/docs-sync-on-tag.yml) takes the tagged tree **whole**, then puts every _other_ `projects/*` back on the release it is on. So a tag moves its own project, the shared libraries beneath it (`shared-libs/`), and the repo root — and nothing another product owns. The shared `projects/start-docs/` site tree rides along only with a product that ships a book, `versions.conf` included, so `versions.conf` must already name the version you are about to publish **before** you tag; it is hand-maintained and the sync will not fix it for you. A bookless release (start-cli, start-registry) still advances its own project and the shared tree; it just leaves the site alone, so no deploy fires. Nothing moves backwards: a tag behind a release already synced — a deb release adopts the commit alpha built, often older than a tag cut before it — still advances its own project but leaves the shared trees where the newer release put them, and a tag behind its own product's last release publishes its book alone. - **Cut every release with [`scripts/manage-release.sh`](scripts/manage-release.sh)** — `./scripts/manage-release.sh release ` (`start-os`, `start-cli`, `start-tunnel`, `start-registry`, `start-sdk`, `start-wrt`); `--help` lists the individual subcommands and env vars. A product's version is read from its manifest (`Cargo.toml`, or `package.json` for the SDK), and its git tag / GitHub release is `/v`. **StartOS is the exception:** its version carries a revision segment SemVer cannot express (`0.4.0.1`), so the **root `package.json`** holds it and `projects/start-os/Cargo.toml` carries only a `0.4.0-rev.1` label — never a comparand, since a SemVer prerelease sorts _below_ its release. Read it via `build/env/version.sh`; see [`shared-libs/crates/start-core/VERSION_BUMP.md`](shared-libs/crates/start-core/VERSION_BUMP.md). - **Never invoke a product's publish step directly** (`make publish`, an upload, a registry index). The pipelines differ per product — npm for the SDK, apt + GitHub release for the debs, S3 + registry promotion for the OS and StartWRT — but all of them run the **idempotent steps (tag, GitHub release) _before_ the irreversible one**. Skip the pipeline and you strand a released version with no tag and no GitHub release, which for npm cannot be undone (`pre-check` then refuses the version and npm won't republish it). This is exactly how start-sdk 2.0.4 and 2.0.5 shipped, and they had to be backfilled. Reach for individual subcommands only to repair a partial release. - **Release from a merged, up-to-date `master`.** The tag is a claim that a commit on `master` produced the artifact, so cut it where that's true. Nothing enforces this — publishing out of band from an unmerged branch is deliberately still possible, and sometimes the right call — but it is a **debt, not a shortcut**: the commit you published from will be squashed or orphaned when the branch merges, leaving the tag nowhere honest to point. If you take it, you owe the follow-up in the same sitting — merge the branch, then tag and release at the resulting `master` commit, having checked that its shipped subtree still matches the artifact you published. start-sdk 2.0.5 went out this way and had to be reconstructed after the fact. @@ -136,7 +137,7 @@ What `### Environment` carries, by project: Some pairs of files mirror each other by hand — nothing enforces them, so a change to one half is incomplete until you update the other. Update both in the **same** commit: - **A product's CI `paths:` filter ↔ its `build.mk` prerequisites.** Each `.github/workflows/.yaml` only triggers on the paths that product's build actually depends on. Those `paths:` allowlists are a hand-maintained mirror of the prerequisites in `projects//build.mk` (the project dir, `shared-libs/**` or the specific crates it pulls in, `Cargo.*`, `build/**`, `debian/**`, the web config for products with a UI, …). When you add or drop a build input in a `build.mk`, update that product's workflow `paths:` (both the `push:` and `pull_request:` blocks) — otherwise CI will silently stop running on changes that affect the build. Affected pairs: `start-cli`, `start-registry`, `start-tunnel`, `start-wrt`, `startos-iso`. Additionally, `startos-iso.yaml`'s `changes` job carries a finer mirror: on PRs it gates the expensive **image** matrix on a regex of image-_assembly_ paths (packaging, image-recipe, systemd units, `apt/**`, shared `build/**`) — the inputs the image target pulls in _beyond_ the compiled binary. When you change what feeds the image target in `projects/start-os/build.mk` (vs. the binary, which the `compile` job always covers), update that regex too, or image-affecting PRs will skip image validation. -- **Pull request retargets run through `.github/workflows/pr-retarget.yml`.** A base change is an `edited` event, but trigger-level `branches:` and `paths:` filters evaluate the new base and diff before a workflow starts. The unfiltered listener therefore calls the explicit base-sensitive set: `test`, `start-cli`, `start-registry`, `start-tunnel`, `start-wrt`, and `startos-iso`; title and body edits finish in its classifier without replacing existing check conclusions. Keep each called target reusable through `workflow_call`, give its concurrency group a workflow-specific literal prefix, and leave `edited` out of its direct `pull_request:` types. `conflict-markers` remains a direct unfiltered `pull_request` listener and rescans every edit, because its `Conflict Markers` job must itself replace any stale verdict after a retarget. Grant called jobs their minimum permissions: `packages: write` belongs only on the `start-registry` call and remains confined inside it to `create-image`; its compile job explicitly downgrades to `contents: read`. `.github/workflows/pr-retarget-conflict.yml` reports GitHub's native conflict verdict through its PR-numbered `Mergeability` job; the `pull_request_target` run itself supplies the pending and final check result without writing a commit status. Base changes use mergeability-specific concurrency and poll until GitHub resolves mergeability; other edits finish under a PR-numbered `Metadata edit` job with separate concurrency. When a base-change event's head or base SHA is stale, it cancels its own run so the obsolete check finishes neutral. It never replaces the separate `Conflict Markers` check, whose real head/base scan clears stale marker failures after a clean retarget. The privileged listener must never check out or execute pull request code. `.github/scripts/check-pr-retarget-workflows.py` enforces independent audited target sets, permissions, concurrency, event routing, and conflict-listener isolation. +- **Pull request retargets run through `.github/workflows/pr-retarget.yml`.** A base change is an `edited` event, but trigger-level `branches:` and `paths:` filters evaluate the new base and diff before a workflow starts. The unfiltered listener therefore calls the explicit base-sensitive set: `test`, `start-cli`, `start-registry`, `start-tunnel`, `start-wrt`, and `startos-iso`; title and body edits finish in its classifier without replacing existing check conclusions. Keep each called target reusable through `workflow_call`, give its concurrency group a workflow-specific literal prefix, and leave `edited` out of its direct `pull_request:` types. `conflict-markers` remains a direct unfiltered `pull_request` listener and rescans every edit, because its `Conflict Markers` job must itself replace any stale verdict after a retarget. `live-docs-guard` is the other direct listener: it keeps its `live-docs` branch filter but takes `edited` too, since the filter sees the new base and a PR retargeted onto `live-docs` must be checked before its next push. Grant called jobs their minimum permissions: `packages: write` belongs only on the `start-registry` call and remains confined inside it to `create-image`; its compile job explicitly downgrades to `contents: read`. `.github/workflows/pr-retarget-conflict.yml` reports GitHub's native conflict verdict through its PR-numbered `Mergeability` job; the `pull_request_target` run itself supplies the pending and final check result without writing a commit status. Base changes use mergeability-specific concurrency and poll until GitHub resolves mergeability; other edits finish under a PR-numbered `Metadata edit` job with separate concurrency. When a base-change event's head or base SHA is stale, it cancels its own run so the obsolete check finishes neutral. It never replaces the separate `Conflict Markers` check, whose real head/base scan clears stale marker failures after a clean retarget. The privileged listener must never check out or execute pull request code. `.github/scripts/check-pr-retarget-workflows.py` enforces independent audited target sets, permissions, concurrency, event routing, and conflict-listener isolation. - **start-wrt's CI publish constants ↔ `scripts/manage-release.sh`'s wrt config.** The `deploy` job in `.github/workflows/start-wrt.yaml` registers builds into the beta registry with values (registry URL, S3 CDN, platform, compat floor) and register/index commands that hand-mirror `manage-release.sh`'s `STARTWRT_*` vars and `cmd_register` (the manual fallback). Change one side, change the other. - **`startos-iso.yaml`'s `PRUNE_REGISTRIES` ↔ every registry that can reference `s3://startos-images`.** The deploy job's `Prune superseded images` step reclaims an image once it is `MIN_AGE_DAYS` old, its version is still published by some registry in that list, and no registry in that list references its commit — so a registry left out of it is a registry whose images get deleted, for every version another registry still publishes. It is deliberately wider than the job's own `REGISTRY` map — production is in it even though this workflow never deploys there, because `os promote` copies asset URLs through verbatim and production ends up pointing at objects alpha has stopped referencing. Stand up a new channel, or point a registry at this bucket by hand, and it belongs in that list before the next deploy runs. - **The reusable service-package CI ↔ the SDK package-template ↔ the packaging docs.** `.github/workflows/{build,release,syncNext,tagAndRelease}.yml` (the `workflow_call` CI that external `*-startos` service repos consume) are mirrored by the copies under `projects/start-sdk/docs/package-template/.github/workflows/` and the examples in `projects/start-sdk/docs/src/project-structure.md`. Change the reusable-workflow surface (inputs, action names, file layout) in all three. diff --git a/projects/start-cli/CHANGELOG.md b/projects/start-cli/CHANGELOG.md index b518de0a4..bc1ecf82c 100644 --- a/projects/start-cli/CHANGELOG.md +++ b/projects/start-cli/CHANGELOG.md @@ -22,6 +22,12 @@ or the CLI's externally observable behavior. archive and the accessor returns `None`. Nothing is packed for an s9pk built before this, and v1 packages migrated forward carry no README either. +- **An `s9pk` command says when `start-cli` is behind the published release.** It compares + itself against the `start-cli` version named by the workspace's `start-technologies` + checkout and prints a one-line notice. `start-cli` installs outside the workspace, so + nothing else would have told you: on Debian `apt upgrade` carries it forward, and + everywhere else re-running the installer is the only update path. + ### Changed - **`server set-hostname` takes one required hostname, and `setup execute` no @@ -33,6 +39,11 @@ or the CLI's externally observable behavior. reject a name longer than 32 characters, or one starting or ending with a hyphen, which 1.1.0 accepted. +- **`s9pk init-workspace` clones the monorepo's `live-docs` branch** rather than `master`, so a + workspace's packaging guide, package template, and SDK source describe the + `@start9labs/start-sdk` its packages install, and match the pages on docs.start9.com. An + existing workspace moves over with `git -C start-technologies checkout live-docs`. + ### Fixed - **`s9pk init-workspace` no longer scaffolds a placeholder host** — `dev-vm.local` resolved diff --git a/projects/start-docs/AGENTS.md b/projects/start-docs/AGENTS.md index 10b8cce66..2da14cf5a 100644 --- a/projects/start-docs/AGENTS.md +++ b/projects/start-docs/AGENTS.md @@ -49,7 +49,7 @@ The StartOS, StartTunnel, Packaging, and StartWRT books are NOT here — they mo Content reaches `live-docs` two ways: -- **On a tag.** `docs-sync-on-tag.yml` copies the tagged tree's `projects//docs/` **and all of `projects/start-docs/`** onto `live-docs`, then dispatches the deploy. This is how a book — and any change you make in this project, including `versions.conf`, `build.sh`, and `theme/` — actually goes live. Work here therefore ships on someone else's release: if a site change needs to go out now, PR it to `live-docs` as below. +- **On a tag.** `docs-sync-on-tag.yml` advances `live-docs` to the tagged tree for the released project, the shared libraries beneath it, and the repo root — **all of `projects/start-docs/`** included, whenever the released product ships a book — then dispatches the deploy. Every other `projects/*` stays on its own release, and a tag behind a release already synced leaves this project, `shared-libs/` and the root where the newer release put them. This is how a book — and any change you make in this project, including `versions.conf`, `build.sh`, and `theme/` — actually goes live. Work here therefore ships on someone else's release: if a site change needs to go out now, PR it to `live-docs` as below. - **By PR into `live-docs`.** For fixing what is already published. It deploys on merge and is then pushed back to master automatically (`docs-backport.yml`), so don't also write the fix in master. Because a tag sync overwrites this whole project from the tagged tree, never hand-edit `projects/start-docs/**` on `live-docs` expecting it to survive — land it in master too (the backport does this for you). diff --git a/projects/start-os/docs/src/cli-reference.md b/projects/start-os/docs/src/cli-reference.md index 2b1e72b6e..acd2a55e8 100644 --- a/projects/start-os/docs/src/cli-reference.md +++ b/projects/start-os/docs/src/cli-reference.md @@ -727,7 +727,7 @@ Build, inspect, edit, and publish service packages. ### `start-cli s9pk init-workspace [PATH]` -Initialize a StartOS packaging workspace in PATH (default: the current directory). Clones the packaging guide, writes the agent-context files (`AGENTS.md`, `AGENTS.local.md`, `CLAUDE.md`), and creates a `.startos/` directory holding the workspace signing key and host/registry config. Nesting is allowed; it refuses to run inside a package repo. See [Set Up Your Packaging Workspace](/packaging/environment-setup.html#set-up-your-packaging-workspace). +Initialize a StartOS packaging workspace in PATH (default: the current directory). Clones the monorepo's `live-docs` branch — what every product has published — writes the agent-context files (`AGENTS.md`, `AGENTS.local.md`, `CLAUDE.md`), and creates a `.startos/` directory holding the workspace signing key and host/registry config. Nesting is allowed; it refuses to run inside a package repo. See [Set Up Your Packaging Workspace](/packaging/environment-setup.html#set-up-your-packaging-workspace). ### `start-cli s9pk init-package ` diff --git a/projects/start-sdk/AGENTS.md b/projects/start-sdk/AGENTS.md index 814173915..f454fc28d 100644 --- a/projects/start-sdk/AGENTS.md +++ b/projects/start-sdk/AGENTS.md @@ -101,7 +101,7 @@ If that commit never landed on `master` (e.g. the publish was cut from an unmerg Authoring conventions shared by every book in the monorepo — mdBook/mdbook-tabs versions, admonitions, tabs, `SUMMARY.md`, the shared `theme/` symlink, cross-book links — live in [`projects/start-docs/AGENTS.md`](../start-docs/AGENTS.md) and its `CONTRIBUTING.md`. Read those before editing pages. What is specific to _this_ book: -- **`docs/src/agent-context.md` ships to every packager.** `start-cli s9pk init-workspace` symlinks it in as each workspace's `AGENTS.md` (the `AGENTS_SYMLINK_TARGET` const in [`shared-libs/crates/start-core/src/s9pk/init.rs`](../../shared-libs/crates/start-core/src/s9pk/init.rs)), so an edit reaches every workspace on its next guide sync. It is an always-on context file first and a book page second: keep it a lean map that points at pages, never a place to inline detail. **Moving or renaming it breaks every existing workspace symlink** — update the const in the same change. +- **`docs/src/agent-context.md` ships to every packager.** `start-cli s9pk init-workspace` symlinks it in as each workspace's `AGENTS.md` (the `AGENTS_SYMLINK_TARGET` const in [`shared-libs/crates/start-core/src/s9pk/init.rs`](../../shared-libs/crates/start-core/src/s9pk/init.rs)). A workspace tracks `live-docs`, so an edit reaches packagers on their next sync once it is on that branch — immediately for a fix PR'd to `live-docs`, at the next SDK release for one that lands on master. It is an always-on context file first and a book page second: keep it a lean map that points at pages, never a place to inline detail. **Moving or renaming it breaks every existing workspace symlink** — update the const in the same change. - **`docs/package-template/` is live code, not an illustration.** `s9pk init-package` copies it verbatim, interpolating `{{id}}` and `{{name}}` (escaped for TypeScript string literals in `.ts` files) and skipping `node_modules/`, `.git/`, and `javascript/`. Keep it buildable; a broken template breaks every new package. Its `.github/workflows/` are a hand-maintained mirror — see the repo-root `AGENTS.md` § Coupled changes. - **Recipes name constructs; reference pages teach them.** Code examples belong on reference pages. A new `recipe-*.md` needs an entry in the intent table in `docs/src/recipes.md`, not just in `SUMMARY.md`. - **Verify SDK claims against `lib/`, not against the prose.** This guide has shipped confidently-worded semantics that were wrong. Before documenting what a call does, read it. diff --git a/projects/start-sdk/docs/src/agent-context.md b/projects/start-sdk/docs/src/agent-context.md index 0e6455cfc..3cc719c16 100644 --- a/projects/start-sdk/docs/src/agent-context.md +++ b/projects/start-sdk/docs/src/agent-context.md @@ -13,7 +13,7 @@ You are an AI assistant working in a **StartOS packaging workspace**. You help c ├── AGENTS.md ← this file (symlink → start-technologies/projects/start-sdk/docs/src/agent-context.md) ├── AGENTS.local.md ← your workspace-specific notes (never overwritten by a sync) ├── CLAUDE.md ← loads AGENTS.md + AGENTS.local.md (Claude Code bridge) -├── start-technologies/ ← checkout of the Start9 monorepo: the packaging guide, plus the SDK and OS source +├── start-technologies/ ← checkout of the Start9 monorepo on `live-docs` (what is published): the packaging guide, plus the SDK and OS source └── -startos/ … ← one or more package repos ``` @@ -29,11 +29,15 @@ The guide, the package template, and this file all live in `start-technologies/` git -C start-technologies pull --ff-only ``` +**The checkout is on `live-docs`, the branch that carries what every product has published — never `master`.** That is what keeps the guide, the template, and the SDK source describing the `@start9labs/start-sdk` a package installs; `master` carries what hasn't shipped, where a page can document a call npm cannot resolve. It is also the branch docs.start9.com serves, so the pages on disk are the published ones. Don't move the checkout to `master` to see something newer — what is newer there is not what your package builds against. + +`start-cli` is installed outside the workspace, so the sync does not touch it. When an `s9pk` command warns that yours is behind the published release, update it before going further — the guide on disk describes the newer one. + To track a different source (e.g. a fork), repoint `start-technologies`'s remote first — the sync follows whatever remote is configured. Keep workspace-specific notes in `AGENTS.local.md`; a sync never touches it. That file is for what is true of _your_ setup — your box, your registry, your packages, any departure from the scaffolded layout. Anything that would help **every** packager belongs in the guide instead: open a PR against `start-technologies` rather than letting it drift in one workspace. -If `start-technologies/` is a **symlink** to a checkout maintained outside this workspace, skip the sync: that repo has its own branches and its own work in progress, so its state is the owner's to manage, not this workspace's. +If `start-technologies/` is a **symlink** to a checkout maintained outside this workspace, skip the sync — its branches are the owner's to manage, not this workspace's. Say so rather than pulling it: a development checkout sits on `master`, so everything read through it is ahead of what packages install. The workspace wants its own (remove the symlink and re-run `start-cli s9pk init-workspace`). ## How to use the guide (local-first) @@ -80,7 +84,7 @@ Read pages from your local checkout (`start-technologies/projects/start-sdk/docs Reach for them **only when the recipes, reference pages, real packages, and the installed SDK types (`node_modules/@start9labs/start-sdk`) don't answer the question** — e.g. to confirm exactly what an SDK call does, or how an OS effect behaves. Open one file to settle one question; don't browse the monorepo to "understand the system." -If what you find there is a bug, say so. You are standing in a git repo you can branch from and open a pull request against. +If what you find there is a bug, say so. You are standing in a git repo you can open a pull request against — branch from `origin/master`, not from the `live-docs` checkout, and switch back to `live-docs` when you're done. ## Key patterns diff --git a/projects/start-sdk/docs/src/environment-setup.md b/projects/start-sdk/docs/src/environment-setup.md index 2043e5501..4d0ec577d 100644 --- a/projects/start-sdk/docs/src/environment-setup.md +++ b/projects/start-sdk/docs/src/environment-setup.md @@ -141,7 +141,9 @@ Install using the automated installer script: curl -fsSL https://start9.com/start-cli/install.sh | sh ``` -On Debian and its derivatives — Ubuntu, Raspberry Pi OS, Linux Mint — the script adds the Start9 apt repository and installs the `start-cli` package to `/usr/bin`, so `sudo apt update && sudo apt upgrade` picks up later releases. On macOS and every other Linux distribution it downloads the release binary into `~/.local/bin` and adds that directory to your `PATH`. +On Debian and its derivatives — Ubuntu, Raspberry Pi OS, Linux Mint — the script adds the Start9 apt repository and installs the `start-cli` package to `/usr/bin`, so `sudo apt update && sudo apt upgrade` picks up later releases. On macOS and every other Linux distribution it downloads the release binary into `~/.local/bin` and adds that directory to your `PATH`; re-running the same command is how you update it there. + +`start-cli` installs outside your workspace, so [Keep it current](#keep-it-current) does not touch it. You don't have to track it yourself either: an `s9pk` command run inside a workspace whose checkout names a newer release prints a one-line notice saying so. ## Git @@ -233,22 +235,15 @@ start9-workspace/ You get the **whole** monorepo, not just the guide. That's deliberate: when the guide can't settle a question, the SDK source (`projects/start-sdk/lib`) and the StartOS source (`projects/start-os`, `shared-libs/`) are right there to read — and if you find a bug, you're already in a repo you can open a pull request from. The clone is `--filter=blob:none`, so file contents are fetched on demand: it lands in a few seconds and takes ~75 MB, while `git log`, `git blame`, and rebase all behave normally. +The checkout tracks **`live-docs`**, not `master`. That branch is what every product has published: each release moves it to the tagged tree for the product being released, so the guide you read, the template `init-package` scaffolds from, and the SDK source all describe the `@start9labs/start-sdk` that `npm install` resolves. `master` carries what hasn't shipped, where a page can document a call your package cannot import. It is also the branch docs.start9.com serves, and corrections to published pages land there first — so your local copy and the site are the same thing, and you get a fix the moment it goes live. + The context lives once, at the workspace root — it is never copied into your package repos. Open the workspace in your AI tool and it picks up `AGENTS.md` / `CLAUDE.md` automatically. You can read exactly what it contains on the [Agent Context](./agent-context.md) page. ### Already have the monorepo? -If you already keep a `start-technologies` checkout — you work on StartOS itself, or you've cloned it for another reason — don't let the workspace clone a second copy. Point at the one you have **before** running `init-workspace`, and it will use it: +Let the workspace clone its own anyway. A checkout you develop in sits on `master` and moves with your branches; the workspace's sits on `live-docs` and is only ever fast-forwarded — one checkout cannot be both, and pointing the workspace at yours would put the guide, the template, and the SDK source ahead of what your packages can install. The second copy is blobless, so it costs ~75 MB. -```sh -mkdir start9-workspace && cd start9-workspace -ln -s /path/to/your/start-technologies start-technologies -start-cli s9pk init-workspace . -``` - -`init-workspace` skips the clone whenever `start-technologies` already resolves to a directory, so the symlink is left alone and everything else is provisioned around it. The workspace `AGENTS.md` links through it, and `s9pk init-package` scaffolds from its package template, exactly as with a fresh clone. - -> [!IMPORTANT] -> A symlinked checkout is **yours to maintain**. Skip the `git pull` in [Keep it current](#keep-it-current): that repo has its own branches and its own work in progress, and a blind pull would fast-forward whatever branch happens to be checked out rather than refresh the guide. Update it on your own schedule instead. +To open a pull request against the monorepo, use your development checkout. Without one, branch the workspace's from `origin/master` and switch it back to `live-docs` when you're done. ### Nested workspaces and config resolution @@ -304,12 +299,14 @@ With no flag, the `default` entry is used. `start-cli` finds this config by walk ### Keep it current -The guide, the package template, and the agent context all live in `start-technologies/`, so syncing it refreshes everything at once. Pull it at the start of each session: +The guide, the package template, the agent context, and the SDK source all live in `start-technologies/`, so syncing it refreshes everything at once. Pull it at the start of each session: ```sh git -C start-technologies pull --ff-only ``` +`live-docs` only ever moves forward, so this is always a fast-forward. It brings in two things: corrections to already-published pages, as soon as they go live on docs.start9.com, and — when a product is released — that product's whole tree at the release. + There's no separate update command — re-running `init-workspace` on an existing workspace just fills in anything missing, and your `AGENTS.local.md` is never touched. Your environment is ready. Continue to [Quick Start](./quick-start.md) to scaffold and build your first package inside the workspace. diff --git a/projects/start-sdk/docs/src/workflow.md b/projects/start-sdk/docs/src/workflow.md index d1f9886ba..8743a47b3 100644 --- a/projects/start-sdk/docs/src/workflow.md +++ b/projects/start-sdk/docs/src/workflow.md @@ -101,7 +101,7 @@ Your workspace's `start-technologies/` is a checkout of the whole Start9 monorep This is a **last resort, not a starting point.** Drop into the source only to answer a specific question those layers can't — exactly what an SDK call does, how an OS effect behaves — and read the one file that settles it instead of browsing. -When the answer turns out to be a bug rather than a misunderstanding, fix it there: that checkout is a full git repo, so you can branch, commit, and open a pull request without leaving the workspace. +When the answer turns out to be a bug rather than a misunderstanding, fix it there: that checkout is a full git repo, so you can branch, commit, and open a pull request without leaving the workspace. Branch from `origin/master` — the checkout itself sits on `live-docs`, which carries what is published — and switch it back when you're done. ## Don't create unnecessary version files diff --git a/shared-libs/crates/start-core/locales/i18n.yaml b/shared-libs/crates/start-core/locales/i18n.yaml index 2184baff0..d785fd8ca 100644 --- a/shared-libs/crates/start-core/locales/i18n.yaml +++ b/shared-libs/crates/start-core/locales/i18n.yaml @@ -239,6 +239,13 @@ s9pk.init.no-workspace-in-package-repo: fr_FR: "Aucun espace de travail d'empaquetage trouvé. %{repo} est un dépôt de paquet ; un espace de travail est le répertoire qui *contient* vos dépôts de paquets (et fournit le guide d'empaquetage pour l'IA). Créez-en un dans son répertoire parent, puis réessayez : `cd %{parent} && start-cli s9pk init-workspace`. Guide de configuration : %{docs}" pl_PL: "Nie znaleziono obszaru roboczego pakowania. %{repo} to repozytorium pakietu; obszar roboczy to katalog, który *zawiera* twoje repozytoria pakietów (i udostępnia przewodnik pakowania dla AI). Utwórz jeden w jego katalogu nadrzędnym, a następnie spróbuj ponownie: `cd %{parent} && start-cli s9pk init-workspace`. Przewodnik konfiguracji: %{docs}" +s9pk.init.start-cli-outdated: + en_US: "start-cli %{running} is behind the published %{published}. Update it with `apt upgrade` on Debian, or by re-running `curl -fsSL https://start9.com/start-cli/install.sh | sh`." + de_DE: "start-cli %{running} liegt hinter der veröffentlichten Version %{published}. Aktualisiere es unter Debian mit `apt upgrade`, sonst durch erneutes Ausführen von `curl -fsSL https://start9.com/start-cli/install.sh | sh`." + es_ES: "start-cli %{running} está por detrás de la versión publicada %{published}. Actualízalo con `apt upgrade` en Debian, o volviendo a ejecutar `curl -fsSL https://start9.com/start-cli/install.sh | sh`." + fr_FR: "start-cli %{running} est en retard sur la version publiée %{published}. Mettez-le à jour avec `apt upgrade` sous Debian, ou en relançant `curl -fsSL https://start9.com/start-cli/install.sh | sh`." + pl_PL: "start-cli %{running} jest starszy niż opublikowana wersja %{published}. Zaktualizuj go poleceniem `apt upgrade` w Debianie lub uruchamiając ponownie `curl -fsSL https://start9.com/start-cli/install.sh | sh`." + # s9pk/v2/pack.rs s9pk.pack.git-hash-omitted: en_US: "No git commit found in %{path} — building without a commit hash in the manifest. Commit your work (or check that git is set up) to include it." diff --git a/shared-libs/crates/start-core/src/context/cli.rs b/shared-libs/crates/start-core/src/context/cli.rs index 6c060a26c..7f5f9b9e1 100644 --- a/shared-libs/crates/start-core/src/context/cli.rs +++ b/shared-libs/crates/start-core/src/context/cli.rs @@ -223,7 +223,10 @@ impl CliContext { // silently stepping past it (`exists()`) or surfacing the error // (`try_exists()?`). match candidate.try_exists() { - Ok(true) => return load_signing_key(candidate), + Ok(true) => { + crate::s9pk::init::warn_if_start_cli_outdated(&dir); + return load_signing_key(candidate); + } Ok(false) => {} Err(_) => break, } diff --git a/shared-libs/crates/start-core/src/s9pk/init.rs b/shared-libs/crates/start-core/src/s9pk/init.rs index 4c677c4a2..fb8f6dd07 100644 --- a/shared-libs/crates/start-core/src/s9pk/init.rs +++ b/shared-libs/crates/start-core/src/s9pk/init.rs @@ -55,6 +55,10 @@ const DOCS_URL: &str = "https://docs.start9.com/packaging/environment-setup.html const MONOREPO_URL: &str = "https://github.com/Start9Labs/start-technologies.git"; /// Workspace-relative path to the monorepo checkout that carries the guide. const MONOREPO_DIR: &str = "start-technologies"; +/// Branch the workspace tracks: what every product has published. master carries the +/// SDK that has not shipped, whose guide and template describe a version npm cannot +/// resolve. +const MONOREPO_BRANCH: &str = "live-docs"; /// Symlink target for the workspace `AGENTS.md` — the guide's canonical copy, so /// a sync keeps the workspace context current with no extra step. It is also a page /// of the published guide, so packagers can read it without scaffolding a workspace. @@ -66,6 +70,9 @@ const AGENTS_SYMLINK_TARGET: &str = const LEGACY_AGENTS_SYMLINK_TARGET: &str = "start-technologies/projects/start-sdk/docs/AGENTS.md"; /// Path to the package template inside the cloned guide (joined onto MONOREPO_DIR). const TEMPLATE_SUBPATH: &str = "projects/start-sdk/docs/package-template"; +/// Manifest naming the published `start-cli` (joined onto MONOREPO_DIR). The checkout +/// tracks releases, so this is the version a packager should be running. +const CLI_MANIFEST_SUBPATH: &str = "projects/start-cli/Cargo.toml"; /// Claude Code does not auto-read `AGENTS.md`, so the workspace `CLAUDE.md` /// imports both it and the user's local prefs. @@ -137,7 +144,7 @@ pub async fn init_workspace( .arg("clone") .arg("--filter=blob:none") .arg("--branch") - .arg("master") + .arg(MONOREPO_BRANCH) .arg(MONOREPO_URL) .arg(&docs) .capture(false) @@ -234,6 +241,8 @@ pub async fn init_package( )); } + warn_if_start_cli_outdated(&root); + let template = root.join(MONOREPO_DIR).join(TEMPLATE_SUBPATH); if !template.exists() { return Err(Error::new( @@ -278,6 +287,51 @@ pub async fn init_package( Ok(()) } +/// Warn when the workspace names a newer `start-cli` than the one running. `start-cli` +/// installs outside the workspace, so nothing else would ever say so: on Debian apt +/// carries it forward, and everywhere else the installer is the only update path. +/// +/// Best-effort, and at most once per process — a stale binary is worth a line, never an +/// error, so every unreadable or unparseable case is silent. +pub fn warn_if_start_cli_outdated(workspace: &Path) { + static WARNED: std::sync::Once = std::sync::Once::new(); + WARNED.call_once(|| { + let Ok(manifest) = + std::fs::read_to_string(workspace.join(MONOREPO_DIR).join(CLI_MANIFEST_SUBPATH)) + else { + return; + }; + let published = serde_toml::from_str::(&manifest) + .ok() + .and_then(|manifest| { + semver::Version::parse( + manifest + .get("package")? + .as_table()? + .get("version")? + .as_str()?, + ) + .ok() + }); + let (Some(published), Ok(running)) = ( + published, + semver::Version::parse(crate::bins::cli_version()), + ) else { + return; + }; + if published > running { + eprintln!( + "{}", + t!( + "s9pk.init.start-cli-outdated", + running = running.to_string(), + published = published.to_string() + ) + ); + } + }); +} + /// Walk up from `start` (inclusive) for the nearest workspace — a directory whose /// `.startos` is a provisioned marker (`build.key.pem` or a schema config). `init-package` /// scaffolds into whatever this returns, so with nested workspaces it targets the