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
16 changes: 16 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
.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
.PHONY: release-verify-tag-workflow release-verify-draft-assets release-download-signed release-smoke-consumer

VERSION := $(shell tr -d '\r\n' < VERSION 2>/dev/null || echo "dev")
SYSPRIMS_PTY_RELEASE_TAG ?= v$(VERSION)
Expand All @@ -20,10 +21,13 @@ help:
'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-download-signed Download signed release assets for verification' \
'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-verify-tag-workflow Verify tag Release workflow success' \
'release-smoke-consumer Verify registry-only consumer after crates.io publish' \
'release-upload Upload signed assets; does not undraft release'

check:
Expand Down Expand Up @@ -85,6 +89,15 @@ release-clean:
release-download: release-clean release-guard-tag-version-post
./scripts/download-release-assets.sh "$(SYSPRIMS_PTY_RELEASE_TAG)" "$(DIST_RELEASE)"

release-download-signed: release-clean release-guard-tag-version-post
./scripts/download-signed-release-assets.sh "$(SYSPRIMS_PTY_RELEASE_TAG)" "$(DIST_RELEASE)"

release-verify-tag-workflow: release-guard-tag-version-post
./scripts/verify-tag-release-workflow.sh "$(SYSPRIMS_PTY_RELEASE_TAG)"

release-verify-draft-assets: release-guard-tag-version-post
./scripts/verify-release-draft-assets.sh "$(SYSPRIMS_PTY_RELEASE_TAG)" "$${MODE:-unsigned}"

release-notes:
mkdir -p "$(DIST_RELEASE)"
cp "docs/releases/$(SYSPRIMS_PTY_RELEASE_TAG).md" "$(DIST_RELEASE)/release-notes-$(SYSPRIMS_PTY_RELEASE_TAG).md"
Expand Down Expand Up @@ -114,5 +127,8 @@ release-verify: release-verify-checksums release-verify-signatures release-verif
release-upload: release-guard-tag-version-post release-verify
./scripts/upload-release-assets.sh "$(SYSPRIMS_PTY_RELEASE_TAG)" "$(DIST_RELEASE)"

release-smoke-consumer:
./scripts/smoke-registry-consumer.sh

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"
59 changes: 56 additions & 3 deletions RELEASE_CHECKLIST.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,28 @@ 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.

Before any crates.io upload, require the tag workflow to be green on the exact
tag and verify the unsigned draft release asset set:

```bash
VERSION=$(cat VERSION)
SYSPRIMS_PTY_REQUIRE_TAG=1 make release-guard-tag-version
make release-verify-tag-workflow
make release-verify-draft-assets
```

Expected unsigned draft assets:

- `LICENSE-MIT`
- `release-notes-vX.Y.Z.md`
- `sbom-X.Y.Z.cdx.json`

## First crates.io publication

Publication is irreversible and requires a separate explicit maintainer cue.
Use `CARGO_REGISTRY_TOKEN` or an equivalent Cargo credential source with the
minimum required crate scope. Do not write token material or owner identities
to tracked files, release notes, PR text, or planning files.

