Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .github/actions/report/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
19 changes: 15 additions & 4 deletions .github/actions/report/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -74,16 +80,21 @@ 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"

{
echo "## termlens: screens from the failing tests"
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"

Expand Down Expand Up @@ -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
Expand Down
5 changes: 4 additions & 1 deletion .github/dependabot.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
155 changes: 131 additions & 24 deletions .github/scripts/check-semver.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 <baseline-version>
# env BREAKING_LABEL=true the PR carries the `breaking` label
# env BASE_REV=<commit> the commit this change lands on; empty or all
# zeros (a push that created its ref) means none
# env SEMVER_GATE_BASELINE_ROOT=<dir>
# the baseline's source instead of the crates.io
# release, for tools/semver-gate-selftest/
# only; CI never sets it
# env SEMVER_GATE_PACKAGE=<name>
# 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 <baseline-version>}
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
Expand All @@ -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 <view> <release type> <against> <baseline args…>`
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
Expand All @@ -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"
32 changes: 26 additions & 6 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down
10 changes: 6 additions & 4 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Loading
Loading