diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index b3c5383a..8da1dada 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -140,5 +140,17 @@ jobs: tests/test_rollback.py --rollback-scope full --rollback-real all --rollback-templates ${{ matrix.template }} -o faulthandler_timeout=300 --rollback-shard ${{ matrix.slice }} + --rollback-report rollback-report/report.json retry: "true" flaky-artifact: flaky-tests-rollback-${{ matrix.os }}-${{ matrix.template }}-${{ strategy.job-index }} + + # Rollback Metrics adds the reports up. A failing job reports too, so the + # badge can say how many faults failed. + - name: Upload the fault report + if: ${{ !cancelled() }} + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: ${{ matrix.artifact }} + path: rollback-report/report.json + if-no-files-found: ignore + retention-days: 7 diff --git a/.github/workflows/rollback-metrics.yml b/.github/workflows/rollback-metrics.yml new file mode 100644 index 00000000..3a42f079 --- /dev/null +++ b/.github/workflows/rollback-metrics.yml @@ -0,0 +1,127 @@ +name: Rollback Metrics + +# Kept apart from Nightly so a publishing hiccup can never fail the run a +# release requires. When a scheduled Nightly finishes, this adds up the faults +# its rollback jobs injected and records them on gh-pages beside the +# mutation score: a history file for the dashboard and a badge endpoint the +# README and jacksonferguson.me read. Only a run in which every rollback job +# reported publishes; manual runs, which may narrow the jobs, never do. +on: + workflow_run: + workflows: ["Nightly"] + types: + - completed + +permissions: + contents: read + +# Keep records in order without cancelling the work already in progress. +concurrency: + group: rollback-metrics + cancel-in-progress: false + +jobs: + record: + name: Record the rollback run + runs-on: ubuntu-latest + timeout-minutes: 10 + if: >- + github.event.workflow_run.head_branch == 'main' && + github.event.workflow_run.event == 'schedule' && + contains(fromJSON('["success", "failure"]'), github.event.workflow_run.conclusion) + permissions: + actions: read + contents: write + outputs: + published: ${{ steps.record.outputs.published }} + + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + + - name: Download every rollback report + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + pattern: rollback-* + path: results + run-id: ${{ github.event.workflow_run.id }} + github-token: ${{ github.token }} + + - name: Read the published data branch + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + ref: gh-pages + path: pages-data + fetch-depth: 0 + + - name: Append history and the latest count + id: record + env: + COMMIT: ${{ github.event.workflow_run.head_sha }} + run: | + # A night with no new commits skips its rollback jobs: nothing to record. + if [[ ! -d results ]] || [[ -z "$(ls -A results)" ]]; then + echo "No rollback reports in this run." + echo "published=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + matrix="$(python3 scripts/nightly_matrix.py)" + recorded_at="$(date -u +%Y-%m-%dT%H:%M:%S+00:00)" + git -C pages-data config user.name 'github-actions[bot]' + git -C pages-data config user.email '41898282+github-actions[bot]@users.noreply.github.com' + for attempt in 1 2 3 4 5; do + echo "Publishing rollback results (attempt $attempt/5)." + python3 scripts/rollback_report.py record \ + --results results --matrix "$matrix" --commit "$COMMIT" --date "$recorded_at" \ + --history pages-data/metrics/rollback-history.json \ + --latest pages-data/metrics/rollback-latest.json + git -C pages-data add metrics/rollback-history.json metrics/rollback-latest.json + if ! git -C pages-data diff --cached --quiet; then + git -C pages-data commit -m "Record rollback faults for ${COMMIT:0:7}" + fi + if git -C pages-data push origin HEAD:gh-pages; then + echo "published=true" >> "$GITHUB_OUTPUT" + exit 0 + fi + # Benchmarks, mutation scores, and releases also write here. Rebase + # non-conflicting edits; if history itself changed, regenerate it. + if ! git -C pages-data pull --rebase origin gh-pages; then + git -C pages-data rebase --abort + git -C pages-data reset --hard origin/gh-pages + fi + done + echo 'Could not publish rollback results after five attempts.' >&2 + exit 1 + + publish-pages: + name: Publish updated rollback metrics + needs: record + if: needs.record.outputs.published == 'true' + permissions: + contents: read + pages: write + id-token: write + uses: ./.github/workflows/pages.yml + + # jacksonferguson.me shows the latest count and fetches it only when it builds. + # Wait for the deploy, not the gh-pages push, so the build reads the new file. + refresh-site: + name: Refresh jacksonferguson.me + needs: publish-pages + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: {} + steps: + - name: Ask the site to rebuild + env: + # A fine-grained token that can only send this dispatch to the site's repository. + GH_TOKEN: ${{ secrets.PORTFOLIO_DISPATCH_TOKEN }} + run: | + if [[ -z "$GH_TOKEN" ]]; then + echo '::error::Add the PORTFOLIO_DISPATCH_TOKEN secret: a fine-grained token with Contents read and write on JacksonFergusonDev/JacksonFergusonDev.github.io only.' + exit 1 + fi + gh api repos/JacksonFergusonDev/JacksonFergusonDev.github.io/dispatches \ + --method POST \ + --field event_type=remote-assets-updated \ + --field "client_payload[asset_source]=$GITHUB_REPOSITORY" \ + --field "client_payload[ref]=refs/heads/main" diff --git a/AGENTS.md b/AGENTS.md index 07a96b63..3d91bbf1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -29,7 +29,7 @@ Execution is strictly split into two decoupled phases: - **Bytes-Over-Intent Rollback:** Rollback restores exact original bytes and POSIX file modes captured before first mutation. Created directories are removed only if empty. Symlinks and special filesystem nodes are rejected before transaction-managed mutation (`UnsupportedFilesystemNodeError`). - **Preview Trees Show Every File a Run Leaves:** `EnvironmentManifest.planned_files()` feeds the dry-run tree, the recipe editor's preview, the change review, and the dry-run JSON `entries`. It adds command outputs (a task's `owned_files`, outside `.git/`), the resolver footprint, and `protostar.lock` to the files Protostar writes. Declare every file a command creates in its task's `owned_files`: `check-snapshots` fails when a scaffold's tree differs from its dry-run plan. - **Installed Git Hooks Are Clone-Local:** `.git/hooks` is not project state. `init` installs hooks as a post-install task; `sync` converges them through `git_hooks.plan_hooks()` (install when a wanted hook is missing, remove only runner-generated hooks that can now only fail) and never counts them as pending, so `sync --check` passes in a fresh checkout. A failed install on `sync` is a diagnostic, not a rollback. -- **Every Fault Rolls Back:** `tests/test_rollback.py` fails `init` and `sync` before, mid-way through, and after every write, directory, removal, and command, with an error and an interrupt, and requires the project and home directory to come back byte for byte. The commit is a site: an interrupt after it keeps the run and claims no rollback. A restore that fails is named in `RollbackFailedError` while every other path still comes back. Its cases come from the site lists in `tests/rollback_sites/`: a change that adds or moves a site fails until they are regenerated with `--snapshot-update`, so review sees it, and a write made around `TransactionAwareFS` fails the clean-run check. Only `sync`'s hook install may fail without a rollback. Pull requests run two scenarios; nightly runs every template and seed. +- **Every Fault Rolls Back:** `tests/test_rollback.py` fails `init` and `sync` before, mid-way through, and after every write, directory, removal, and command, with an error and an interrupt, and requires the project and home directory to come back byte for byte. The commit is a site: an interrupt after it keeps the run and claims no rollback. A restore that fails is named in `RollbackFailedError` while every other path still comes back. Its cases come from the site lists in `tests/rollback_sites/`: a change that adds or moves a site fails until they are regenerated with `--snapshot-update`, so review sees it, and a write made around `TransactionAwareFS` fails the clean-run check. Only `sync`'s hook install may fail without a rollback. Pull requests run two scenarios; nightly runs every template and seed. Each fault test carries the `rollback_fault` marker, and nightly's `--rollback-report` counts them for the metrics dashboard and badge: a count is published only when every job reported, and a failure shows in the badge's text, never its color. - **Rollback Boundary:** Rollback reliably covers paths written directly through Protostar's transaction-aware filesystem layer. Subprocess side effects are strictly disclaimed unless explicitly pre-declared by a module (e.g., `git init` declaring the `.git/` tree, or `pre-commit` declaring specific hook files). Unbounded or externally managed state (like `.venv/` or global caches) falls outside the transacted boundary. ### 2. The Headless Core diff --git a/README.md b/README.md index 633180dd..b6c6c05d 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,7 @@ [](https://github.com/jacksonfergusondev/protostar/actions/workflows/release.yml) [](https://codecov.io/gh/JacksonFergusonDev/protostar) [](https://protostar.jacksonferguson.me/metrics/) +[](https://protostar.jacksonferguson.me/metrics/) [](https://www.python.org/downloads/) [](https://protostar.jacksonferguson.me/) [](LICENSE) @@ -208,7 +209,7 @@ Written for people who already know Python tooling: Protostar edits other people's work, so it's built to be careful: -- **It plans before it acts.** Every change is worked out in memory and shown to you first. If a step fails or you press Ctrl+C, every file it wrote is restored exactly as it was. +- **It plans before it acts.** Every change is worked out in memory and shown to you first. If a step fails or you press Ctrl+C, every file it wrote is restored exactly as it was, and a nightly run proves it by failing a real run at every step. - **The engine is separate from the interface.** The same core runs behind the interactive editor, the command line, and coding agents, which is why every command can also answer in JSON. - **It's tested thoroughly.** Over 3,500 tests, including runs of the real tools, pass on Linux, macOS, and Windows, with strict type checking and at least 85% coverage. - **Its docs are checked against its code.** Before every push, a script confirms that the commands, errors, exit codes, and file paths the docs mention still match the code. diff --git a/docs/developer/testing.md b/docs/developer/testing.md index e294ea07..f3696f65 100644 --- a/docs/developer/testing.md +++ b/docs/developer/testing.md @@ -100,6 +100,12 @@ uv run pytest tests/test_rollback.py --rollback-scope full --rollback-real all - `just test-rollback` runs the full scope with faked commands. The harness skips `fsync`: rollback restores what is on disk, and durability through a power cut isn't under test. +#### Counting the Faults + +Every test that injects a fault carries the `rollback_fault` marker, and `--rollback-report PATH` writes how many of them passed, and which failed, per scenario (`tests/rollback_report.py`). A retry of failed tests updates the same file, so it holds each fault's last outcome, and a run that is killed or interrupted writes nothing. Nightly's rollback jobs pass the option and upload the file as an artifact, even when the job fails. + +The `Rollback Metrics` workflow (`rollback-metrics.yml`) runs after each scheduled Nightly, apart from it so a publishing failure can never fail the run a release requires. It adds the reports up with `scripts/rollback_report.py` and writes two files under `metrics/` on `gh-pages`: `rollback-history.json`, one entry per fully green run with the count for each operating system and template, and `rollback-latest.json`, a Shields endpoint that reads `rollback faults restored` with the total. The badge stays cyan like the others, so a failure shows in its text, as `N failing`, and never in its color; only green runs reach the history. A run publishes only when every job in the rollback matrix reported: a missing, empty, or foreign report means the result is unknown, so nothing changes. Manual Nightly runs, which may narrow the matrix, never publish. The count adds every operating system, so the same fault restored on three platforms counts three times. The dashboard graphs the history, and the Pages workflow deploys both files, after which the site is asked to rebuild as it is for the mutation score. + ### Template Hooks Smoke Matrix (CI) End-to-end template validation runs through the `template-smoke` action (`.github/actions/template-smoke`). It scaffolds each built-in template it is given in turn, verifies that `prek` hooks are installed, runs a canary check ensuring non-conventional commit messages are rejected, and asserts that the first commit triggers and cleanly passes all pre-commit hooks. A `template-hooks-smoke` job runs it for a list of templates, and a test job can run it after the suite through its matrix entry's `smoke` list, which is how macOS smoke-tests templates without a runner of its own. diff --git a/metrics/index.html b/metrics/index.html index 9ef85de8..f280b262 100644 --- a/metrics/index.html +++ b/metrics/index.html @@ -6,7 +6,7 @@
Every night, Protostar fails a real init or sync at each
+ write, directory, removal, and command of every built-in template, with an error and with an interrupt,
+ before, during, and after the step. A fault counts as restored when the project and home directory come back
+ byte for byte. The count covers every operating system the nightly run uses.
The overall line adds every job’s count, so it rises when a template, a step, + or a platform is added. Dashed vertical markers show a change in the set of jobs. Toggle operating + systems and templates in the legend to inspect them.
+Loading rollback history…
+ +| Template |
|---|
| Recorded | Faults restored | Commit | Jobs |
|---|