```bash
VERSION=$(cat VERSION)
Expand All @@ -48,6 +67,20 @@ tarball differs from the reviewed package.
After the draft GitHub release exists and the crates.io publish cue has been
handled:

Required local signing environment:

- `SYSPRIMS_PTY_RELEASE_TAG`: release tag with the leading `v`, for example
`v0.9.0`.
- `SYSPRIMS_PTY_MINISIGN_KEY`: minisign secret key path.
- `SYSPRIMS_PTY_MINISIGN_PUB`: minisign public key path. If omitted, the
tooling derives it from `SYSPRIMS_PTY_MINISIGN_KEY` by replacing `.key` with
`.pub`.

Optional PGP signing is enabled by key id:

- `SYSPRIMS_PTY_PGP_KEY_ID`: PGP signing key id.
- `SYSPRIMS_PTY_GPG_HOMEDIR`: optional alternate GPG home, if needed.

```bash
export SYSPRIMS_PTY_RELEASE_TAG=v$(cat VERSION)
make release-download
Expand All @@ -58,7 +91,25 @@ make release-verify
make release-upload
```

Publishing the GitHub release is a separate explicit maintainer cue:
After upload, the release must still be a draft and must contain the signed
asset inventory:

```bash
make release-verify-draft-assets MODE=signed
```

Before publishing the GitHub release, run the registry-only consumer smoke from
a clean checkout after crates.io shows the package:

```bash
cargo info --registry crates-io "sysprims-pty@$(cat VERSION)"
make release-smoke-consumer
make release-download-signed
make release-verify
```

Publishing the GitHub release is a separate explicit maintainer cue after the
checks above:

```bash
gh release edit "v$(cat VERSION)" --draft=false
Expand All @@ -69,10 +120,12 @@ gh release edit "v$(cat VERSION)" --draft=false
- [ ] `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.
- [ ] `make release-download-signed` then `make release-verify` pass from
freshly downloaded signed 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:
- [ ] `make release-smoke-consumer` passes with a registry-only temporary
consumer that builds with:

```toml
[dependencies]
Expand Down
24 changes: 24 additions & 0 deletions scripts/download-signed-release-assets.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
#!/usr/bin/env bash
set -euo pipefail

TAG=${1:?"usage: download-signed-release-assets.sh <tag> [dest_dir]"}
DEST=${2:-dist/release}
VERSION="${TAG#v}"

mkdir -p "$DEST"
find "$DEST" -mindepth 1 -maxdepth 1 -exec rm -r {} +

gh release download "$TAG" --dir "$DEST" --clobber \
--pattern "sbom-${VERSION}.cdx.json" \
--pattern 'LICENSE-MIT' \
--pattern "release-notes-${TAG}.md" \
--pattern 'SHA256SUMS' \
--pattern 'SHA256SUMS.minisig' \
--pattern 'SHA512SUMS' \
--pattern 'SHA512SUMS.minisig' \
--pattern 'sysprims-pty-minisign.pub' \
--pattern 'SHA256SUMS.asc' \
--pattern 'SHA512SUMS.asc' \
--pattern 'sysprims-pty-release-signing-key.asc'

find "$DEST" -maxdepth 1 -type f -print | sort
144 changes: 137 additions & 7 deletions scripts/release-tooling-test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -38,9 +38,109 @@ git -C "$copy_root" \
-c user.email="release-tooling-test@example.invalid" \
commit -q -m "test fixture"

stub_bin="$TMP_ROOT/stub-bin"
mkdir -p "$stub_bin"
cat > "$stub_bin/minisign" <<'EOF'
#!/bin/sh
exit 0
EOF
cat > "$stub_bin/gh" <<'EOF'
#!/usr/bin/env bash
set -euo pipefail

if [ "$1" = "run" ] && [ "$2" = "list" ]; then
case "${GH_STUB_MODE:-}" in
workflow-success)
printf '[{"databaseId":1,"headBranch":"%s","headSha":"%s","status":"completed","conclusion":"success","url":"https://example.invalid/run"}]\n' "$GH_STUB_TAG" "$GH_STUB_SHA"
;;
workflow-pending)
printf '[{"databaseId":1,"headBranch":"%s","headSha":"%s","status":"in_progress","conclusion":"","url":"https://example.invalid/run"}]\n' "$GH_STUB_TAG" "$GH_STUB_SHA"
;;
*)
printf '[]\n'
;;
esac
exit 0
fi

