diff --git a/.githooks/README.md b/.githooks/README.md index da236b2..a348bf8 100644 --- a/.githooks/README.md +++ b/.githooks/README.md @@ -3,7 +3,7 @@ Committed Git hooks that run the fleet's gate locally, before a commit lands, so a failure surfaces here rather than after a push. -Fleet-managed by [conf](https://github.com/PowderworksCode/conf); edit them +Distributed by [Ordnung](https://ordnung.dev)'s `recommended` tier; edit them there, not here — a local change is drift the next sync reports. ## Activate diff --git a/.githooks/commit-msg b/.githooks/commit-msg index 9b1b482..6fb12a0 100755 --- a/.githooks/commit-msg +++ b/.githooks/commit-msg @@ -3,7 +3,7 @@ # applies the same rule to the commit subject, so a bad message fails here # rather than after a push. Bypass with `git commit --no-verify`. # -# Fleet-managed by conf (.ordnung/managed/githooks/commit-msg): edit it there. +# Distributed by Ordnung's `recommended` tier: edit it there, not here. msg_file="$1" # First non-blank, non-comment line is the subject. diff --git a/.githooks/pre-commit b/.githooks/pre-commit index 6a8c345..0b6984c 100755 --- a/.githooks/pre-commit +++ b/.githooks/pre-commit @@ -2,8 +2,11 @@ # The local gate: what CI would say about this tree, said before the commit # lands. Bypass with `git commit --no-verify`. # -# Fleet-managed by conf (.ordnung/managed/githooks/pre-commit). +# Fleet-managed by conf (.ordnung/managed/githooks/pre-commit): it replaces the +# `ordnung check` hook Ordnung's `recommended` tier ships. set -eu +# No path argument: a positional overrides the `paths` a repository set in its +# own straitjacket.toml, and a hook has no business overruling that. hooks_dir=$(CDPATH='' cd -- "$(dirname -- "$0")" && pwd) -exec "$hooks_dir/run-straitjacket" . +exec "$hooks_dir/run-straitjacket" diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 0ffa1c6..7e4b3ec 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,14 +1,14 @@ version: 2 -# Fleet-owned. Edit this in conf, not in the member repository: a local change is -# drift, and the next sync overwrites it. +# Fleet-owned. Edit this in the policy tier, not in the member repository: a +# local change is drift, and the next sync overwrites it. # # One file for every member. The directories inside it are globs rather than a # list of each repository's actual layout, so this file states fleet POLICY — # weekly, minor and patch grouped into one pull request, five open at most — and # lets the globs discover the topology. A glob matching nothing is not an error, -# so the same bytes are correct in a Cargo workspace, a Bun site, and conf, -# which has neither. +# so the same bytes are correct in a Cargo workspace, a Bun site, and a fleet +# definition, which has neither. # # The alternative was a file per repository shape. That put facts about each # repository's layout into the fleet, where they would go stale silently the diff --git a/.github/workflows/fleet-lint.yml b/.github/workflows/fleet-lint.yml index 83f07d3..7e5dbc9 100644 --- a/.github/workflows/fleet-lint.yml +++ b/.github/workflows/fleet-lint.yml @@ -1,9 +1,12 @@ name: fleet-lint -# Fleet-managed by conf (.ordnung/managed/github/lint.yml): the static-analysis -# floor every member runs — prose, workflow, shell, and public-API hygiene. -# Edit it in conf and let ordnung distribute it; a local edit here is drift the -# next fleet sync will report. +# Distributed by Ordnung's `paranoid` tier: the static-analysis floor every +# member runs — prose, workflow, shell, and public-API hygiene. Edit it in the +# tier and let ordnung distribute it; a local edit here is drift the next fleet +# sync will report. +# +# It replaces, by entry name, the codespell-only workflow the `recommended` tier +# ships: the extra jobs are the checks this tier raises. on: push: branches: [main] diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..1f5125a --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,225 @@ +name: release (rust) + +# A tag is the whole trigger. Everything the installer reads — the archives, the +# checksums, and the "latest" pointer — is produced here, so a release is never +# assembled by hand. +on: + push: + tags: ["v*"] + workflow_dispatch: + inputs: + tag: + description: Existing tag to build a release for + required: true + +permissions: + contents: read + +# Never run two releases at once, and never cancel one in flight: a cancelled +# `cargo publish` can leave a version uploaded that can never be reused. +concurrency: + group: release-${{ inputs.tag || github.ref_name }} + cancel-in-progress: false + +env: + TAG: ${{ inputs.tag || github.ref_name }} + +jobs: + # The release is created as a draft and published only after every archive and + # the checksum file are in place. A draft is not returned by the "latest" + # endpoint, so `install.sh` can never see a half-uploaded release. + draft: + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: write + outputs: + # What this repository publishes, answered where the checkout is. Neither + # can be a job-level `if: hashFiles(...)`: that reads a workspace which + # does not exist before checkout, and a workflow whose expression cannot + # be evaluated does not start at all — no jobs, no release, on a tag push + # nobody is watching. + crate: ${{ steps.publishes.outputs.crate }} + binary: ${{ steps.publishes.outputs.binary }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ env.TAG }} + - id: publishes + run: | + if [ -f scripts/publish.sh ]; then + echo "crate=true" >> "$GITHUB_OUTPUT" + else + echo "crate=false" >> "$GITHUB_OUTPUT" + echo "no scripts/publish.sh; this release ends at the smoke test" + fi + # A library crate has nothing to put in an archive, nothing to + # install, and nothing to smoke-test. Cargo is asked rather than the + # file tree, because a binary target can be declared several ways. + if cargo metadata --no-deps --format-version 1 | + jq -e '[.packages[].targets[] | select(any(.kind[]; . == "bin"))] | length > 0' >/dev/null; then + echo "binary=true" >> "$GITHUB_OUTPUT" + else + echo "binary=false" >> "$GITHUB_OUTPUT" + echo "no binary targets; this release is a crate publish only" + fi + - name: Check the tag against Cargo.toml + run: | + crate=$(sed -n 's/^version = "\(.*\)"$/\1/p' Cargo.toml | head -n 1) + if [ "$TAG" != "v${crate}" ]; then + echo "tag ${TAG} does not match Cargo.toml version ${crate}" >&2 + exit 1 + fi + - name: Create the draft release + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + run: | + if ! gh release view "$TAG" >/dev/null 2>&1; then + gh release create "$TAG" --draft --title "$TAG" --generate-notes + fi + + build: + needs: draft + if: needs.draft.outputs.binary == 'true' + permissions: + contents: write + strategy: + fail-fast: false + matrix: + include: + - target: x86_64-unknown-linux-musl + os: ubuntu-latest + - target: aarch64-unknown-linux-musl + os: ubuntu-latest + - target: aarch64-apple-darwin + os: macos-latest + - target: x86_64-apple-darwin + os: macos-latest + runs-on: ${{ matrix.os }} + timeout-minutes: 45 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ env.TAG }} + # Both Linux targets cross-compile from an ordinary x86_64 host, because + # .cargo/config.toml links them with rust-lld against a self-contained + # musl. No cross toolchain, container, or apt package is involved. + - run: rustup target add ${{ matrix.target }} + # No build cache here, deliberately: a cache is writable from other + # workflows, and these binaries are the release. Cold builds are the + # price of artifacts nothing else could have written to (zizmor's + # cache-poisoning audit). + - run: cargo build --release --locked --target ${{ matrix.target }} + - name: Package + run: | + stage=$(mktemp -d) + cp "target/${{ matrix.target }}/release/jawohl" "$stage/" + cp README.md LICENSE "$stage/" + tar -czf "jawohl-${TAG}-${{ matrix.target }}.tar.gz" -C "$stage" \ + jawohl README.md LICENSE + - name: Upload + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + run: gh release upload "$TAG" "jawohl-${TAG}-${{ matrix.target }}.tar.gz" --clobber + + # Runs for every release, because un-drafting is not a binary concern: a + # library's release would otherwise stay a draft forever. Only the archives + # are conditional. `always()` is what lets a skipped `build` through; without + # it a skipped dependency skips this job too. + publish: + needs: [draft, build] + if: >- + always() && needs.draft.result == 'success' + && (needs.build.result == 'success' || needs.build.result == 'skipped') + runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + contents: write + steps: + # The checksums are computed from what the release actually holds rather + # than from what each build job believes it uploaded. + - name: Checksum every archive + if: needs.draft.outputs.binary == 'true' + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + run: | + gh release download "$TAG" --pattern '*.tar.gz' --dir assets + cd assets && sha256sum ./*.tar.gz | sed 's|\./||' > SHA256SUMS + cat SHA256SUMS + gh release upload "$TAG" SHA256SUMS --clobber + - name: Publish + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + run: gh release edit "$TAG" --draft=false --latest + + # Install the release that was just published, on every supported platform, + # through both the script and the action. Called rather than triggered: a + # release published by GITHUB_TOKEN raises no event that starts a workflow. + smoke: + needs: [draft, publish] + if: >- + always() && needs.publish.result == 'success' + && needs.draft.outputs.binary == 'true' + permissions: + contents: read + uses: ./.github/workflows/install-smoke.yml + with: + version: ${{ inputs.tag || github.ref_name }} + + # Publish the same commit to crates.io, after the binaries have been proven + # to install and run. crates.io is the one place a version can never be + # replaced, so it goes last and everything checkable is checked first. + # + # The logic lives in scripts/publish.sh so the same path can be run by hand, + # and so this job is a caller rather than a second definition of it. + crate: + needs: [draft, publish, smoke] + # Only repositories that publish a crate carry the script; everyone else + # ends at the smoke test. The answer comes from the draft job, which has + # the checkout — see the note on its outputs. + # + # A skipped smoke job means either "no binary in this repository" or "the + # binary never got that far", and those must not read alike. `publish` + # tells them apart: it succeeds when the archives are up or when there were + # none to build, and is skipped when the build failed. + if: >- + always() && needs.draft.outputs.crate == 'true' + && needs.publish.result == 'success' + && (needs.smoke.result == 'success' || needs.smoke.result == 'skipped') + runs-on: ubuntu-latest + timeout-minutes: 20 + # One place to require a human approval before an irreversible publish: + # Settings > Environments > crates-io > Required reviewers. + environment: crates-io + permissions: + id-token: write + env: + # `secrets` cannot be read from an `if`, so the question of whether one + # exists is answered here, where it can. + HAS_STORED_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN != '' }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ inputs.tag || github.ref_name }} + # Trusted publishing is the normal path and needs no secret. The stored + # token exists only to bootstrap: a crate's first version cannot be + # published this way, because crates.io will not attach a trusted + # publisher to a crate that does not exist yet. Delete the secret once + # the trusted publisher is configured and this step takes over again. + - uses: rust-lang/crates-io-auth-action@c6f97d42243bad5fab37ca0427f495c86d5b1a18 # v1.0.5 + id: auth + if: env.HAS_STORED_TOKEN == 'false' + - name: Publish + env: + CARGO_REGISTRY_TOKEN: ${{ steps.auth.outputs.token || secrets.CARGO_REGISTRY_TOKEN }} + run: | + if [ "${HAS_STORED_TOKEN}" = "true" ]; then + echo "publishing with the stored bootstrap token; delete it once trusted publishing is configured" + else + echo "publishing with a short-lived trusted-publishing token" + fi + scripts/publish.sh --execute diff --git a/.vale.ini b/.vale.ini index 0142431..0538725 100644 --- a/.vale.ini +++ b/.vale.ini @@ -1,4 +1,4 @@ -# Fleet-managed by conf (.ordnung/managed/vale/vale.ini). +# Distributed by Ordnung's `paranoid` tier: edit it there, not here. # # proselint and write-good rather than a house style guide: they catch prose # that is weak on anyone's terms — weasel words, passive constructions, @@ -12,5 +12,24 @@ Packages = proselint, write-good [*.md] BasedOnStyles = proselint, write-good +# write-good ships these two at error level and the rest of its style advice at +# warning, which is its packaging rather than a considered position: opening a +# sentence with "There is" is a preference exactly like the passive voice it +# warns about. Levelled down so that prose style informs and does not gate; +# what still fails a build is a real defect, such as a TODO left in a document. +write-good.ThereIs = warning +write-good.So = warning + # Prose lives in sentences; a fenced block is code someone will run. BlockIgnores = (?s) *(```.*?```) + +# `vale sync` writes the style packages here, README files and all, and Vale +# then grades the prose of the linters it just downloaded. +[.vale/styles/**] +BasedOnStyles = + +# Verbatim text from other projects, reproduced to satisfy their licences. +# proselint asks for `©` where a licence header writes `(c)`, and taking that +# advice would edit somebody else's licence. +[THIRD-PARTY-NOTICES.md] +BasedOnStyles = diff --git a/CHANGELOG.md b/CHANGELOG.md index 704cbe8..036e978 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,7 +21,7 @@ documents through `complete_json`, checking whether the result parses: | `{"que` | `{"que"}` ✗ | `{}` | A closer-counter has no model of where it is in the grammar, so it cannot know -that `tru` needs an `e` or that a trailing `\` escapes the very quote it just +that `tru` needs an `e` or that a trailing `\` escapes the quote it just appended. That is structural, not a bug list. ### Added diff --git a/README.md b/README.md index 11138c9..b80dbca 100644 --- a/README.md +++ b/README.md @@ -178,7 +178,7 @@ See `examples/sse.rs`. ## Upgrading from 0.1 `complete_json` and `get_closing_string_for_partial_json` keep their signatures. -The behaviour is stricter and more correct: +The behaviour is stricter and better defined: - **Malformed input now returns `Err`.** 0.1 returned `Ok` with output that did not parse — for `{"a": tru`, `{"a": "x\`, `{"a":`, `{"a":1,`, `{"que`, and a diff --git a/notes/DESIGN.md b/notes/DESIGN.md index a63eeb5..ecc5e41 100644 --- a/notes/DESIGN.md +++ b/notes/DESIGN.md @@ -376,7 +376,7 @@ and `jawohl.stream(MyZodSchema)` are host-side sugar over it. the same handle; `push`/`snapshot`/`status` are synchronous **methods** on it; `changes()` is a **stream op**; a native validator is a **value-returning callback param**. jawohl exercises all three of -jedem's hard three — which is what makes it a good acid test and also what +jedem's hard three — which is what makes it a good proving ground and also what makes §8 the risk. --- @@ -420,7 +420,7 @@ bet: 1. **The core track absorbs the wait.** C1–C4 is the majority of the engineering and none of it is blocked. By the time jedem reaches step 3, jawohl should have a complete, tested Rust core waiting for a surface. -2. **jawohl is the acid test that pulls jedem forward.** `findings.md`'s +2. **jawohl is the proving ground that pulls jedem forward.** `findings.md`'s method — author the complete real surface before freezing the IR — is what caught every gap in fluessig. jawohl's surface is small, precise, and hits all three hard cases, which makes it a far better forcing function than a demo diff --git a/scripts/publish.sh b/scripts/publish.sh new file mode 100755 index 0000000..826c7a2 --- /dev/null +++ b/scripts/publish.sh @@ -0,0 +1,137 @@ +#!/usr/bin/env bash +# Publish jawohl to crates.io. +# +# Publishing is irreversible: a version can be yanked but never deleted, and a +# name/version pair can never be reused. The shape of this script follows from +# that. +# +# - Dry run is the default. Uploading takes --execute. +# - A version the registry already has is skipped rather than attempted, so +# re-running a release for an existing tag is a no-op instead of a failure. +# - Existing versions are read from the sparse index rather than the web API, +# because the index is what cargo itself resolves against, and because +# --index lets the whole path be rehearsed against a local registry. +# - The version is whatever Cargo.toml says. Nothing here computes or bumps +# it; the release tag is checked against it in the release workflow. +# +# Usage: +# scripts/publish.sh # dry run: package and verify +# scripts/publish.sh --execute # real publish; needs a token +# scripts/publish.sh --execute --registry local --index ./idx +# +# --dry-run package and compile the tarball; never uploads (default) +# --execute actually publish +# --registry NAME publish to a cargo registry other than crates.io +# --index URL where to enumerate existing versions; an https:// base or a +# local sparse-index directory. Defaults to index.crates.io. +# --allow-dirty package with uncommitted changes present +# +# Environment: +# CARGO_REGISTRY_TOKEN required for --execute against crates.io. +# CARGO_REGISTRIES__TOKEN ... or for --execute --registry . +# Neither is ever logged. +# +# Fleet-managed by conf (.ordnung/managed/publishing/rust/publish.sh): edit it +# there. The crate name is substituted from the repository name, so a crate +# named differently from its repository needs a copy of its own. +# +# straitjacket-allow-file:no-comments — this is the procedure for the one +# action in the repository that cannot be undone, and sh has no +# documentation-comment syntax to hoist the reasoning into. +set -euo pipefail + +ROOT=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) +CRATE=jawohl +MODE=dry-run +REGISTRY="" +INDEX_BASE="https://index.crates.io" +ALLOW_DIRTY=0 + +while [ $# -gt 0 ]; do + case "$1" in + --dry-run) MODE=dry-run ;; + --execute) MODE=execute ;; + --registry) REGISTRY=${2:?--registry needs a name}; shift ;; + --index) INDEX_BASE=${2:?--index needs a url or directory}; shift ;; + --allow-dirty) ALLOW_DIRTY=1 ;; + -h|--help) sed -n '2,33p' "${BASH_SOURCE[0]}"; exit 0 ;; + -*) echo "publish: unknown flag $1" >&2; exit 2 ;; + *) echo "publish: unexpected argument $1" >&2; exit 2 ;; + esac + shift +done + +cd "$ROOT" + +VERSION=$(sed -n 's/^version = "\(.*\)"$/\1/p' Cargo.toml | head -n 1) +[ -n "$VERSION" ] || { echo "publish: no version in Cargo.toml" >&2; exit 1; } + +# cargo reads the token for a named registry from CARGO_REGISTRIES__TOKEN, +# and only the default registry from CARGO_REGISTRY_TOKEN. +if [ -n "$REGISTRY" ]; then + reg_upper=${REGISTRY^^}; reg_upper=${reg_upper//-/_} + TOKEN_VAR="CARGO_REGISTRIES_${reg_upper}_TOKEN" +else + TOKEN_VAR=CARGO_REGISTRY_TOKEN +fi +if [ "$MODE" = execute ] && [ -z "${!TOKEN_VAR:-}" ]; then + cat >&2 < Secrets and variables > Actions > New repository secret + Name: CARGO_REGISTRY_TOKEN + Value: a crates.io API token with publish-new, scoped to $CRATE + + Once the crate exists on crates.io, configure Trusted Publishing instead + and delete that secret: the release workflow then authenticates over OIDC + with a token that lives under an hour. See notes/field_guide.md. +MSG + exit 1 +fi + +# The sparse index lays a name out as //, and each +# line is one published version. +index_path() { + local name=$1 + case ${#name} in + 1) printf '1/%s\n' "$name" ;; + 2) printf '2/%s\n' "$name" ;; + 3) printf '3/%s/%s\n' "${name:0:1}" "$name" ;; + *) printf '%s/%s/%s\n' "${name:0:2}" "${name:2:2}" "$name" ;; + esac +} + +# Whether the registry already carries this version. +# +# A missing entry is the normal case for a first publish and is not an error; +# anything else that goes wrong reads as "not published", and cargo refuses the +# upload on its own if that guess was wrong. +already_published() { + local path body + path=$(index_path "$CRATE") + if [ -d "$INDEX_BASE" ]; then + [ -f "${INDEX_BASE}/${path}" ] || return 1 + body=$(cat "${INDEX_BASE}/${path}") + else + body=$(curl -sSf "${INDEX_BASE}/${path}" 2>/dev/null) || return 1 + fi + # A herestring rather than a pipe: `grep -q` exits at the first match, and + # under `set -o pipefail` the SIGPIPE that gives the writer would become the + # status of the whole pipeline, turning a hit into a miss. + grep -q "\"vers\"[[:space:]]*:[[:space:]]*\"${VERSION}\"" <<<"$body" +} + +if already_published; then + echo "publish: ${CRATE} ${VERSION} is already on the registry; nothing to do" + exit 0 +fi + +# Written as `if` rather than `test && ...`: under `set -e` a false test as a +# bare statement aborts the script. +args=(publish --locked) +if [ "$MODE" = dry-run ]; then args+=(--dry-run); fi +if [ -n "$REGISTRY" ]; then args+=(--registry "$REGISTRY"); fi +if [ "$ALLOW_DIRTY" = 1 ]; then args+=(--allow-dirty); fi + +echo "publish: ${MODE} ${CRATE} ${VERSION}" +cargo "${args[@]}"