diff --git a/.github/actions/report/README.md b/.github/actions/report/README.md index 727d5fb..83f6eb6 100644 --- a/.github/actions/report/README.md +++ b/.github/actions/report/README.md @@ -10,10 +10,19 @@ as SVG/HTML in an uploaded artifact. - run: cargo test env: TERMLENS_ARTIFACT_DIR: ${{ runner.temp }}/termlens + INSTA_UPDATE: new - uses: vyncint/termlens/.github/actions/report@v0.11.6 if: failure() ``` +`INSTA_UPDATE: new` is what makes insta write a `.snap.new` on CI. Its +default, `auto`, writes none when the `CI` variable is set, and GitHub sets +it on every runner, so without the line a failing snapshot leaves nothing +here to show. Inline snapshots (`assert_snapshot!(value, @"…")`) are not +rendered: insta keeps their new values in `.pending-snap` files, JSON +records for `cargo insta review` rather than a screen, and the action does +not collect them. + Nothing to install: the action `cargo install`s `termlens-cli` on the runner. Inputs: `artifact-dir` (defaults to `TERMLENS_ARTIFACT_DIR`, else `$RUNNER_TEMP/termlens`), `cli-version` (a `termlens-cli` version; empty diff --git a/.github/actions/report/action.yml b/.github/actions/report/action.yml index b0f6e73..298d14d 100644 --- a/.github/actions/report/action.yml +++ b/.github/actions/report/action.yml @@ -6,7 +6,13 @@ # SVG/HTML renderings as an artifact, since a step summary shows text and # not a local image. # -# One `uses:` line from a consumer's workflow, nothing else: the CLI is +# insta writes a `.snap.new` on CI only when the test step sets +# `INSTA_UPDATE: new`: its default, `auto`, writes nothing once `CI` is set, +# and every GitHub runner sets it (#597). Inline snapshots are not collected: +# insta keeps their new values in `.pending-snap` files, JSON records for +# `cargo insta review` rather than a screen `termlens render` reads. +# +# One `uses:` line from a consumer's workflow, nothing to install: the CLI is # `cargo install`ed from crates.io, which every Rust runner already has. # This repository passes `cli: workspace` so the suite dogfoods the CLI in # the tree rather than the one last published. @@ -74,9 +80,13 @@ runs: # What to show: every .snap.new outside target/, then every file the # artifact hook wrote. Sorted, so the summary is stable run to run. + # `find` exits 1 on any directory it cannot read, a root-owned cache + # or a chmod 000 fixture, after printing everything it could; under + # pipefail that status ended the step before the summary had a line + # in it (#597). The output is kept and the status is not. { - find . -path ./target -prune -o -name '*.snap.new' -print 2>/dev/null - if [ -d "$dir" ]; then find "$dir" -type f -print; fi + find . -path ./target -prune -o -name '*.snap.new' -print 2>/dev/null || true + if [ -d "$dir" ]; then find "$dir" -type f -print || true; fi } | sort > "${out}/files.txt" { @@ -84,6 +94,7 @@ runs: echo if [ ! -s "${out}/files.txt" ]; then echo "No \`.snap.new\` files and nothing under \`${dir}\`: the failures left no screen behind." + echo "On CI, insta writes a \`.snap.new\` only when the test step sets \`INSTA_UPDATE: new\`; inline snapshots (\`.pending-snap\`) are not rendered here." fi } >> "$GITHUB_STEP_SUMMARY" @@ -125,7 +136,7 @@ runs: fi echo "rendered ${n} screen(s) into the step summary" - - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: ${{ inputs.name }} path: ${{ runner.temp }}/termlens-report diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 07ed870..2df8689 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -11,7 +11,10 @@ updates: open-pull-requests-limit: 5 - package-ecosystem: github-actions - directory: / + # `/` alone covers .github/workflows, not the composite action under + # .github/actions, so its pins, which run in other repositories' CI, + # were never updated (#599). + directories: ["/", "/.github/actions/*"] schedule: interval: weekly day: monday diff --git a/.github/scripts/check-semver.sh b/.github/scripts/check-semver.sh index 6711763..e6220e8 100755 --- a/.github/scripts/check-semver.sh +++ b/.github/scripts/check-semver.sh @@ -11,7 +11,11 @@ # green gate. The type is therefore forced here and never inferred. # * `--all-features` cannot see an item moved behind a feature: that view # never loses the item. Cargo calls that removal a major change, so the -# default and explicit-only views run as well. +# default and explicit-only views run as well, and so does each feature +# on its own: an item moved from behind `regex` to behind `regex` and +# `serde` is lost to a `--features regex` build and to none of the other +# three views (#598). The features are read from the manifest, so one +# added later is checked without an edit here. # # The baseline is a literal: the last *published* version, which is the one # a consumer can install. It moves in a follow-up PR after each release, not @@ -34,34 +38,73 @@ # `minor`, so the label would not have let the one intended break through # (the self-test pins this too). # +# `major` accepts any break, and a bullet stays in CHANGELOG.md until the +# release, so one declared break used to let every later pull request break +# the API unseen (#598). When the break is declared by bullets the base +# revision already had, so that this change adds none and carries no label, +# the tree is checked a second time, against that revision with `patch`: +# the declared break is part of that baseline, and only a new one fails. A +# change that rewrites a bullet's first line adds a bullet. The workflow +# passes the revision as BASE_REV: a pull request's base, or the commit a +# push moved main from. A tag push, which release.yml runs this for, has +# none, and neither has a local run without it: the second check is then +# skipped, and the log and the summary say so. +# # Usage: check-semver.sh # env BREAKING_LABEL=true the PR carries the `breaking` label +# env BASE_REV= the commit this change lands on; empty or all +# zeros (a push that created its ref) means none +# env SEMVER_GATE_BASELINE_ROOT= +# the baseline's source instead of the crates.io +# release, for tools/semver-gate-selftest/ +# only; CI never sets it +# env SEMVER_GATE_PACKAGE= +# the package checked instead of termlens, for +# the same selftest: its models must not be named +# termlens, or cargo-semver-checks, which reads +# every manifest under the repository, finds the +# real crate's name defined five times set -euo pipefail baseline=${1:?usage: check-semver.sh } -package=termlens +package="${SEMVER_GATE_PACKAGE:-termlens}" root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" cd "$root" -version="$(cargo metadata --no-deps --format-version 1 \ - | jq -r --arg p "$package" '.packages[] | select(.name == $p) | .version')" +metadata="$(cargo metadata --no-deps --format-version 1)" +version="$(jq -r --arg p "$package" \ + '.packages[] | select(.name == $p) | .version' <<<"$metadata")" +# Every feature a consumer can ask for alone. `default` is the default view +# already, and so is a feature that is the whole default set (`insta`). +features="$(jq -r --arg p "$package" ' + .packages[] | select(.name == $p) | .features as $f + | $f | keys[] | select(. != "default" and [.] != ($f.default // [])) +' <<<"$metadata")" +views="default-features only-explicit-features all-features $(echo $features)" -# A `- **Breaking` bullet inside one `## [section]` of the CHANGELOG. -declares_break() { +# The `- **Breaking` bullets inside one `## [section]` of the CHANGELOG on +# standard input. +breaking_bullets() { awk -v want="$1" ' /^## \[/ { in_section = (index($0, "## [" want "]") == 1) } - in_section && /^- \*\*Breaking/ { found = 1 } - END { exit !found } - ' CHANGELOG.md + in_section && /^- \*\*Breaking/ { print } + ' } declared="" +bullets="" if [ "${BREAKING_LABEL:-false}" = "true" ]; then declared="the pull request carries the \`breaking\` label" -elif declares_break Unreleased; then - declared="CHANGELOG.md declares a break under [Unreleased]" -elif [ "$version" != "$baseline" ] && declares_break "$version"; then - declared="CHANGELOG.md declares a break under [$version], which is ahead of the $baseline baseline" +else + bullets="$(breaking_bullets Unreleased < CHANGELOG.md)" + if [ -n "$bullets" ]; then + declared="CHANGELOG.md declares a break under [Unreleased]" + elif [ "$version" != "$baseline" ]; then + bullets="$(breaking_bullets "$version" < CHANGELOG.md)" + if [ -n "$bullets" ]; then + declared="CHANGELOG.md declares a break under [$version], which is ahead of the $baseline baseline" + fi + fi fi if [ -n "$declared" ]; then @@ -70,22 +113,83 @@ else release_type=patch fi +base_rev="${BASE_REV:-}" +case "$base_rev" in + *[!0]*) ;; + *) base_rev="" ;; +esac + +# Whether the tree is checked again against the base revision, and why. +recheck="" +recheck_reason="" +if [ "${BREAKING_LABEL:-false}" = "true" ]; then + recheck_reason="not checked against a base revision: the label declares this pull request's own break" +elif [ -n "$bullets" ]; then + if [ -z "$base_rev" ]; then + recheck_reason="not checked against a base revision, since BASE_REV names none: a further break would pass under \`major\`" + elif ! git rev-parse -q --verify "$base_rev^{commit}" > /dev/null; then + echo "::error::BASE_REV $base_rev is not a commit in this clone; the checkout needs the history (fetch-depth: 0)" + exit 1 + else + base_changelog="$(git show "$base_rev:CHANGELOG.md" 2> /dev/null || true)" + added="" + while IFS= read -r bullet; do + grep -Fxq -- "$bullet" <<<"$base_changelog" || added="$added$bullet"$'\n' + done <<<"$bullets" + if [ -n "$added" ]; then + recheck_reason="not checked against $base_rev: this change adds the declaring bullet, so its break is the one declared" + else + recheck="$base_rev" + recheck_reason="checked against $base_rev with \`patch\` as well: the declaring bullet was there already, so this change may add no break of its own" + fi + fi +fi + +if [ -n "${SEMVER_GATE_BASELINE_ROOT:-}" ]; then + published="$baseline at $SEMVER_GATE_BASELINE_ROOT" + set -- --baseline-root "$SEMVER_GATE_BASELINE_ROOT" +else + published="published $baseline" + set -- --baseline-version "$baseline" +fi + checker="$(cargo semver-checks --version)" echo "semver gate: $checker" -echo " package $package $version against published $baseline; release type $release_type${declared:+ ($declared)}" +echo " package $package $version against $published; release type $release_type${declared:+ ($declared)}" +echo " views: $views" +if [ -n "$recheck_reason" ]; then + echo " $recheck_reason" +fi status=0 -for view in default-features only-explicit-features all-features; do - echo "::group::$view" - if ! cargo semver-checks --package "$package" \ - --baseline-version "$baseline" \ - --release-type "$release_type" \ - "--$view"; then - echo "::error::the $view view of $package $version is not $release_type-compatible with $baseline (release type $release_type)" +checks=0 +# `check ` +check() { + view=$1 + release=$2 + against=$3 + shift 3 + case "$view" in + default-features | only-explicit-features | all-features) set -- "$@" "--$view" ;; + *) set -- "$@" --only-explicit-features --features "$view" ;; + esac + checks=$((checks + 1)) + echo "::group::$view, against $against, release type $release" + if ! cargo semver-checks --package "$package" --release-type "$release" "$@"; then + echo "::error::the $view view of $package $version is not $release-compatible with $against" status=1 fi echo "::endgroup::" +} + +for view in $views; do + check "$view" "$release_type" "$published" "$@" done +if [ -n "$recheck" ]; then + for view in $views; do + check "$view" patch "$recheck" --baseline-rev "$recheck" + done +fi # The job summary records what checked what, so a green run is legible later. if [ -n "${GITHUB_STEP_SUMMARY:-}" ]; then @@ -95,12 +199,15 @@ if [ -n "${GITHUB_STEP_SUMMARY:-}" ]; then echo "- checker: \`$checker\`" echo "- baseline: \`$package $baseline\` (published); tree: \`$version\`" echo "- release type: \`$release_type\`${declared:+ — $declared}" - echo "- views: default-features, only-explicit-features, all-features" - echo "- result: $([ "$status" -eq 0 ] && echo pass || echo FAIL)" + echo "- views: $views" + if [ -n "$recheck_reason" ]; then + echo "- $recheck_reason" + fi + echo "- result: $([ "$status" -eq 0 ] && echo pass || echo FAIL) ($checks checks)" } >> "$GITHUB_STEP_SUMMARY" fi if [ "$status" -eq 0 ]; then - echo "semver gate: PASS — $package $version is $release_type-compatible with $baseline in all three views" + echo "semver gate: PASS — $package $version is $release_type-compatible with $published in every view${recheck:+, and patch-compatible with $recheck}" fi exit "$status" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 85dd2a8..4cb13ae 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -67,6 +67,9 @@ jobs: - run: cargo test --workspace --all-features env: TERMLENS_ARTIFACT_DIR: ${{ runner.temp }}/termlens + # insta's default, `auto`, writes no .snap.new once CI is set, so + # the report step below never had a snapshot to show (#597). + INSTA_UPDATE: new # The CLI's documented exit codes, against this tree. `install.yml` # runs the same script against the *published* binary, which is the # net; this is the fast feedback. #310 was a `--version` that exited 2 @@ -208,6 +211,10 @@ jobs: # all-passing fixture must exit zero. Needs no toolchain, so it lives # here with the other script gates. - run: tools/preflight-selftest/run.sh + # …and the report action's render step (#597), run from action.yml + # with a stub CLI: a directory `find` cannot read ended the step + # before it wrote a line, and other repositories run this step. + - run: tools/report-action-selftest/run.sh # The emit fixture's documented steps against the ones it implements # (#318). It lives in this job because it needs no toolchain — it is # two greps and a `comm` — and the fixture is what almost every @@ -269,12 +276,16 @@ jobs: # measured and both pinned by the self-test that runs first: a version bump # in the same PR as a break makes the inferred release type "major" and # skips every check, so the type is forced; and --all-features cannot see - # an item moved behind a feature, so three views run. A deliberate break - # carries the `breaking` label (the release type becomes `major`, and the - # diagnostics go in the PR description) — and is recorded in CHANGELOG.md - # as a `- **Breaking` bullet, which is what keeps main green after the - # merge, where no label exists. `.github/scripts/check-semver.sh` has the - # rules; docs/RELEASING.md says when the baseline literal below moves. + # an item moved behind a feature, so the default and explicit-only views + # run too, and each feature alone (#598). A deliberate break carries the + # `breaking` label (the release type becomes `major`, and the diagnostics + # go in the PR description) — and is recorded in CHANGELOG.md as a + # `- **Breaking` bullet, which is what keeps main green after the merge, + # where no label exists. While that bullet is on main, a later change that + # declares nothing itself is also checked with `patch` against the commit + # it lands on, so the one declared break carries no other through (#598). + # `.github/scripts/check-semver.sh` has the rules; docs/RELEASING.md says + # when the baseline literal below moves. # # The checker is a prebuilt binary downloaded every run: never built from # source and never restored from a cache, so a green run is evidence about @@ -286,6 +297,9 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: persist-credentials: false + # The whole history, so that BASE_REV below is a commit the + # checker can build as a baseline. + fetch-depth: 0 - uses: dtolnay/rust-toolchain@683b588c487a69d01f6c6ddf40f036ff682cc2c8 # v1 with: toolchain: stable @@ -296,6 +310,12 @@ jobs: - run: .github/scripts/check-semver.sh 0.11.6 env: BREAKING_LABEL: ${{ contains(github.event.pull_request.labels.*.name, 'breaking') }} + # The commit this change lands on: a pull request's base, or the + # commit a push to main moved from. A push that creates its ref + # reports forty zeros, which the script reads as no base; that is + # the tag push release.yml calls this workflow for, whose commit + # was checked on its way to main. + BASE_REV: ${{ github.event_name == 'pull_request' && github.event.pull_request.base.sha || github.event.before }} docs: name: docs diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 394a29a..7dc69e9 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -38,10 +38,12 @@ jobs: # Re-run the exact CI gates (fmt, clippy, test matrix, msrv, docs, deny), # the semver gate included: ci.yml's `semver` job checks the tree against - # the last published release with the release type forced, in three - # feature views (#323). A release with a declared break passes because - # its CHANGELOG section records it (`- **Breaking`); an undeclared one - # fails here before anything is published. + # the last published release with the release type forced, in every + # feature view (#323, #598). A release with a declared break passes + # because its CHANGELOG section records it (`- **Breaking`); an undeclared + # one fails here before anything is published. A tag push has no base + # revision, so the gate's second check of a declared break is not + # repeated here: the tagged commit passed it on its push to main. ci: name: ci needs: verify-version diff --git a/CHANGELOG.md b/CHANGELOG.md index 5478cbc..5daf559 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -177,6 +177,20 @@ reads that marker. check refuses a file a termlens wrote: a keycap such as `1️⃣`, a cell whose width only this build's width tables dispute, and a wide character that vt100 0.16 left in the last column after a resize all still read. +- The `report` action writes its summary when the workspace holds a + directory `find` cannot read, such as a root-owned cache or a `chmod 000` + test fixture (#597). The step used to stop there with exit 1 before + writing anything. The CI snippets in the README, the action's README and + the skill now set `INSTA_UPDATE: new` beside `TERMLENS_ARTIFACT_DIR`: + insta's default writes no `.snap.new` when `CI` is set, so on GitHub + Actions a failing snapshot left the action nothing to show. Inline + snapshots are not rendered, and the action's README and its "nothing + found" summary now say so. +- The README's resize recipe for ratatui users waits with `wait_until` + instead of `wait_frame` (#600). `wait_frame` waits for DEC 2026 + synchronized updates, which stock ratatui does not emit, so the recipe + timed out for exactly the readers it addressed. The torn-frame row now + says it applies to an application that does emit them. ## [0.11.6] - 2026-10-14 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index bdf0f25..04447da 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -74,6 +74,7 @@ tools/report-pin-gate-selftest/run.sh # …and the report-pin tools/attribution-gate-selftest/run.sh # …and the AI-attribution gate, likewise tools/dco-gate-selftest/run.sh # …and the DCO gate, its one-commit squash exemption included tools/preflight-selftest/run.sh # …and the runner accounts for each gate it runs, failures included +tools/report-action-selftest/run.sh # …and the report action writes its summary past a directory it cannot read .github/scripts/check-semver.sh 0.11.6 # …and the public API is compatible with the last published release ``` diff --git a/README.md b/README.md index 4964ab5..3322a92 100644 --- a/README.md +++ b/README.md @@ -223,14 +223,17 @@ termlens render --svg --out shot.svg - # …to a file, from a screen on stdin ``` In CI, set `TERMLENS_ARTIFACT_DIR` on the test step and every screen a -failing wait embeds is also written there. The report action then puts -those screens, and every `.snap.new` with its diff, into the pull request's -step summary: +failing wait embeds is also written there. Set `INSTA_UPDATE: new` beside +it: insta's default writes no `.snap.new` when `CI` is set, and GitHub sets +`CI` on every runner. The report action then puts those screens, and every +`.snap.new` with its diff, into the pull request's step summary (inline +snapshots are not rendered): ```yaml - run: cargo test env: TERMLENS_ARTIFACT_DIR: ${{ runner.temp }}/termlens + INSTA_UPDATE: new - uses: vyncint/termlens/.github/actions/report@v0.11.6 if: failure() ``` @@ -252,9 +255,9 @@ termlens assertion that does: | Invisible to `TestBackend` | The assertion that sees it | | --- | --- | | raw-mode entry and exit, the alternate screen | `t.wait_until(\|s\| s.alternate_screen())`, and `!alternate_screen()` after `q` | -| a resize reaching the application | `t.resize(60, 14)?; t.wait_frame(\|s\| s.contains("60x14"))` | +| a resize reaching the application | `t.resize(60, 14)?; t.wait_until(\|s\| s.contains("60x14"))?` | | output outside ratatui — a `println!`, a logger, a panic | `s.contains("panicked")`, or a snapshot of the whole grid | -| a torn frame | `wait_frame` returns complete DEC 2026 frames only | +| a torn frame, in an application that emits DEC 2026 synchronized updates (stock ratatui does not) | `wait_frame` returns complete DEC 2026 frames only | | capability probes and the modes they turn on | `answer_queries` replies as a terminal would; `s.mouse_mode()`, `s.bracketed_paste()` say what was asked for | | mouse and paste bytes under the enabled modes | `t.click(col, row)`, `t.scroll(…)`, `t.paste(…)` encode for the mode the app turned on | | a masked field that is really printed in clear | `cell.style().conceal` — identical text, different picture | diff --git a/docs/RELEASING.md b/docs/RELEASING.md index a003f4d..d170804 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -27,21 +27,28 @@ before 1.0. What that means for a release: - **A patch or minor release breaks nothing.** The `semver` job holds it: every pull request is checked against the last published release with - the release type forced to `patch`, in three feature views. A red - `semver` on a PR is the answer, not an obstacle. + the release type forced to `patch`, in the default, explicit-only and + all-features views and with each feature alone. A red `semver` on a PR + is the answer, not an obstacle. - **A necessary break is a new candidate** — `0.12.0`, never a patch — and restarts the observation window of the [1.0 readiness criteria](STABILITY.md#the-10-readiness-criteria). Its pull request carries the **`breaking` label** (a maintainer applies it; the gate then runs with `--release-type major`), its description pastes the diagnostics the *forced* run produced (run - `.github/scripts/check-semver.sh ` without the label to get - them), and its CHANGELOG entry is a bullet beginning **`- **Breaking:**`** - under **Changed** or **Removed** whose text matches those diagnostics and - carries a migration table. The marker is what keeps `main` green after - the merge, where no label exists, and what lets the release tag pass the - same gate — so a break without the marker is a break the gate refuses, - on the PR and again on the tag. + `.github/scripts/check-semver.sh ` without the label, and + before the bullet below is written, to get them), and its CHANGELOG + entry is a bullet beginning **`- **Breaking:**`** under **Changed** or + **Removed** whose text matches those diagnostics and carries a migration + table. The marker is what keeps `main` green after the merge, where no + label exists, and what lets the release tag pass the same gate — so a + break without the marker is a break the gate refuses, on the PR and + again on the tag. The marker declares that break and no other: until the + release, every pull request and push to `main` that adds no marker of its + own is checked a second time, with `patch`, against the commit it lands + on (`BASE_REV` in ci.yml), so a second, undeclared break is refused on + its PR and again on `main`. A second break that is meant is declared + like the first, with the label and a bullet of its own. - **Release candidates for 1.0** are tagged `v1.0.0-rc.N` and publish to crates.io as pre-releases; a consumer opts in with an exact requirement (`termlens = "=1.0.0-rc.1"`), and Cargo never resolves a pre-release @@ -137,14 +144,18 @@ previous release. That is green when the release broke nothing, and green for a release whose CHANGELOG section records its break as a `- **Breaking` bullet — the gate reads that marker (`.github/scripts/check-semver.sh`), so a break that was declared stays declared after the label on its pull -request is out of sight. +request is out of sight. The release moved the bullet without adding one, +so a change in that window is still checked a second time against the +commit it lands on, as above. Pushing the tag runs `release.yml`, which: 1. fails unless tag == `crates/termlens` version, 2. re-runs the full CI gates (`workflow_call` into ci.yml) — the semver gate among them, against the last published release with the release - type forced (`.github/scripts/check-semver.sh`), + type forced (`.github/scripts/check-semver.sh`); a tag push has no + commit it lands on, so a declared break's second check is not repeated + there, and the tagged commit passed it on its way to `main`, 3. `cargo publish -p termlens` then `cargo publish -p termlens-cli`, both via Trusted Publishing (OIDC); each crate needs its own publisher link, and the step says so if one is missing, diff --git a/docs/STABILITY.md b/docs/STABILITY.md index 2d63e31..805c1b9 100644 --- a/docs/STABILITY.md +++ b/docs/STABILITY.md @@ -130,18 +130,29 @@ to be short. `--no-default-features`, each single feature (`decode`, `regex`, `serde`) and all features together. Semver over all of it: an item is not removed, renamed, re-typed or moved behind a feature in any of those - configurations without a new candidate. *Checked by* the `semver` job - (`.github/scripts/check-semver.sh`: cargo-semver-checks against the last - published release, release type forced to `patch`, in the default, - explicit-only and all-features views; `tools/semver-gate-selftest/` proves - the gate fails on a removed and on a gated item) and by the `features` - job, which builds and tests each reduced configuration for the library - alone (`.github/scripts/check-feature-isolation.sh`). *Not checked, and - said so:* a method whose signature changes under the same name — the - 0.11 change to `unsupported()` was exactly that, and cargo-semver-checks + configurations without a new candidate. *Checked by* the `semver` and + `features` jobs. The `semver` job (`.github/scripts/check-semver.sh`) + runs cargo-semver-checks against the last published release, release + type forced to `patch`, in each of those configurations: the default, + explicit-only and all-features views, and `decode`, `regex` and `serde` + each alone, read from the manifest so that a new feature joins them. + While a declared break waits in the CHANGELOG for its release, every + later pull request and push to `main` that declares nothing itself is + checked again, with `patch`, against the commit it lands on, so the + declared break carries no second one through. + `tools/semver-gate-selftest/` proves the gate fails on a removed item, a + gated item, an item moved from one feature to two, and a second break + after a declared one. The `features` job builds and tests each reduced + configuration for the library alone + (`.github/scripts/check-feature-isolation.sh`). *Not checked, and said + so:* a method whose signature changes under the same name — the 0.11 + change to `unsupported()` was exactly that, and cargo-semver-checks 0.50.0 reported only the removed `unsupported_overflow()` beside it. That class is caught by the crate's own tests and every consumer's compile, - and by review. + and by review. Nor is the rest of a pull request that declares a break, + by the `breaking` label or a `- **Breaking` bullet it adds: it runs with + `--release-type major`, which accepts any break, so what else it breaks + is for review, against the diagnostics its description pastes. - **The snapshot text format** of [DESIGN.md](DESIGN.md) §3, which `Screen::parse` reads back: the header, the grid, the `styles:` block and its tokens. A file written by any release from 0.10.1 on parses in every diff --git a/skills/termlens/SKILL.md b/skills/termlens/SKILL.md index 4c7761b..7ea8db2 100644 --- a/skills/termlens/SKILL.md +++ b/skills/termlens/SKILL.md @@ -567,10 +567,14 @@ directory (see §9b). `diff` and `render` read `-` as stdin (one operand of `diff` at most), and `inspect --cwd DIR` runs the program somewhere other than here; a relative program path such as `./demo` is then found inside `DIR`. -- In CI, set `TERMLENS_ARTIFACT_DIR: ${{ runner.temp }}/termlens` on the - test step and add `uses: vyncint/termlens/.github/actions/report@v0.11.6` - with `if: failure()` after it: every screen a failing wait embedded, and - every `.snap.new` with its diff, lands in the pull request's step summary. +- In CI, set `TERMLENS_ARTIFACT_DIR: ${{ runner.temp }}/termlens` and + `INSTA_UPDATE: new` on the test step, and add + `uses: vyncint/termlens/.github/actions/report@v0.11.6` with + `if: failure()` after it: every screen a failing wait embedded, and every + `.snap.new` with its diff, lands in the pull request's step summary. + `INSTA_UPDATE: new` is not optional: insta's default writes no `.snap.new` + when `CI` is set, and GitHub sets it on every runner. Inline snapshots are + not rendered; insta keeps their new values in `.pending-snap` files. ## 10. A checklist before you finish diff --git a/tools/report-action-selftest/run.sh b/tools/report-action-selftest/run.sh new file mode 100755 index 0000000..67226c9 --- /dev/null +++ b/tools/report-action-selftest/run.sh @@ -0,0 +1,122 @@ +#!/usr/bin/env bash +# Proves the report action's render step survives the workspaces it meets, +# before a green run of it is trusted (#597). +# +# The step's `run:` block is read out of .github/actions/report/action.yml +# and run as the runner runs it, under bash with `set -euo pipefail`, in a +# scratch workspace. termlens-cli is a stub that echoes its arguments: what +# is under test is the shell around the CLI, not the CLI. +# +# - a directory nobody can read beside a .snap.new: `find` exits 1 on it, +# and that status ended the step before the summary had a line (#597); +# the .snap.new must still reach the summary +# - the same inside the artifact directory, which the step searches with +# a second `find` +# - nothing to show, with a .snap.new under target/ that must stay out: +# the summary says so and names INSTA_UPDATE +# +# Root reads a chmod 000 directory anyway, so as root the first two cases +# cannot fail and are skipped by name rather than passed. +# +# Usage: tools/report-action-selftest/run.sh +set -euo pipefail + +cd "$(dirname "${BASH_SOURCE[0]}")" +root="$(cd ../.. && pwd)" +action="$root/.github/actions/report/action.yml" +status=0 + +body="$(awk ' + /^ - name: Render the screens into the summary$/ { step = 1; next } + step && /^ run: [|]$/ { run = 1; next } + run && /^$/ { print ""; next } + run && /^ / { print substr($0, 9); next } + run { exit } +' "$action")" +if [ -z "$body" ]; then + echo "::error::no \"Render the screens into the summary\" step with a run: block in $action" >&2 + exit 1 +fi + +scratch="$(mktemp -d)" +locked="" +trap 'for d in $locked; do chmod 755 "$d"; done; rm -rf "$scratch"' EXIT + +# `workspace `: a fresh workspace, artifact directory and RUNNER_TEMP +# for one case, with the stub CLI where the install step puts the real one. +workspace() { + ws="$scratch/$1/workspace" + artifacts="$scratch/$1/artifacts" + rt="$scratch/$1/runner-temp" + mkdir -p "$ws" "$artifacts" "$rt/termlens-cli/bin" + printf '#!/bin/sh\necho "stub termlens $*"\n' > "$rt/termlens-cli/bin/termlens" + chmod +x "$rt/termlens-cli/bin/termlens" +} + +unreadable() { + mkdir -p "$1" + chmod 000 "$1" + locked="$locked $1" +} + +# `expect