if [ "$1" = "release" ] && [ "$2" = "view" ]; then
if printf '%s\n' "$*" | grep -Fq ".isDraft"; then
case "${GH_STUB_MODE:-}" in
release-nondraft) printf 'false\n' ;;
*) printf 'true\n' ;;
esac
exit 0
fi
if printf '%s\n' "$*" | grep -Fq ".assets[].name"; then
case "${GH_STUB_MODE:-}" in
release-unsigned-good)
printf '%s\n' LICENSE-MIT "release-notes-${GH_STUB_TAG}.md" "sbom-${GH_STUB_VERSION}.cdx.json"
;;
release-unsigned-missing)
printf '%s\n' LICENSE-MIT "release-notes-${GH_STUB_TAG}.md"
;;
release-signed-good)
printf '%s\n' \
LICENSE-MIT \
"release-notes-${GH_STUB_TAG}.md" \
"sbom-${GH_STUB_VERSION}.cdx.json" \
SHA256SUMS \
SHA256SUMS.minisig \
SHA512SUMS \
SHA512SUMS.minisig \
sysprims-pty-minisign.pub
;;
release-signed-unexpected)
printf '%s\n' \
LICENSE-MIT \
"release-notes-${GH_STUB_TAG}.md" \
"sbom-${GH_STUB_VERSION}.cdx.json" \
SHA256SUMS \
SHA256SUMS.minisig \
SHA512SUMS \
SHA512SUMS.minisig \
sysprims-pty-minisign.pub \
stale-extra.zip
;;
*)
printf '\n'
;;
esac
exit 0
fi
fi

if [ "$1" = "release" ] && { [ "$2" = "upload" ] || [ "$2" = "edit" ]; }; then
printf '%s\n' "$*" >> "${GH_STUB_MUTATION_LOG:?}"
exit 0
fi

echo "unexpected gh stub call: $*" >&2
exit 1
EOF
chmod +x "$stub_bin/minisign" "$stub_bin/gh"

expect_fail "SYSPRIMS_PTY_REQUIRE_TAG requires an annotated tag" \
env SYSPRIMS_PTY_REQUIRE_TAG=1 "$copy_root/scripts/release-guard-tag-version.sh"

git -C "$copy_root" \
-c user.name="release tooling test" \
-c user.email="release-tooling-test@example.invalid" \
tag -a "v${VERSION}" -m "v${VERSION}"
copy_sha="$(git -C "$copy_root" rev-parse "refs/tags/v${VERSION}^{commit}")"
expect_fail "tag workflow verifier requires completed success on exact tag commit" \
env PATH="$stub_bin:$PATH" \
GH_STUB_MODE=workflow-pending \
GH_STUB_TAG="v${VERSION}" \
GH_STUB_SHA="$copy_sha" \
"$copy_root/scripts/verify-tag-release-workflow.sh" "v${VERSION}"
env PATH="$stub_bin:$PATH" \
GH_STUB_MODE=workflow-success \
GH_STUB_TAG="v${VERSION}" \
GH_STUB_SHA="$copy_sha" \
"$copy_root/scripts/verify-tag-release-workflow.sh" "v${VERSION}" >/dev/null
echo "[ok] tag workflow verifier accepts completed success on exact tag commit"

asset_dir="$TMP_ROOT/assets"
mkdir -p "$asset_dir"
printf 'license\n' > "$asset_dir/LICENSE-MIT"
Expand All @@ -55,6 +155,26 @@ mkdir -p "$empty_dir"
expect_fail "public key verification requires minisign public key" \
"$ROOT/scripts/verify-public-keys.sh" "$empty_dir"

expect_fail "unsigned draft inventory rejects missing assets" \
env PATH="$stub_bin:$PATH" \
GH_STUB_MODE=release-unsigned-missing \
GH_STUB_TAG="v${VERSION}" \
GH_STUB_VERSION="$VERSION" \
"$ROOT/scripts/verify-release-draft-assets.sh" "v${VERSION}" unsigned
env PATH="$stub_bin:$PATH" \
GH_STUB_MODE=release-unsigned-good \
GH_STUB_TAG="v${VERSION}" \
GH_STUB_VERSION="$VERSION" \
"$ROOT/scripts/verify-release-draft-assets.sh" "v${VERSION}" unsigned >/dev/null
echo "[ok] unsigned draft inventory accepts exact assets"

