From cccc108cd0405853df1726a888f54b407002d03e Mon Sep 17 00:00:00 2001 From: Dave Thompson Date: Tue, 1 Sep 2026 16:38:01 -0400 Subject: [PATCH] chore: add Rust release tooling AI-assisted: GPT-5 Codex Supervised-by: @3leapsdave Role: devlead --- .github/workflows/ci.yml | 15 ++- .github/workflows/release.yml | 95 ++++++++++++++++ .gitignore | 1 + CHANGELOG.md | 12 ++ Cargo.lock | 25 +++-- Cargo.toml | 161 ++++++++------------------- Cargo.toml.orig | 50 --------- Makefile | 104 ++++++++++++++++- README.md | 11 +- RELEASE_CHECKLIST.md | 82 ++++++++++++++ RELEASE_NOTES.md | 5 + UPSTREAM.md | 6 +- VERSION | 1 + docs/releases/v0.9.0.md | 24 ++++ scripts/check-package-contents.py | 45 ++++++++ scripts/check-package.sh | 38 +++++++ scripts/download-release-assets.sh | 14 +++ scripts/export-release-keys.sh | 28 +++++ scripts/generate-checksums.sh | 54 +++++++++ scripts/release-guard-tag-version.sh | 50 +++++++++ scripts/release-tooling-test.sh | 77 +++++++++++++ scripts/run-candidate-check.sh | 22 ++-- scripts/run-diabolical-docker.sh | 7 +- scripts/run-owned-pty-empty.sh | 22 ++-- scripts/sign-release-assets.sh | 38 +++++++ scripts/upload-release-assets.sh | 36 ++++++ scripts/verify-public-keys.sh | 41 +++++++ scripts/verify-signatures.sh | 54 +++++++++ scripts/version-check.py | 67 +++++++++++ 29 files changed, 965 insertions(+), 220 deletions(-) create mode 100644 .github/workflows/release.yml create mode 100644 CHANGELOG.md delete mode 100644 Cargo.toml.orig create mode 100644 RELEASE_CHECKLIST.md create mode 100644 RELEASE_NOTES.md create mode 100644 VERSION create mode 100644 docs/releases/v0.9.0.md create mode 100755 scripts/check-package-contents.py create mode 100755 scripts/check-package.sh create mode 100755 scripts/download-release-assets.sh create mode 100755 scripts/export-release-keys.sh create mode 100755 scripts/generate-checksums.sh create mode 100755 scripts/release-guard-tag-version.sh create mode 100755 scripts/release-tooling-test.sh create mode 100755 scripts/sign-release-assets.sh create mode 100755 scripts/upload-release-assets.sh create mode 100755 scripts/verify-public-keys.sh create mode 100755 scripts/verify-signatures.sh create mode 100755 scripts/version-check.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c33aee3..9978b44 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,8 +23,8 @@ jobs: with: components: rustfmt - run: cargo fmt --all -- --check - - run: sh -n scripts/run-owned-pty-empty.sh - - run: sh -n scripts/run-diabolical-docker.sh + - run: for script in scripts/*.sh; do bash -n "$script"; done + - run: python3 -m py_compile scripts/*.py lint: name: Clippy @@ -45,6 +45,17 @@ jobs: - run: cargo build --all-targets --all-features - run: cargo test --all-targets --all-features + release-package: + name: Release Package Gate + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: dtolnay/rust-toolchain@1.88.0 + with: + components: clippy + - run: make release-tooling-test + - run: make release-check + test: name: Test (${{ matrix.target }}) strategy: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..a8ec605 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,95 @@ +name: Release + +on: + push: + tags: + - 'v*' + +permissions: + contents: read + +env: + CARGO_TERM_COLOR: always + RUSTFLAGS: -Dwarnings + +jobs: + validate-version: + name: Validate Version + runs-on: ubuntu-latest + outputs: + version: ${{ steps.version.outputs.version }} + steps: + - uses: actions/checkout@v7 + with: + fetch-depth: 0 + - uses: dtolnay/rust-toolchain@1.88.0 + - name: Guard exact annotated tag and version pack + id: version + run: | + set -euo pipefail + SYSPRIMS_PTY_TAG_GUARD_MODE=post-tag \ + SYSPRIMS_PTY_RELEASE_TAG="$GITHUB_REF_NAME" \ + ./scripts/release-guard-tag-version.sh + version="$(cat VERSION)" + echo "version=${version}" >> "$GITHUB_OUTPUT" + + package: + name: Package Gate + runs-on: ubuntu-latest + needs: validate-version + steps: + - uses: actions/checkout@v7 + - uses: dtolnay/rust-toolchain@1.88.0 + with: + components: clippy + - run: make check + - run: make release-check + + sbom: + name: Generate SBOM + runs-on: ubuntu-latest + needs: validate-version + container: + image: ghcr.io/fulmenhq/sbom-tools@sha256:3e7de7c2887abd6050c918bdc27cf557a15a00b02a6783c4ba1de716e809bfd4 + options: --user 1001 + steps: + - uses: actions/checkout@v7 + - name: Generate CycloneDX SBOM + run: | + syft dir:. -o cyclonedx-json > "sbom-${{ needs.validate-version.outputs.version }}.cdx.json" + - uses: actions/upload-artifact@v7 + with: + name: sbom + path: sbom-${{ needs.validate-version.outputs.version }}.cdx.json + + draft-release: + name: Draft GitHub Release + runs-on: ubuntu-latest + needs: + - validate-version + - package + - sbom + permissions: + contents: write + steps: + - uses: actions/checkout@v7 + - uses: actions/download-artifact@v8 + with: + name: sbom + path: release + - name: Stage release provenance assets + run: | + set -euo pipefail + version="${{ needs.validate-version.outputs.version }}" + cp LICENSE-MIT release/ + cp "docs/releases/v${version}.md" "release/release-notes-v${version}.md" + find release -maxdepth 1 -type f -print | sort + - name: Create unsigned draft release + uses: softprops/action-gh-release@v2 + with: + draft: true + prerelease: false + fail_on_unmatched_files: true + generate_release_notes: false + body_path: release/release-notes-v${{ needs.validate-version.outputs.version }}.md + files: release/* diff --git a/.gitignore b/.gitignore index b83d222..a742227 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ /target/ +/dist/ diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..c025e88 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,12 @@ +# Changelog + +All notable changes to `sysprims-pty` are documented here. + +## 0.9.0 - 2026-09-01 + +- First public `sysprims-pty` release. +- Publishes the `sysprims-pty` crate while retaining the `portable_pty` + library target for migration compatibility. +- Uses registry-resolvable `sysprims-session` and `sysprims-timeout` 0.2.3 + dependencies. +- Adds release/version/package gates and a draft-only GitHub release workflow. diff --git a/Cargo.lock b/Cargo.lock index 30becbd..ef94d09 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -704,8 +704,9 @@ dependencies = [ [[package]] name = "sysprims-core" -version = "0.2.2" -source = "git+https://github.com/3leaps/sysprims?tag=v0.2.2#e7f74ce65b59e9fa4042a2e937a22ad2630d3994" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2816b40c55c52bf1a2641042d0f52973d09799b95e414817ad7c2003c0c19476" dependencies = [ "rsfulmen", "thiserror", @@ -714,8 +715,9 @@ dependencies = [ [[package]] name = "sysprims-proc" -version = "0.2.2" -source = "git+https://github.com/3leaps/sysprims?tag=v0.2.2#e7f74ce65b59e9fa4042a2e937a22ad2630d3994" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9acb5b479a204fcec9b66eb913a83d206689c8a42d6ee24f3ebbf9be29e17011" dependencies = [ "libc", "serde", @@ -752,8 +754,9 @@ dependencies = [ [[package]] name = "sysprims-session" -version = "0.2.2" -source = "git+https://github.com/3leaps/sysprims?tag=v0.2.2#e7f74ce65b59e9fa4042a2e937a22ad2630d3994" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6bee93310cb3b97c3642d2a0c00a03cdcb8590b13319c6ca36b5b2b779e5a4d7" dependencies = [ "libc", "sysprims-core", @@ -761,8 +764,9 @@ dependencies = [ [[package]] name = "sysprims-signal" -version = "0.2.2" -source = "git+https://github.com/3leaps/sysprims?tag=v0.2.2#e7f74ce65b59e9fa4042a2e937a22ad2630d3994" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7752fd5c913128254b37ded6404b7dac805af97628f9e42493dfe39bf3d27d5c" dependencies = [ "libc", "rsfulmen", @@ -772,8 +776,9 @@ dependencies = [ [[package]] name = "sysprims-timeout" -version = "0.2.2" -source = "git+https://github.com/3leaps/sysprims?tag=v0.2.2#e7f74ce65b59e9fa4042a2e937a22ad2630d3994" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dcf53cf1a0919f3d6b09bd2b418a46f25c2fc5fd979f81d8634b190b1833be48" dependencies = [ "libc", "rsfulmen", diff --git a/Cargo.toml b/Cargo.toml index ca1c89a..3972f54 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,135 +1,72 @@ -# THIS FILE IS AUTOMATICALLY GENERATED BY CARGO -# -# When uploading crates to the registry Cargo will automatically -# "normalize" Cargo.toml files for maximal compatibility -# with all versions of Cargo and also rewrite `path` dependencies -# to registry (e.g., crates.io) dependencies. -# -# If you are reading this file be aware that the original Cargo.toml -# will likely look very different (and much more reasonable). -# See Cargo.toml.orig for the original contents. - [package] -edition = "2018" -rust-version = "1.88.0" name = "sysprims-pty" version = "0.9.0" -authors = [ - "Wez Furlong", - "3 Leaps, LLC", -] -build = false -autolib = false -autobins = false -autoexamples = false -autotests = true -autobenches = false +authors = ["Wez Furlong", "3 Leaps, LLC"] +edition = "2018" +rust-version = "1.88.0" +repository = "https://github.com/3leaps/sysprims-pty" +homepage = "https://github.com/3leaps/sysprims-pty" description = "Cross-platform PTY interface with sysprims containment" documentation = "https://docs.rs/sysprims-pty" readme = "README.md" -exclude = ["Cargo.toml.orig"] license = "MIT" -repository = "https://github.com/3leaps/sysprims-pty" +keywords = ["pty", "terminal", "conpty", "process", "containment"] +categories = ["command-line-interface", "development-tools", "os"] +include = [ + "Cargo.toml", + "Cargo.lock", + "VERSION", + "README.md", + "UPSTREAM.md", + "LICENSE-MIT", + "CHANGELOG.md", + "RELEASE_NOTES.md", + "docs/releases/v0.9.0.md", + "src/**/*.rs", + "tests/**/*.rs", + "examples/**/*.rs", +] +publish = true resolver = "2" [lib] name = "portable_pty" path = "src/lib.rs" -[[example]] -name = "bash" -path = "examples/bash.rs" - -[[example]] -name = "narrow" -path = "examples/narrow.rs" - -[[example]] -name = "whoami" -path = "examples/whoami.rs" - -[[example]] -name = "whoami_async" -path = "examples/whoami_async.rs" - -[dependencies.anyhow] -version = "1.0" - -[dependencies.downcast-rs] -version = "1.0" - -[dependencies.filedescriptor] -version = "0.8.3" - -[dependencies.libc] -version = "0.2" - -[dependencies.log] -version = "0.4" - -[dependencies.nix] -version = "0.28" -features = [ - "term", - "fs", -] - -[dependencies.serde] -version = "1.0" -optional = true - -[dependencies.serde_derive] -version = "1.0" -optional = true - -[dependencies.serial2] -version = "0.2" - -[dependencies.shell-words] -version = "1.1" - -[dependencies.sysprims-timeout] -git = "https://github.com/3leaps/sysprims" -tag = "v0.2.2" -version = "=0.2.2" - -[dev-dependencies.futures] -version = "0.3" - -[dev-dependencies.smol] -version = "2.0" - [features] default = [] -serde_support = [ - "serde", - "serde_derive", -] - -[target."cfg(windows)".dependencies.bitflags] -version = "1.3" - -[target."cfg(windows)".dependencies.lazy_static] -version = "1.4" - -[target."cfg(windows)".dependencies.shared_library] -version = "0.1" - -[target."cfg(windows)".dependencies.winapi] -version = "0.3" -features = [ +serde_support = ["serde", "serde_derive"] + +[dependencies] +anyhow = "1.0" +downcast-rs = "1.0" +filedescriptor = "0.8.3" +log = "0.4" +libc = "0.2" +nix = { version = "0.28", features = ["term", "fs"] } +shell-words = "1.1" +serde_derive = { version = "1.0", optional = true } +serde = { version = "1.0", optional = true } +serial2 = "0.2" +sysprims-timeout = "=0.2.3" + +[target."cfg(windows)".dependencies] +bitflags = "1.3" +lazy_static = "1.4" +shared_library = "0.1" +winapi = { version = "0.3", features = [ "winuser", "consoleapi", "handleapi", "fileapi", "namedpipeapi", "synchapi", -] - -[target."cfg(windows)".dependencies.winreg] -version = "0.10" +]} +winreg = "0.10" [target."cfg(unix)".dependencies.sysprims-session] -git = "https://github.com/3leaps/sysprims" -tag = "v0.2.2" -version = "=0.2.2" +version = "=0.2.3" + +[dev-dependencies] +smol = "2.0" +futures = "0.3" diff --git a/Cargo.toml.orig b/Cargo.toml.orig deleted file mode 100644 index c26e6b1..0000000 --- a/Cargo.toml.orig +++ /dev/null @@ -1,50 +0,0 @@ -[package] -name = "sysprims-pty" -version = "0.9.0" -authors = ["Wez Furlong", "3 Leaps, LLC"] -edition = "2018" -rust-version = "1.88.0" -repository = "https://github.com/3leaps/sysprims-pty" -description = "Cross-platform PTY interface with sysprims containment" -license = "MIT" -documentation = "https://docs.rs/sysprims-pty" -readme = "README.md" -exclude = ["Cargo.toml.orig"] - -[dependencies] -anyhow = "1.0" -downcast-rs = "1.0" -filedescriptor = { version="0.8.3", path = "../filedescriptor" } -log = "0.4" -libc = "0.2" -nix = {version="0.28", features=["term", "fs"]} -shell-words = "1.1" -serde_derive = {version="1.0", optional=true} -serde = {version="1.0", optional=true} -serial2 = "0.2" -sysprims-timeout = { version = "=0.2.2", git = "https://github.com/3leaps/sysprims", tag = "v0.2.2" } - -[features] -default = [] -serde_support = ["serde", "serde_derive"] - -[target."cfg(windows)".dependencies] -bitflags = "1.3" -lazy_static = "1.4" -shared_library = "0.1" -winapi = { version = "0.3", features = [ - "winuser", - "consoleapi", - "handleapi", - "fileapi", - "namedpipeapi", - "synchapi", -]} -winreg = "0.10" - -[target."cfg(unix)".dependencies] -sysprims-session = { version = "=0.2.2", git = "https://github.com/3leaps/sysprims", tag = "v0.2.2" } - -[dev-dependencies] -smol = "2.0" -futures = "0.3" diff --git a/Makefile b/Makefile index a2ccc8a..4952317 100644 --- a/Makefile +++ b/Makefile @@ -1,16 +1,37 @@ .PHONY: help check check-candidate test-owned-pty-empty test-diabolical +.PHONY: version-check release-tooling-test release-guard-tag-version release-guard-tag-version-post +.PHONY: release-check release-preflight release-clean release-download release-notes +.PHONY: release-checksums release-sign release-export-keys release-verify-checksums +.PHONY: release-verify-signatures release-verify-keys release-verify release-upload release + +VERSION := $(shell tr -d '\r\n' < VERSION 2>/dev/null || echo "dev") +SYSPRIMS_PTY_RELEASE_TAG ?= v$(VERSION) +DIST_RELEASE ?= dist/release +CARGO ?= cargo help: @printf '%s\n' \ 'check Run formatting, lint, and host-safe tests' \ 'check-candidate Check the exact sibling sysprims candidate' \ 'test-owned-pty-empty Prove explicit-close and natural-exit PTY cleanup' \ - 'test-diabolical Run hostile containment scenes in disposable Docker' + 'test-diabolical Run hostile containment scenes in disposable Docker' \ + 'version-check Validate VERSION, Cargo.toml, lockfile, and release docs' \ + 'release-tooling-test Run release-tooling negative controls' \ + 'release-check Run package and dry-run publish gates' \ + 'release-preflight Verify clean synced main and all pre-tag gates' \ + 'release-download Download draft release provenance assets' \ + 'release-checksums Copy notes and generate checksum manifests' \ + 'release-sign Sign checksum manifests locally' \ + 'release-export-keys Export public signing keys locally' \ + 'release-verify Verify checksums, signatures, and public-only keys' \ + 'release-upload Upload signed assets; does not undraft release' check: - cargo fmt --all -- --check - cargo clippy --all-targets --all-features -- -D warnings - cargo test --all-targets --all-features + for script in scripts/*.sh; do bash -n "$$script"; done + python3 -m py_compile scripts/*.py + $(CARGO) fmt --all -- --check + $(CARGO) clippy --all-targets --all-features -- -D warnings + $(CARGO) test --all-targets --all-features check-candidate: ./scripts/run-candidate-check.sh @@ -20,3 +41,78 @@ test-owned-pty-empty: test-diabolical: ./scripts/run-diabolical-docker.sh + +version-check: + python3 scripts/version-check.py + +release-tooling-test: + ./scripts/release-tooling-test.sh + +release-guard-tag-version: + @SYSPRIMS_PTY_TAG_GUARD_MODE=$${MODE:-pre-tag} ./scripts/release-guard-tag-version.sh + +release-guard-tag-version-post: + @SYSPRIMS_PTY_TAG_GUARD_MODE=post-tag ./scripts/release-guard-tag-version.sh + +release-check: version-check + ./scripts/check-package.sh + +release-preflight: + @echo "Running release preflight checks..." + @if [ -n "$$(git status --porcelain 2>/dev/null)" ]; then \ + echo "error: working tree not clean" >&2; \ + git status --short >&2; \ + exit 1; \ + fi + @git fetch origin >/dev/null 2>&1 + @if [ "$$(git rev-parse --abbrev-ref HEAD)" != "main" ]; then \ + echo "error: release preflight must run on main" >&2; \ + exit 1; \ + fi + @if [ "$$(git rev-parse HEAD)" != "$$(git rev-parse origin/main)" ]; then \ + echo "error: local main is not synchronized with origin/main" >&2; \ + exit 1; \ + fi + @$(MAKE) release-guard-tag-version MODE=pre-tag --silent + @$(MAKE) check --silent + @$(MAKE) release-check --silent + @echo "[ok] release preflight passed" + +release-clean: + mkdir -p "$(DIST_RELEASE)" + find "$(DIST_RELEASE)" -mindepth 1 -maxdepth 1 -exec rm -r {} + + +release-download: release-clean release-guard-tag-version-post + ./scripts/download-release-assets.sh "$(SYSPRIMS_PTY_RELEASE_TAG)" "$(DIST_RELEASE)" + +release-notes: + mkdir -p "$(DIST_RELEASE)" + cp "docs/releases/$(SYSPRIMS_PTY_RELEASE_TAG).md" "$(DIST_RELEASE)/release-notes-$(SYSPRIMS_PTY_RELEASE_TAG).md" + @echo "[ok] copied release notes" + +release-checksums: release-notes + ./scripts/generate-checksums.sh "$(SYSPRIMS_PTY_RELEASE_TAG)" "$(DIST_RELEASE)" + +release-sign: release-guard-tag-version-post + ./scripts/sign-release-assets.sh "$(SYSPRIMS_PTY_RELEASE_TAG)" "$(DIST_RELEASE)" + +release-export-keys: + ./scripts/export-release-keys.sh "$(DIST_RELEASE)" + +release-verify-checksums: + cd "$(DIST_RELEASE)" && shasum -a 256 -c SHA256SUMS && shasum -a 512 -c SHA512SUMS + +release-verify-signatures: + ./scripts/verify-signatures.sh "$(DIST_RELEASE)" + +release-verify-keys: + ./scripts/verify-public-keys.sh "$(DIST_RELEASE)" + +release-verify: release-verify-checksums release-verify-signatures release-verify-keys + @echo "[ok] release provenance verification passed" + +release-upload: release-guard-tag-version-post release-verify + ./scripts/upload-release-assets.sh "$(SYSPRIMS_PTY_RELEASE_TAG)" "$(DIST_RELEASE)" + +release: release-clean release-download release-checksums release-sign release-export-keys release-verify release-upload + @echo "[ok] release assets uploaded; undraft requires a separate explicit cue" diff --git a/README.md b/README.md index 2386d37..cba2d8b 100644 --- a/README.md +++ b/README.md @@ -19,8 +19,7 @@ an owned, verifiable process lifecycle. The package name is `sysprims-pty`. The library target is `portable_pty`, so existing `portable_pty` imports can keep working after a package rename. -crates.io version coordinates will appear at first publish; they are not -resolvable yet. +The first public version coordinate is `0.9.0`. ## What you can build @@ -88,11 +87,11 @@ opens a real controlling PTY, starts at least one descendant, and proves make test-owned-pty-empty ``` -The runner uses the sibling `../sysprims` checkout by default, verifies a -clean exact revision, then applies it as a local source override in an +The runner uses the sibling `../sysprims` checkout by default, verifies the +reviewed sysprims revision, then applies it as a local source override in an isolated copy. Set `SYSPRIMS_ROOT` to use another checkout. The package -manifest retains the released minimum dependency; this target proves a -separately reviewed candidate. +manifest retains registry-resolvable released dependencies; this target proves +a separately reviewed candidate without changing the public package edges. The target is intentionally separate from ordinary CI because its evidence depends on real Unix PTY and process-group behavior. `make help` lists the diff --git a/RELEASE_CHECKLIST.md b/RELEASE_CHECKLIST.md new file mode 100644 index 0000000..ed258ee --- /dev/null +++ b/RELEASE_CHECKLIST.md @@ -0,0 +1,82 @@ +# Release checklist + +This repository publishes a Rust library crate only. Do not add platform +archives, FFI bundles, Go modules, npm packages, or automated crates.io +publication. + +## Prepare + +- [ ] Confirm `VERSION`, `Cargo.toml`, `CHANGELOG.md`, and + `docs/releases/vX.Y.Z.md` agree. +- [ ] Run `make release-preflight` from a clean `main` synchronized with + `origin/main`. +- [ ] Open and merge the release-prep PR after review assent. + +## Tag + +```bash +VERSION=$(cat VERSION) +git switch main +git pull --ff-only origin main +make release-preflight +git tag -a "v${VERSION}" -m "v${VERSION}" +git push origin "v${VERSION}" +``` + +The tag workflow must create a draft GitHub release only. It must not sign +assets and must not publish to crates.io. + +## First crates.io publication + +Publication is irreversible and requires a separate explicit maintainer cue. + +```bash +VERSION=$(cat VERSION) +git checkout --detach "v${VERSION}" +SYSPRIMS_PTY_REQUIRE_TAG=1 make release-guard-tag-version +cargo info --registry crates-io sysprims-pty +cargo publish --dry-run --locked +cargo publish --locked +cargo info --registry crates-io "sysprims-pty@${VERSION}" +``` + +Stop if the crate name resolves to an unexpected owner or if the dry-run +tarball differs from the reviewed package. + +## Sign and upload release provenance + +After the draft GitHub release exists and the crates.io publish cue has been +handled: + +```bash +export SYSPRIMS_PTY_RELEASE_TAG=v$(cat VERSION) +make release-download +make release-checksums +make release-sign +make release-export-keys +make release-verify +make release-upload +``` + +Publishing the GitHub release is a separate explicit maintainer cue: + +```bash +gh release edit "v$(cat VERSION)" --draft=false +``` + +## Post-release verification + +- [ ] `vX.Y.Z` is annotated and peels to the reviewed commit. +- [ ] GitHub release is public and contains notes, license, SBOM, checksums, + signatures, and public keys. +- [ ] `make release-verify` passes from downloaded assets. +- [ ] `cargo info --registry crates-io sysprims-pty@X.Y.Z` resolves. +- [ ] docs.rs recognizes the published version. +- [ ] A registry-only temporary consumer builds with: + + ```toml + [dependencies] + sysprims-pty = "=X.Y.Z" + ``` + + and imports `portable_pty`. diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md new file mode 100644 index 0000000..b393d0b --- /dev/null +++ b/RELEASE_NOTES.md @@ -0,0 +1,5 @@ +# Release notes + +Release-specific notes are kept in `docs/releases/`. + +- [v0.9.0](docs/releases/v0.9.0.md) diff --git a/UPSTREAM.md b/UPSTREAM.md index 6eb7616..2f529b2 100644 --- a/UPSTREAM.md +++ b/UPSTREAM.md @@ -24,6 +24,6 @@ The compatibility delta is intentionally narrow: - lifecycle, real-PTY, allocator-lock, and compatibility tests; and - mechanical current-Clippy fixes that do not change behavior. -The minimum sysprims contract is `v0.2.2`, commit -`7e5cc03847029dbd316d9f8c0887997bf64a247c`. Compatibility is also checked -against that exact sysprims revision before a companion release is cut. +The minimum sysprims contract is `v0.2.3`, commit +`e366d37bbdbe28764c0f7022577b1999393742cb`. Compatibility is also checked +against that reviewed sysprims revision before a companion release is cut. diff --git a/VERSION b/VERSION new file mode 100644 index 0000000..ac39a10 --- /dev/null +++ b/VERSION @@ -0,0 +1 @@ +0.9.0 diff --git a/docs/releases/v0.9.0.md b/docs/releases/v0.9.0.md new file mode 100644 index 0000000..ee4a1b4 --- /dev/null +++ b/docs/releases/v0.9.0.md @@ -0,0 +1,24 @@ +# sysprims-pty v0.9.0 + +First public release of `sysprims-pty`. + +The crate provides cross-platform PTY APIs with sysprims-backed containment and +keeps the library target named `portable_pty` for source-level migration from +compatible callers. + +## Distribution + +- Crate package: `sysprims-pty = "=0.9.0"` +- Library import: `portable_pty` +- License: MIT + +## Release assets + +The GitHub release carries provenance assets for this source release: + +- `LICENSE-MIT` +- CycloneDX SBOM +- release notes +- checksum manifests +- checksum signatures +- public signing keys diff --git a/scripts/check-package-contents.py b/scripts/check-package-contents.py new file mode 100755 index 0000000..fb7d64c --- /dev/null +++ b/scripts/check-package-contents.py @@ -0,0 +1,45 @@ +#!/usr/bin/env python3 +import re +import subprocess +import sys +from pathlib import Path + +extra_args = sys.argv[1:] +root = Path(__file__).resolve().parents[1] +version = (root / "VERSION").read_text(encoding="utf-8").strip() +allowed = re.compile( + r"^(" + r"\.cargo_vcs_info\.json|" + r"Cargo\.(toml|lock)|" + r"Cargo\.toml\.orig|" + r"README\.md|UPSTREAM\.md|LICENSE-MIT|CHANGELOG\.md|RELEASE_NOTES\.md|VERSION|" + rf"docs/releases/v{re.escape(version)}\.md|" + r"src/.*\.rs|tests/.*\.rs|examples/.*\.rs" + r")$" +) + +result = subprocess.run( + ["cargo", "package", "--list", "--locked", *extra_args], + check=True, + text=True, + stdout=subprocess.PIPE, +) + +bad = [] +for raw in result.stdout.splitlines(): + path = raw.strip() + if not path: + continue + if path.startswith("/") or ".." in path.split("/"): + bad.append(path) + continue + if not allowed.match(path): + bad.append(path) + +if bad: + print("error: unexpected package entries:", file=sys.stderr) + for path in bad: + print(f" {path}", file=sys.stderr) + raise SystemExit(1) + +print("[ok] package contents match allowlist") diff --git a/scripts/check-package.sh b/scripts/check-package.sh new file mode 100755 index 0000000..0321302 --- /dev/null +++ b/scripts/check-package.sh @@ -0,0 +1,38 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(git rev-parse --show-toplevel)" +VERSION="$(tr -d '\r\n' < "$ROOT/VERSION")" +TARGET_DIR="${CARGO_TARGET_DIR:-$ROOT/target}" +PACKAGE_DIR="$TARGET_DIR/package" +CRATE="$PACKAGE_DIR/sysprims-pty-${VERSION}.crate" +UNPACK_DIR="$(mktemp -d "${TMPDIR:-/tmp}/sysprims-pty-package.XXXXXX")" +trap 'rm -rf "$UNPACK_DIR"' EXIT HUP INT TERM + +cd "$ROOT" + +cargo_extra=() +if [ -n "$(git status --porcelain)" ]; then + cargo_extra+=(--allow-dirty) +fi + +python3 scripts/check-package-contents.py "${cargo_extra[@]}" +cargo package --locked "${cargo_extra[@]}" +cargo publish --dry-run --locked "${cargo_extra[@]}" + +test -f "$CRATE" || { + echo "error: expected crate archive not found: $CRATE" >&2 + exit 1 +} + +tar -xzf "$CRATE" -C "$UNPACK_DIR" +cd "$UNPACK_DIR/sysprims-pty-${VERSION}" + +if grep -R "git+https://github.com/3leaps/sysprims" Cargo.lock Cargo.toml >/dev/null 2>&1; then + echo "error: packaged crate contains git-sourced sysprims dependency" >&2 + exit 1 +fi + +CARGO_TARGET_DIR="$TARGET_DIR/package-unpacked" cargo test --all-targets --all-features + +echo "[ok] package, dry-run publish, contents, and unpacked tests passed" diff --git a/scripts/download-release-assets.sh b/scripts/download-release-assets.sh new file mode 100755 index 0000000..19203d2 --- /dev/null +++ b/scripts/download-release-assets.sh @@ -0,0 +1,14 @@ +#!/usr/bin/env bash +set -euo pipefail + +TAG=${1:?"usage: download-release-assets.sh [dest_dir]"} +DEST=${2:-dist/release} + +mkdir -p "$DEST" +find "$DEST" -mindepth 1 -maxdepth 1 -exec rm -r {} + +gh release download "$TAG" --dir "$DEST" --clobber \ + --pattern 'sbom-*.json' \ + --pattern 'LICENSE-*' \ + --pattern 'release-notes-*.md' + +find "$DEST" -maxdepth 1 -type f -print | sort diff --git a/scripts/export-release-keys.sh b/scripts/export-release-keys.sh new file mode 100755 index 0000000..dafe738 --- /dev/null +++ b/scripts/export-release-keys.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash +set -euo pipefail + +DIR=${1:-dist/release} +test -d "$DIR" + +MINISIGN_PUB="${SYSPRIMS_PTY_MINISIGN_PUB:-}" +if [ -z "$MINISIGN_PUB" ] && [ -n "${SYSPRIMS_PTY_MINISIGN_KEY:-}" ]; then + MINISIGN_PUB="${SYSPRIMS_PTY_MINISIGN_KEY%.key}.pub" +fi + +if [ -n "$MINISIGN_PUB" ] && [ -f "$MINISIGN_PUB" ]; then + cp "$MINISIGN_PUB" "$DIR/sysprims-pty-minisign.pub" +else + echo "error: minisign public key not found; set SYSPRIMS_PTY_MINISIGN_PUB" >&2 + exit 1 +fi + +if [ -n "${SYSPRIMS_PTY_PGP_KEY_ID:-}" ]; then + GPG_OPTS=() + if [ -n "${SYSPRIMS_PTY_GPG_HOMEDIR:-}" ]; then + GPG_OPTS+=("--homedir" "$SYSPRIMS_PTY_GPG_HOMEDIR") + fi + gpg "${GPG_OPTS[@]}" --armor --export "$SYSPRIMS_PTY_PGP_KEY_ID" \ + >"$DIR/sysprims-pty-release-signing-key.asc" +fi + +echo "[ok] public keys exported" diff --git a/scripts/generate-checksums.sh b/scripts/generate-checksums.sh new file mode 100755 index 0000000..ceccaca --- /dev/null +++ b/scripts/generate-checksums.sh @@ -0,0 +1,54 @@ +#!/usr/bin/env bash +set -euo pipefail + +TAG=${1:?"usage: generate-checksums.sh [dir]"} +DIR=${2:-dist/release} +VERSION="${TAG#v}" +test -d "$DIR" + +cd "$DIR" + +required=( + "LICENSE-MIT" + "release-notes-${TAG}.md" + "sbom-${VERSION}.cdx.json" +) + +for file in "${required[@]}"; do + test -f "$file" || { + echo "error: required release asset missing: $file" >&2 + exit 1 + } +done + +stale=$( + find . -maxdepth 1 -type f \ + \( \ + \( -name 'release-notes-v*.md' ! -name "release-notes-${TAG}.md" \) -o \ + \( -name 'sbom-*.json' ! -name "sbom-${VERSION}.cdx.json" \) \ + \) \ + -print +) +if [ -n "$stale" ]; then + echo "error: stale or unexpected versioned release assets present:" >&2 + printf '%s\n' "$stale" >&2 + exit 1 +fi + +find . -maxdepth 1 -type f \ + \( -name '*.json' -o -name 'LICENSE-*' -o -name 'release-notes-*.md' \) \ + ! -name 'SHA*' \ + ! -name '*.minisig' \ + ! -name '*.asc' \ + ! -name '*.pub' \ + -print0 | sort -z | xargs -0 shasum -a 256 > SHA256SUMS + +find . -maxdepth 1 -type f \ + \( -name '*.json' -o -name 'LICENSE-*' -o -name 'release-notes-*.md' \) \ + ! -name 'SHA*' \ + ! -name '*.minisig' \ + ! -name '*.asc' \ + ! -name '*.pub' \ + -print0 | sort -z | xargs -0 shasum -a 512 > SHA512SUMS + +echo "[ok] checksum manifests generated" diff --git a/scripts/release-guard-tag-version.sh b/scripts/release-guard-tag-version.sh new file mode 100755 index 0000000..7e3c778 --- /dev/null +++ b/scripts/release-guard-tag-version.sh @@ -0,0 +1,50 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="${SYSPRIMS_PTY_REPO_ROOT:-$(git -C "$SCRIPT_DIR/.." rev-parse --show-toplevel)}" +MODE="${SYSPRIMS_PTY_TAG_GUARD_MODE:-${MODE:-pre-tag}}" +if [ "${SYSPRIMS_PTY_REQUIRE_TAG:-0}" = "1" ]; then + MODE="post-tag" +fi + +cd "$PROJECT_ROOT" + +fail() { + echo "error: $*" >&2 + exit 1 +} + +python3 scripts/version-check.py + +VERSION="$(tr -d '\r\n' < VERSION)" +EXPECTED_TAG="v${VERSION}" +INTENDED_TAG="${SYSPRIMS_PTY_RELEASE_TAG:-$EXPECTED_TAG}" + +[ "$INTENDED_TAG" = "$EXPECTED_TAG" ] || + fail "intended release tag ${INTENDED_TAG} does not equal ${EXPECTED_TAG}" + +case "$MODE" in +pre-tag) + if [ -n "$(git status --porcelain)" ]; then + git status --short >&2 + fail "pre-tag guard requires a clean working tree" + fi + echo "[ok] pre-tag guard: clean coherent pack intends ${EXPECTED_TAG}" + ;; +post-tag) + REF="refs/tags/${EXPECTED_TAG}" + git show-ref --verify --quiet "$REF" || + fail "exact tag ${EXPECTED_TAG} does not exist" + [ "$(git cat-file -t "$REF")" = "tag" ] || + fail "tag ${EXPECTED_TAG} must be annotated" + HEAD_COMMIT="$(git rev-parse 'HEAD^{commit}')" + TAG_COMMIT="$(git rev-parse "${REF}^{commit}")" + [ "$TAG_COMMIT" = "$HEAD_COMMIT" ] || + fail "tag ${EXPECTED_TAG} peels to ${TAG_COMMIT}, not HEAD ${HEAD_COMMIT}" + echo "[ok] post-tag guard: ${EXPECTED_TAG} is annotated and points at HEAD ${HEAD_COMMIT}" + ;; +*) + fail "unknown tag guard mode ${MODE}; expected pre-tag or post-tag" + ;; +esac diff --git a/scripts/release-tooling-test.sh b/scripts/release-tooling-test.sh new file mode 100755 index 0000000..8fa475c --- /dev/null +++ b/scripts/release-tooling-test.sh @@ -0,0 +1,77 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(git rev-parse --show-toplevel)" +VERSION="$(tr -d '\r\n' < "$ROOT/VERSION")" +TMP_ROOT="$(mktemp -d "${TMPDIR:-/tmp}/sysprims-pty-release-tooling.XXXXXX")" +cleanup() { + if [ -n "${TMP_ROOT:-}" ] && [ -d "$TMP_ROOT" ]; then + find "$TMP_ROOT" -mindepth 1 -maxdepth 1 -exec rm -r {} + + rmdir "$TMP_ROOT" + fi +} +trap cleanup EXIT HUP INT TERM + +expect_fail() { + label="$1" + shift + if "$@" >/dev/null 2>&1; then + echo "error: expected failure did not occur: $label" >&2 + exit 1 + fi + echo "[ok] negative control failed as expected: $label" +} + +copy_root="$TMP_ROOT/repo" +mkdir -p "$copy_root" +tar -C "$ROOT" \ + --exclude .git \ + --exclude target \ + --exclude dist \ + --exclude __pycache__ \ + -cf - . | tar -C "$copy_root" -xf - + +git -C "$copy_root" init -q +git -C "$copy_root" add . +git -C "$copy_root" \ + -c user.name="release tooling test" \ + -c user.email="release-tooling-test@example.invalid" \ + commit -q -m "test fixture" + +expect_fail "SYSPRIMS_PTY_REQUIRE_TAG requires an annotated tag" \ + env SYSPRIMS_PTY_REQUIRE_TAG=1 "$copy_root/scripts/release-guard-tag-version.sh" + +asset_dir="$TMP_ROOT/assets" +mkdir -p "$asset_dir" +printf 'license\n' > "$asset_dir/LICENSE-MIT" +printf 'notes\n' > "$asset_dir/release-notes-v${VERSION}.md" +printf 'stale\n' > "$asset_dir/release-notes-v0.8.0.md" +printf '{}\n' > "$asset_dir/sbom-${VERSION}.cdx.json" +expect_fail "checksum generation rejects stale versioned assets" \ + "$ROOT/scripts/generate-checksums.sh" "v${VERSION}" "$asset_dir" + +empty_dir="$TMP_ROOT/empty-keys" +mkdir -p "$empty_dir" +expect_fail "public key verification requires minisign public key" \ + "$ROOT/scripts/verify-public-keys.sh" "$empty_dir" + +pgp_dir="$TMP_ROOT/partial-pgp" +mkdir -p "$pgp_dir" +printf 'x y\n' > "$pgp_dir/SHA256SUMS" +printf 'x y\n' > "$pgp_dir/SHA512SUMS" +printf 'dummy\n' > "$pgp_dir/SHA256SUMS.minisig" +printf 'dummy\n' > "$pgp_dir/SHA512SUMS.minisig" +printf '%s\n' 'untrusted comment: fake public key for negative test' > "$pgp_dir/sysprims-pty-minisign.pub" +printf '%s\n' '-----BEGIN PGP PUBLIC KEY BLOCK-----' '-----END PGP PUBLIC KEY BLOCK-----' \ + > "$pgp_dir/sysprims-pty-release-signing-key.asc" +stub_bin="$TMP_ROOT/stub-bin" +mkdir -p "$stub_bin" +cat > "$stub_bin/minisign" <<'EOF' +#!/bin/sh +exit 0 +EOF +chmod +x "$stub_bin/minisign" +expect_fail "signature verification requires complete PGP signature set" \ + env PATH="$stub_bin:$PATH" "$ROOT/scripts/verify-signatures.sh" "$pgp_dir" + +echo "[ok] release tooling negative controls passed" diff --git a/scripts/run-candidate-check.sh b/scripts/run-candidate-check.sh index 5c1acf0..bcfacaf 100755 --- a/scripts/run-candidate-check.sh +++ b/scripts/run-candidate-check.sh @@ -3,7 +3,7 @@ set -eu companion_root=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) sysprims_root=${SYSPRIMS_ROOT:-"$companion_root/../sysprims"} -reviewed_sysprims_rev=7e5cc03847029dbd316d9f8c0887997bf64a247c +reviewed_sysprims_rev=e366d37bbdbe28764c0f7022577b1999393742cb actual_sysprims_rev=$(git -C "$sysprims_root" rev-parse HEAD) if [ "$actual_sysprims_rev" != "$reviewed_sysprims_rev" ]; then @@ -29,26 +29,20 @@ awk \ -v timeout_path="$sysprims_root/crates/sysprims-timeout" \ -v session_path="$sysprims_root/crates/sysprims-session" \ ' - $0 == "[dependencies.sysprims-timeout]" { - print - print "path = \"" timeout_path "\"" - replacing_timeout = 1 - next - } - replacing_timeout { - if ($0 == "") { - print - replacing_timeout = 0 - } + $0 ~ /^sysprims-timeout = / { + print "sysprims-timeout = { path = \"" timeout_path "\" }" next } $0 == "[target.\"cfg(unix)\".dependencies.sysprims-session]" { print + in_unix = 1 + next + } + in_unix && $0 ~ /^version = / { print "path = \"" session_path "\"" - replacing_session = 1 + in_unix = 0 next } - replacing_session { next } { print } ' "$work_root/companion/Cargo.toml" >"$work_root/Cargo.toml" mv "$work_root/Cargo.toml" "$work_root/companion/Cargo.toml" diff --git a/scripts/run-diabolical-docker.sh b/scripts/run-diabolical-docker.sh index ce92fb9..1b121e1 100755 --- a/scripts/run-diabolical-docker.sh +++ b/scripts/run-diabolical-docker.sh @@ -3,7 +3,7 @@ set -eu companion_root=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) sysprims_root=${SYSPRIMS_ROOT:-"$companion_root/../sysprims"} -reviewed_sysprims_rev=7e5cc03847029dbd316d9f8c0887997bf64a247c +reviewed_sysprims_rev=e366d37bbdbe28764c0f7022577b1999393742cb if ! docker info >/dev/null 2>&1; then echo "docker daemon unavailable; start the reviewed disposable runtime" >&2 @@ -22,10 +22,7 @@ docker run --rm \ cp -R /work/companion /tmp/companion cd /tmp/companion sed -i \ - -e "/^\[dependencies.sysprims-timeout\]$/,/^$/c\\ -[dependencies.sysprims-timeout]\\ -path = \"/work/sysprims/crates/sysprims-timeout\"\\ -" \ + -e "s|^sysprims-timeout = .*|sysprims-timeout = { path = \"/work/sysprims/crates/sysprims-timeout\" }|" \ -e "/^\[target.\"cfg(unix)\".dependencies.sysprims-session\]$/,\$c\\ [target.\"cfg(unix)\".dependencies.sysprims-session]\\ path = \"/work/sysprims/crates/sysprims-session\"\\ diff --git a/scripts/run-owned-pty-empty.sh b/scripts/run-owned-pty-empty.sh index 10e0392..774bad3 100755 --- a/scripts/run-owned-pty-empty.sh +++ b/scripts/run-owned-pty-empty.sh @@ -3,7 +3,7 @@ set -eu companion_root=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) sysprims_root=${SYSPRIMS_ROOT:-"$companion_root/../sysprims"} -reviewed_sysprims_rev=7e5cc03847029dbd316d9f8c0887997bf64a247c +reviewed_sysprims_rev=e366d37bbdbe28764c0f7022577b1999393742cb case "$(uname -s)" in Darwin|Linux) ;; @@ -37,26 +37,20 @@ awk \ -v timeout_path="$sysprims_root/crates/sysprims-timeout" \ -v session_path="$sysprims_root/crates/sysprims-session" \ ' - $0 == "[dependencies.sysprims-timeout]" { - print - print "path = \"" timeout_path "\"" - replacing_timeout = 1 - next - } - replacing_timeout { - if ($0 == "") { - print - replacing_timeout = 0 - } + $0 ~ /^sysprims-timeout = / { + print "sysprims-timeout = { path = \"" timeout_path "\" }" next } $0 == "[target.\"cfg(unix)\".dependencies.sysprims-session]" { print + in_unix = 1 + next + } + in_unix && $0 ~ /^version = / { print "path = \"" session_path "\"" - replacing_session = 1 + in_unix = 0 next } - replacing_session { next } { print } ' "$work_root/companion/Cargo.toml" >"$work_root/Cargo.toml" mv "$work_root/Cargo.toml" "$work_root/companion/Cargo.toml" diff --git a/scripts/sign-release-assets.sh b/scripts/sign-release-assets.sh new file mode 100755 index 0000000..77c9682 --- /dev/null +++ b/scripts/sign-release-assets.sh @@ -0,0 +1,38 @@ +#!/usr/bin/env bash +set -euo pipefail + +TAG=${1:?"usage: sign-release-assets.sh [dir]"} +DIR=${2:-dist/release} + +test -d "$DIR" + +if [ -z "${SYSPRIMS_PTY_MINISIGN_KEY:-}" ]; then + echo "error: SYSPRIMS_PTY_MINISIGN_KEY is required" >&2 + exit 1 +fi +test -f "$SYSPRIMS_PTY_MINISIGN_KEY" + +cd "$DIR" + +for manifest in SHA256SUMS SHA512SUMS; do + test -f "$manifest" + minisign -S -s "$SYSPRIMS_PTY_MINISIGN_KEY" \ + -m "$manifest" \ + -t "sysprims-pty $TAG - $(date -u +%Y-%m-%dT%H:%M:%SZ)" \ + -x "${manifest}.minisig" +done + +if [ -n "${SYSPRIMS_PTY_PGP_KEY_ID:-}" ]; then + GPG_OPTS=() + if [ -n "${SYSPRIMS_PTY_GPG_HOMEDIR:-}" ]; then + GPG_OPTS+=("--homedir" "$SYSPRIMS_PTY_GPG_HOMEDIR") + fi + for manifest in SHA256SUMS SHA512SUMS; do + gpg "${GPG_OPTS[@]}" --armor --detach-sign \ + --local-user "$SYSPRIMS_PTY_PGP_KEY_ID" \ + --output "${manifest}.asc" \ + "$manifest" + done +fi + +echo "[ok] release manifests signed" diff --git a/scripts/upload-release-assets.sh b/scripts/upload-release-assets.sh new file mode 100755 index 0000000..ab2602c --- /dev/null +++ b/scripts/upload-release-assets.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env bash +set -euo pipefail + +TAG=${1:?"usage: upload-release-assets.sh [dir]"} +DIR=${2:-dist/release} +test -d "$DIR" + +cd "$DIR" + +required=( + SHA256SUMS + SHA256SUMS.minisig + SHA512SUMS + SHA512SUMS.minisig + sysprims-pty-minisign.pub + "release-notes-${TAG}.md" +) + +for file in "${required[@]}"; do + test -f "$file" || { + echo "error: required upload file missing: $file" >&2 + exit 1 + } +done + +uploads=("${required[@]}") +for optional in SHA256SUMS.asc SHA512SUMS.asc sysprims-pty-release-signing-key.asc; do + if [ -f "$optional" ]; then + uploads+=("$optional") + fi +done + +gh release upload "$TAG" "${uploads[@]}" --clobber +gh release edit "$TAG" --notes-file "release-notes-${TAG}.md" + +echo "[ok] signed assets uploaded; release remains draft until an explicit undraft cue" diff --git a/scripts/verify-public-keys.sh b/scripts/verify-public-keys.sh new file mode 100755 index 0000000..e7724f6 --- /dev/null +++ b/scripts/verify-public-keys.sh @@ -0,0 +1,41 @@ +#!/usr/bin/env bash +set -euo pipefail + +DIR=${1:-dist/release} +test -d "$DIR" + +cd "$DIR" + +errors=0 + +if [ ! -f sysprims-pty-minisign.pub ]; then + echo "error: missing required sysprims-pty-minisign.pub" >&2 + errors=$((errors + 1)) +elif grep -qi "secret" sysprims-pty-minisign.pub; then + echo "error: minisign public key file appears to contain secret material" >&2 + errors=$((errors + 1)) +elif ! grep -q "^untrusted comment:" sysprims-pty-minisign.pub; then + echo "error: minisign public key has unexpected format" >&2 + errors=$((errors + 1)) +fi + +if [ -f SHA256SUMS.asc ] || [ -f SHA512SUMS.asc ]; then + if [ ! -f sysprims-pty-release-signing-key.asc ]; then + echo "error: PGP signatures exist but sysprims-pty-release-signing-key.asc is missing" >&2 + errors=$((errors + 1)) + fi +fi + +if [ -f sysprims-pty-release-signing-key.asc ]; then + if grep -q "PRIVATE KEY BLOCK" sysprims-pty-release-signing-key.asc; then + echo "error: PGP export contains private key material" >&2 + errors=$((errors + 1)) + fi + if ! grep -q "PUBLIC KEY BLOCK" sysprims-pty-release-signing-key.asc; then + echo "error: PGP export has unexpected format" >&2 + errors=$((errors + 1)) + fi +fi + +test "$errors" -eq 0 +echo "[ok] exported keys are public-only" diff --git a/scripts/verify-signatures.sh b/scripts/verify-signatures.sh new file mode 100755 index 0000000..741e0f5 --- /dev/null +++ b/scripts/verify-signatures.sh @@ -0,0 +1,54 @@ +#!/usr/bin/env bash +set -euo pipefail + +DIR=${1:-dist/release} +test -d "$DIR" + +cd "$DIR" + +errors=0 + +if [ ! -f sysprims-pty-minisign.pub ]; then + echo "error: missing sysprims-pty-minisign.pub" >&2 + errors=$((errors + 1)) +else + for manifest in SHA256SUMS SHA512SUMS; do + if [ -f "$manifest" ] && [ -f "${manifest}.minisig" ]; then + minisign -Vm "$manifest" -p sysprims-pty-minisign.pub || errors=$((errors + 1)) + else + echo "error: missing $manifest or ${manifest}.minisig" >&2 + errors=$((errors + 1)) + fi + done +fi + +pgp_artifacts=0 +for file in sysprims-pty-release-signing-key.asc SHA256SUMS.asc SHA512SUMS.asc; do + if [ -f "$file" ]; then + pgp_artifacts=1 + fi +done + +if [ "$pgp_artifacts" -eq 1 ]; then + for file in sysprims-pty-release-signing-key.asc SHA256SUMS.asc SHA512SUMS.asc; do + if [ ! -f "$file" ]; then + echo "error: incomplete PGP signature set; missing $file" >&2 + errors=$((errors + 1)) + fi + done +fi + +if [ -f sysprims-pty-release-signing-key.asc ] && [ -f SHA256SUMS.asc ] && [ -f SHA512SUMS.asc ]; then + GNUPGHOME="$(mktemp -d)" + export GNUPGHOME + trap 'rm -rf "$GNUPGHOME"' EXIT + gpg --import sysprims-pty-release-signing-key.asc >/dev/null 2>&1 + for manifest in SHA256SUMS SHA512SUMS; do + if [ -f "${manifest}.asc" ]; then + gpg --verify "${manifest}.asc" "$manifest" >/dev/null 2>&1 || errors=$((errors + 1)) + fi + done +fi + +test "$errors" -eq 0 +echo "[ok] release signatures verified" diff --git a/scripts/version-check.py b/scripts/version-check.py new file mode 100755 index 0000000..1c57fe1 --- /dev/null +++ b/scripts/version-check.py @@ -0,0 +1,67 @@ +#!/usr/bin/env python3 +import re +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +SEMVER = re.compile(r"^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$") + + +def fail(message: str) -> None: + print(f"error: {message}", file=sys.stderr) + raise SystemExit(1) + + +def read(path: str) -> str: + return (ROOT / path).read_text(encoding="utf-8") + + +version_file = read("VERSION") +if not version_file.endswith("\n") or version_file.count("\n") != 1: + fail("VERSION must contain exactly one SemVer followed by LF") + +version = version_file.strip() +if not SEMVER.fullmatch(version): + fail(f"VERSION is not SemVer: {version}") + +cargo = read("Cargo.toml") +package = re.search(r'(?m)^version = "([^"]+)"$', cargo) +if not package: + fail("Cargo.toml package version not found") +if package.group(1) != version: + fail(f"Cargo.toml version {package.group(1)} does not match VERSION {version}") + +required = { + 'sysprims-timeout = "=0.2.3"': "sysprims-timeout registry dependency", + 'version = "=0.2.3"': "sysprims-session registry dependency", + "publish = true": "publish flag", +} +for needle, label in required.items(): + if needle not in cargo: + fail(f"missing {label}: {needle}") + +if "git =" in cargo: + fail("Cargo.toml must not contain git dependencies for release packaging") +if "Cargo.toml.orig" in cargo: + fail("Cargo.toml must not reference Cargo.toml.orig") +if (ROOT / "Cargo.toml.orig").exists(): + fail("Cargo.toml.orig must not exist") + +for path in ("CHANGELOG.md", "RELEASE_NOTES.md", f"docs/releases/v{version}.md"): + if not (ROOT / path).is_file(): + fail(f"missing release document: {path}") + +lock = read("Cargo.lock") +if "git+https://github.com/3leaps/sysprims" in lock: + fail("Cargo.lock still contains git-sourced sysprims packages") +for crate in ("sysprims-session", "sysprims-timeout"): + pattern = re.compile( + rf'(?s)\[\[package\]\]\nname = "{re.escape(crate)}"\nversion = "([^"]+)"\nsource = "registry\+https://github.com/rust-lang/crates.io-index"' + ) + match = pattern.search(lock) + if not match: + fail(f"Cargo.lock missing registry source for {crate}") + if match.group(1) != "0.2.3": + fail(f"Cargo.lock has {crate} {match.group(1)}, expected 0.2.3") + +print(f"[ok] version pack is coherent for v{version}")