expect_fail "signed draft inventory rejects unexpected assets" \
env PATH="$stub_bin:$PATH" \
GH_STUB_MODE=release-signed-unexpected \
GH_STUB_TAG="v${VERSION}" \
GH_STUB_VERSION="$VERSION" \
"$ROOT/scripts/verify-release-draft-assets.sh" "v${VERSION}" signed

pgp_dir="$TMP_ROOT/partial-pgp"
mkdir -p "$pgp_dir"
printf 'x y\n' > "$pgp_dir/SHA256SUMS"
Expand All @@ -64,14 +184,24 @@ 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"

upload_dir="$TMP_ROOT/upload"
mkdir -p "$upload_dir"
for file in SHA256SUMS SHA256SUMS.minisig SHA512SUMS SHA512SUMS.minisig sysprims-pty-minisign.pub "release-notes-v${VERSION}.md"; do
printf 'dummy\n' > "$upload_dir/$file"
done
mutation_log="$TMP_ROOT/gh-mutations.log"
expect_fail "upload refuses non-draft release before mutation" \
env PATH="$stub_bin:$PATH" \
GH_STUB_MODE=release-nondraft \
GH_STUB_MUTATION_LOG="$mutation_log" \
"$ROOT/scripts/upload-release-assets.sh" "v${VERSION}" "$upload_dir"
if [ -e "$mutation_log" ]; then
echo "error: non-draft upload attempted release mutation" >&2
exit 1
fi
echo "[ok] non-draft upload exits before release mutation"

echo "[ok] release tooling negative controls passed"
46 changes: 46 additions & 0 deletions scripts/smoke-registry-consumer.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
#!/usr/bin/env bash
set -euo pipefail

ROOT="$(git rev-parse --show-toplevel)"
VERSION="$(tr -d '\r\n' < "$ROOT/VERSION")"
WORK_DIR="$(mktemp -d "${TMPDIR:-/tmp}/sysprims-pty-consumer.XXXXXX")"
cleanup() {
if [ -n "${WORK_DIR:-}" ] && [ -d "$WORK_DIR" ]; then
find "$WORK_DIR" -mindepth 1 -maxdepth 1 -exec rm -r {} +
rmdir "$WORK_DIR"
fi
}
trap cleanup EXIT HUP INT TERM

cd "$WORK_DIR"
cargo init --bin --quiet

cat > Cargo.toml <<EOF
[package]
name = "sysprims-pty-consumer-smoke"
version = "0.0.0"
edition = "2021"
publish = false

[dependencies]
sysprims-pty = "=${VERSION}"
EOF

cat > src/main.rs <<'EOF'
use portable_pty::CommandBuilder;

fn main() {
let _cmd = CommandBuilder::new("true");
}
EOF

cargo generate-lockfile
cargo build --locked
cargo tree --locked | grep -F "sysprims-pty v${VERSION}"

if cargo tree --locked | grep -F "git+"; then
echo "error: registry consumer resolved a git dependency" >&2
exit 1
fi

echo "[ok] registry-only consumer builds with sysprims-pty ${VERSION}"
7 changes: 7 additions & 0 deletions scripts/upload-release-assets.sh
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,11 @@ TAG=${1:?"usage: upload-release-assets.sh <tag> [dir]"}
DIR=${2:-dist/release}
test -d "$DIR"

if [ "$(gh release view "$TAG" --json isDraft --jq '.isDraft')" != "true" ]; then
echo "error: release ${TAG} is not draft; refusing to mutate published release" >&2
exit 1
fi

cd "$DIR"

required=(
Expand Down Expand Up @@ -32,5 +37,7 @@ done

gh release upload "$TAG" "${uploads[@]}" --clobber
gh release edit "$TAG" --notes-file "release-notes-${TAG}.md"
cd - >/dev/null
"$(dirname "$0")/verify-release-draft-assets.sh" "$TAG" signed

echo "[ok] signed assets uploaded; release remains draft until an explicit undraft cue"
Loading