From cfbe11b0da69896166f998742c1cf9c4a7d1fca0 Mon Sep 17 00:00:00 2001 From: Dave Thompson Date: Wed, 22 Jul 2026 12:26:49 -0400 Subject: [PATCH] release: standardize signed tag ceremony Add fail-closed Make targets for release identity, live tag-policy validation, GPG-signed tag creation, guarded publication, and local/remote verification. Bind every release boundary to the literal check-verified commit, authorized primary fingerprint, exact tagger identity, direct annotated-tag target, and canonical ruleset attestation. Add hermetic negative controls to the complete local gate while preserving the pure-Go test target. Generated by GPT-5 via Codex under supervision of @3leapsdave Role: devlead Co-authored-by: GPT-5 --- Makefile | 36 ++- RELEASE_CHECKLIST.md | 112 ++++++--- scripts/release-common.sh | 328 +++++++++++++++++++++++++++ scripts/release-guard-signing-env.sh | 13 ++ scripts/release-guard-tag-ruleset.sh | 118 ++++++++++ scripts/release-guard-tag-version.sh | 14 ++ scripts/release-push-tag.sh | 20 ++ scripts/release-tag.sh | 38 ++++ scripts/release-verify-remote-tag.sh | 39 ++++ scripts/release-verify-tag.sh | 15 ++ scripts/test-release-controls.sh | 303 +++++++++++++++++++++++++ 11 files changed, 1007 insertions(+), 29 deletions(-) create mode 100755 scripts/release-common.sh create mode 100755 scripts/release-guard-signing-env.sh create mode 100755 scripts/release-guard-tag-ruleset.sh create mode 100755 scripts/release-guard-tag-version.sh create mode 100755 scripts/release-push-tag.sh create mode 100755 scripts/release-tag.sh create mode 100755 scripts/release-verify-remote-tag.sh create mode 100755 scripts/release-verify-tag.sh create mode 100755 scripts/test-release-controls.sh diff --git a/Makefile b/Makefile index 7cc4a82..3971dcc 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,5 @@ .PHONY: all help fmt test policy build build-linux-arm64 gitleaks provability drift-check generated-real-check contract check-all clean +.PHONY: release-control-test release-guard-signing-env release-guard-tag-version release-guard-tag-ruleset release-tag release-push-tag release-verify-tag release-verify-remote-tag BINARY_NAME := synthcorpus-gen BINARY_EXT := @@ -25,6 +26,14 @@ help: ' generated-real-check Property-only dogfood check (needs pinned decernor + sidecars)' \ ' contract Run both decernor consumer-contract lanes' \ ' check-all fmt + tests + build + scanners + proofs + contract + diff --check' \ + ' release-control-test Hermetic negative tests for release tooling' \ + ' release-guard-signing-env Validate the operator-private signing environment' \ + ' release-guard-tag-version Verify release tag and commit inputs' \ + ' release-guard-tag-ruleset Verify the live version-tag publication policy' \ + ' release-tag Create and verify the signed release tag locally' \ + ' release-push-tag Recheck and push the signed release tag' \ + ' release-verify-tag Verify an existing signed release tag' \ + ' release-verify-remote-tag Verify GitHub tag signature state and target' \ ' clean Remove local build artifacts' fmt: @@ -33,6 +42,10 @@ fmt: test: go test ./... +release-control-test: + @bash -n scripts/*.sh + @./scripts/test-release-controls.sh + policy: go test ./internal/repopolicy/ -count=1 @@ -62,10 +75,31 @@ generated-real-check: contract: drift-check generated-real-check -check-all: fmt test policy build gitleaks provability contract +check-all: fmt test policy release-control-test build gitleaks provability contract @git diff --check @git diff --check $(DIFF_BASE)...HEAD @echo 'check-all ok' +release-guard-signing-env: + @./scripts/release-guard-signing-env.sh + +release-guard-tag-version: + @./scripts/release-guard-tag-version.sh + +release-guard-tag-ruleset: + @./scripts/release-guard-tag-ruleset.sh + +release-tag: + @./scripts/release-tag.sh + +release-push-tag: + @./scripts/release-push-tag.sh + +release-verify-tag: + @./scripts/release-verify-tag.sh + +release-verify-remote-tag: + @./scripts/release-verify-remote-tag.sh + clean: rm -rf bin diff --git a/RELEASE_CHECKLIST.md b/RELEASE_CHECKLIST.md index f544b45..ddac16e 100644 --- a/RELEASE_CHECKLIST.md +++ b/RELEASE_CHECKLIST.md @@ -11,18 +11,38 @@ committed-synthetic corpus, and no generated-real material. This intentionally follows [`docs/decisions/ADR-0002-no-publish-no-worktree-generator.md`](docs/decisions/ADR-0002-no-publish-no-worktree-generator.md). +## Release environment + +The maintainer loads the signing identity from the operator-private release +environment and sets the release tag and literal check-verified commit. The +dedicated keyring must remain outside every Git worktree. + +- `THREELEAPS_SYNTHCORPUS_RELEASE_TAG`: the intended `vMAJOR.MINOR.PATCH` tag; +- `THREELEAPS_SYNTHCORPUS_RELEASE_COMMIT`: the full lowercase 40-hex commit SHA + on which the release gates and hosted checks passed; +- `THREELEAPS_SYNTHCORPUS_GPG_HOMEDIR`: the dedicated release-signing keyring; +- `THREELEAPS_SYNTHCORPUS_GPG_SIGNING_FINGERPRINT`: the full uppercase 40-hex + primary GPG fingerprint authorized to sign the Git tag; +- `THREELEAPS_SYNTHCORPUS_TAGGER_NAME`: the tagger name associated with the + signing identity; +- `THREELEAPS_SYNTHCORPUS_TAGGER_EMAIL`: a tagger email present on the signing + key and verified for the publishing account. + +Git release tags carry GPG signatures. This no-asset release creates no +minisign signature or uploaded key; minisign material is not part of this +ceremony. + ## 1. Quality gates -- [ ] In the shell that will perform the release, confirm all release changes - are merged to `main`; fetch and record the intended release commit as a - read-only value; and confirm the clean checkout matches it: +- [ ] Confirm all release changes are merged to `main`; fetch and record the + intended release commit; and confirm the clean checkout matches it: ```sh git fetch origin main - release_commit="$(git rev-parse origin/main)" - readonly release_commit - printf 'release commit: %s\n' "$release_commit" - test "$(git rev-parse HEAD)" = "$release_commit" + gate_verified_commit="$(git rev-parse origin/main)" + readonly gate_verified_commit + printf 'release commit: %s\n' "$gate_verified_commit" + test "$(git rev-parse HEAD)" = "$gate_verified_commit" test -z "$(git status --porcelain)" ``` @@ -88,36 +108,71 @@ follows - [ ] Confirm the tag matches `VERSION`: ```sh - release_version="$(tr -d '\n' < VERSION)" - test "v${release_version}" = "v0.1.0" + export THREELEAPS_SYNTHCORPUS_RELEASE_TAG="v$(tr -d '\n' < VERSION)" + export THREELEAPS_SYNTHCORPUS_RELEASE_COMMIT='' + readonly THREELEAPS_SYNTHCORPUS_RELEASE_TAG + readonly THREELEAPS_SYNTHCORPUS_RELEASE_COMMIT + make release-guard-tag-version + ``` + +- [ ] As the maintainer, load the operator-private release environment and + validate all required variables and out-of-band paths without copying key + material into the repository: + + ```sh + source + make release-guard-signing-env + ``` + +- [ ] Confirm the active tag-protection ruleset covers only `refs/tags/v*`, + blocks creation, update, deletion, and non-fast-forward changes, and + permits only the authorized organization-administrator bypass: + + ```sh + make release-guard-tag-ruleset ``` -- [ ] Confirm the active tag-protection ruleset covers `refs/tags/v*`, blocks - creation, update, deletion, and non-fast-forward changes, and permits only - the authorized organization-administrator bypass. - [ ] Confirm the three required hosted checks are green on the recorded - `release_commit`. Keep the same release shell open through signing; the - tag command fails closed if that verified value is unavailable. -- [ ] As an authorized organization administrator, create and push the signed - tag through the ruleset bypass: + `THREELEAPS_SYNTHCORPUS_RELEASE_COMMIT`. This literal full SHA is the + verifier-to-maintainer handoff; never replace it with a newly resolved + `HEAD` or `origin/main`. Keep the checkout unchanged through signing. +- [ ] As an authorized organization administrator, create the GPG-signed tag + with out-of-band key material. The target validates the signing + environment and live ruleset, embeds the ruleset-policy fingerprint, + explicitly tags the check-verified commit, and verifies the annotated + object directly targets that commit with type `commit`, plus the exact + signer fingerprint, tagger identity, policy attestation, and peeled + target: ```sh - test -n "${release_commit:-}" - git fetch origin main - current_main="$(git rev-parse origin/main)" - printf 'current main: %s\n' "$current_main" - test "$current_main" = "$release_commit" - git tag -s -m "v0.1.0 — synthetic fixtures with provable boundaries" \ - v0.1.0 "$release_commit" - git tag -v v0.1.0 - test "$(git rev-parse 'v0.1.0^{}')" = "$release_commit" - git push origin v0.1.0 + make release-tag + ``` + +- [ ] Push the signed tag through the protected-tag bypass. The push target + rechecks the signature and signer, tagger identity, policy attestation, + peeled target, live ruleset, clean checkout, and `origin/main` equality + immediately before publication. This guarded split is intentional: it + preserves the check-verified SHA across local signing and publication. + + ```sh + make release-push-tag + ``` + +- [ ] After the maintainer reports a successful push, return control to the + release operator. Confirm GitHub reports the annotated tag signature as + verified and its target as the literal check-verified release commit: + + ```sh + export THREELEAPS_SYNTHCORPUS_RELEASE_TAG="v$(tr -d '\n' < VERSION)" + export THREELEAPS_SYNTHCORPUS_RELEASE_COMMIT='' + make release-verify-remote-tag ``` - [ ] Create the GitHub release from `RELEASE_NOTES.md` without uploading files: ```sh - gh release create v0.1.0 --title "synthcorpus v0.1.0" \ + gh release create "$THREELEAPS_SYNTHCORPUS_RELEASE_TAG" \ + --title "synthcorpus $THREELEAPS_SYNTHCORPUS_RELEASE_TAG" \ --notes-file RELEASE_NOTES.md ``` @@ -125,7 +180,8 @@ follows archives are not included in the `assets` API field: ```sh - test "$(gh release view v0.1.0 --json assets --jq '.assets | length')" -eq 0 + test "$(gh release view "$THREELEAPS_SYNTHCORPUS_RELEASE_TAG" \ + --json assets --jq '.assets | length')" -eq 0 ``` ## 5. Post-release housekeeping diff --git a/scripts/release-common.sh b/scripts/release-common.sh new file mode 100755 index 0000000..c2f8620 --- /dev/null +++ b/scripts/release-common.sh @@ -0,0 +1,328 @@ +#!/usr/bin/env bash + +set -euo pipefail + +release_common_dir="$(CDPATH='' cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)" +readonly release_common_dir + +release_repo_root() { + git rev-parse --show-toplevel +} + +release_read_version() { + if [ ! -f VERSION ]; then + echo "error: VERSION file not found" >&2 + return 1 + fi + tr -d ' \t\r\n' &2 + return 1 + fi +} + +release_require_command() { + local name="$1" + if ! command -v "${name}" >/dev/null 2>&1; then + echo "error: ${name} is required on PATH" >&2 + return 1 + fi +} + +release_assert_tag_version() { + release_require_env THREELEAPS_SYNTHCORPUS_RELEASE_TAG + + local version expected_tag + version="$(release_read_version)" + expected_tag="v${version}" + + if ! [[ "${THREELEAPS_SYNTHCORPUS_RELEASE_TAG}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "error: THREELEAPS_SYNTHCORPUS_RELEASE_TAG must match vMAJOR.MINOR.PATCH" >&2 + return 1 + fi + if [ "${THREELEAPS_SYNTHCORPUS_RELEASE_TAG}" != "${expected_tag}" ]; then + echo "error: THREELEAPS_SYNTHCORPUS_RELEASE_TAG does not match VERSION (${expected_tag})" >&2 + return 1 + fi +} + +release_validate_release_commit() { + release_require_env THREELEAPS_SYNTHCORPUS_RELEASE_COMMIT + if ! [[ "${THREELEAPS_SYNTHCORPUS_RELEASE_COMMIT}" =~ ^[0-9a-f]{40}$ ]]; then + echo "error: THREELEAPS_SYNTHCORPUS_RELEASE_COMMIT must be a full lowercase 40-hex SHA" >&2 + return 1 + fi + if [ "$(git cat-file -t "${THREELEAPS_SYNTHCORPUS_RELEASE_COMMIT}" 2>/dev/null || true)" != "commit" ]; then + echo "error: THREELEAPS_SYNTHCORPUS_RELEASE_COMMIT must identify a commit object" >&2 + return 1 + fi +} + +release_canonical_directory() { + local path="$1" + if [ ! -d "${path}" ]; then + return 1 + fi + CDPATH='' cd -- "${path}" && pwd -P +} + +release_configure_gpg() { + release_require_env THREELEAPS_SYNTHCORPUS_GPG_HOMEDIR + + local canonical_home + canonical_home="$(release_canonical_directory "${THREELEAPS_SYNTHCORPUS_GPG_HOMEDIR}" || true)" + if [ -z "${canonical_home}" ]; then + echo "error: THREELEAPS_SYNTHCORPUS_GPG_HOMEDIR must name an existing directory" >&2 + return 1 + fi + if git -C "${canonical_home}" rev-parse --git-dir >/dev/null 2>&1; then + echo "error: THREELEAPS_SYNTHCORPUS_GPG_HOMEDIR must remain outside every Git repository" >&2 + return 1 + fi + + export GNUPGHOME="${canonical_home}" +} + +release_validate_fingerprint() { + release_require_env THREELEAPS_SYNTHCORPUS_GPG_SIGNING_FINGERPRINT + if ! [[ "${THREELEAPS_SYNTHCORPUS_GPG_SIGNING_FINGERPRINT}" =~ ^[0-9A-F]{40}$ ]]; then + echo "error: THREELEAPS_SYNTHCORPUS_GPG_SIGNING_FINGERPRINT must be a full uppercase 40-hex fingerprint" >&2 + return 1 + fi +} + +release_validate_tagger_env() { + release_require_env THREELEAPS_SYNTHCORPUS_TAGGER_NAME + release_require_env THREELEAPS_SYNTHCORPUS_TAGGER_EMAIL + case "${THREELEAPS_SYNTHCORPUS_TAGGER_NAME}" in + *$'\n'* | *'<'* | *'>'*) + echo "error: THREELEAPS_SYNTHCORPUS_TAGGER_NAME contains an invalid character" >&2 + return 1 + ;; + esac + case "${THREELEAPS_SYNTHCORPUS_TAGGER_EMAIL}" in + *$'\n'* | *'<'* | *'>'* | *' '*) + echo "error: THREELEAPS_SYNTHCORPUS_TAGGER_EMAIL contains an invalid character" >&2 + return 1 + ;; + esac +} + +release_primary_fingerprints() { + local listing="$1" + printf '%s\n' "${listing}" | awk -F: ' + $1 == "sec" || $1 == "pub" { want_fingerprint = 1; next } + want_fingerprint && $1 == "fpr" { print $10; want_fingerprint = 0 } + ' +} + +release_key_uid_emails() { + local listing="$1" + printf '%s\n' "${listing}" | + awk -F: '$1 == "uid" { print $10 }' | + sed -n 's/.*<\([^<>]*\)>.*/\1/p' +} + +release_validate_verification_env() { + release_assert_tag_version + release_validate_release_commit + release_configure_gpg + release_validate_fingerprint + release_validate_tagger_env + release_require_command gpg + + local public_listing primary_fingerprints primary_count + public_listing="$(GNUPGHOME="${GNUPGHOME}" gpg --batch --with-colons \ + --fingerprint --list-keys \ + "${THREELEAPS_SYNTHCORPUS_GPG_SIGNING_FINGERPRINT}" 2>/dev/null || true)" + primary_fingerprints="$(release_primary_fingerprints "${public_listing}")" + primary_count="$(printf '%s\n' "${primary_fingerprints}" | awk 'NF { count++ } END { print count + 0 }')" + if [ "${primary_count}" -ne 1 ] || + [ "${primary_fingerprints}" != "${THREELEAPS_SYNTHCORPUS_GPG_SIGNING_FINGERPRINT}" ]; then + echo "error: signing fingerprint does not select exactly one primary public key in the release keyring" >&2 + return 1 + fi + + local uid_emails + uid_emails="$(release_key_uid_emails "${public_listing}")" + if ! grep -qxF "${THREELEAPS_SYNTHCORPUS_TAGGER_EMAIL}" <<<"${uid_emails}"; then + echo "error: THREELEAPS_SYNTHCORPUS_TAGGER_EMAIL matches no UID on the configured signing key" >&2 + return 1 + fi +} + +release_validate_signing_env() { + release_validate_verification_env + + local secret_listing primary_fingerprints primary_count + secret_listing="$(GNUPGHOME="${GNUPGHOME}" gpg --batch --with-colons \ + --fingerprint --list-secret-keys \ + "${THREELEAPS_SYNTHCORPUS_GPG_SIGNING_FINGERPRINT}" 2>/dev/null || true)" + primary_fingerprints="$(release_primary_fingerprints "${secret_listing}")" + primary_count="$(printf '%s\n' "${primary_fingerprints}" | awk 'NF { count++ } END { print count + 0 }')" + if [ "${primary_count}" -ne 1 ] || + [ "${primary_fingerprints}" != "${THREELEAPS_SYNTHCORPUS_GPG_SIGNING_FINGERPRINT}" ]; then + echo "error: signing fingerprint does not select exactly one primary secret key in the release keyring" >&2 + return 1 + fi +} + +release_setup_gpg_tty() { + if [ ! -t 0 ] || [ ! -t 1 ]; then + echo "error: release-tag requires an interactive terminal for GPG pinentry" >&2 + return 1 + fi + + export GPG_TTY + GPG_TTY="$(tty)" + if command -v gpg-connect-agent >/dev/null 2>&1; then + gpg-connect-agent updatestartuptty /bye >/dev/null 2>&1 || true + fi +} + +release_assert_checkout() { + if [ "$(git branch --show-current)" != "main" ]; then + echo "error: the release checkout must be on main" >&2 + return 1 + fi + if [ -n "$(git status --porcelain)" ]; then + echo "error: the release checkout must be clean" >&2 + return 1 + fi + if [ "$(git rev-parse HEAD)" != "${THREELEAPS_SYNTHCORPUS_RELEASE_COMMIT}" ]; then + echo "error: HEAD does not equal the check-verified release commit" >&2 + return 1 + fi + + git fetch origin main + local current_main + current_main="$(git rev-parse origin/main)" + readonly current_main + if [ "${current_main}" != "${THREELEAPS_SYNTHCORPUS_RELEASE_COMMIT}" ]; then + echo "error: origin/main does not equal the check-verified release commit" >&2 + return 1 + fi +} + +release_assert_tag_absent() { + if git rev-parse -q --verify "refs/tags/${THREELEAPS_SYNTHCORPUS_RELEASE_TAG}" >/dev/null; then + echo "error: local tag ${THREELEAPS_SYNTHCORPUS_RELEASE_TAG} already exists" >&2 + return 1 + fi + + local remote_status + set +e + git ls-remote --exit-code --tags origin \ + "refs/tags/${THREELEAPS_SYNTHCORPUS_RELEASE_TAG}" >/dev/null 2>&1 + remote_status=$? + set -e + case "${remote_status}" in + 0) + echo "error: remote tag ${THREELEAPS_SYNTHCORPUS_RELEASE_TAG} already exists" >&2 + return 1 + ;; + 2) ;; + *) + echo "error: unable to determine whether the remote tag exists" >&2 + return 1 + ;; + esac +} + +release_expected_policy_attestation() { + "${release_common_dir}/release-guard-tag-ruleset.sh" --expected-attestation +} + +release_validsig_primary_fingerprint() { + local verification="$1" + local validsig count + validsig="$(sed -n 's/^\[GNUPG:\] VALIDSIG //p' <<<"${verification}")" + count="$(printf '%s\n' "${validsig}" | awk 'NF { count++ } END { print count + 0 }')" + if [ "${count}" -ne 1 ]; then + return 1 + fi + awk '{ if (NF >= 10) print $10; else print $1 }' <<<"${validsig}" +} + +release_verify_local_tag() { + local tag="${THREELEAPS_SYNTHCORPUS_RELEASE_TAG}" + local expected_commit="${THREELEAPS_SYNTHCORPUS_RELEASE_COMMIT}" + local tag_text tag_header object_type embedded_tag peeled_commit expected_attestation + + object_type="$(git cat-file -t "refs/tags/${tag}" 2>/dev/null || true)" + if [ "${object_type}" != "tag" ]; then + echo "error: ${tag} must resolve to an annotated tag object" >&2 + return 1 + fi + tag_text="$(git cat-file tag "refs/tags/${tag}")" + tag_header="$(sed '/^$/q' <<<"${tag_text}")" + + local direct_objects direct_types + direct_objects="$(sed -n 's/^object //p' <<<"${tag_header}")" + direct_types="$(sed -n 's/^type //p' <<<"${tag_header}")" + if [ "$(printf '%s\n' "${direct_objects}" | awk 'NF { count++ } END { print count + 0 }')" -ne 1 ] || + [ "${direct_objects}" != "${expected_commit}" ]; then + echo "error: annotated tag must directly target the check-verified release commit" >&2 + return 1 + fi + if [ "$(printf '%s\n' "${direct_types}" | awk 'NF { count++ } END { print count + 0 }')" -ne 1 ] || + [ "${direct_types}" != "commit" ]; then + echo "error: annotated tag direct object type must be commit" >&2 + return 1 + fi + + embedded_tag="$(sed -n 's/^tag //p' <<<"${tag_header}")" + if [ "${embedded_tag}" != "${tag}" ]; then + echo "error: annotated tag object identity does not match ${tag}" >&2 + return 1 + fi + if [ "$(grep -c '^-----BEGIN PGP SIGNATURE-----$' <<<"${tag_text}")" -ne 1 ] || + [ "$(grep -c '^-----END PGP SIGNATURE-----$' <<<"${tag_text}")" -ne 1 ]; then + echo "error: annotated tag must contain exactly one OpenPGP signature" >&2 + return 1 + fi + + expected_attestation="$(release_expected_policy_attestation)" + if [ "$(grep -c '^Tag-Publish-Policy-SHA256: ' <<<"${tag_text}")" -ne 1 ] || + ! grep -qxF "${expected_attestation}" <<<"${tag_text}"; then + echo "error: signed tag lacks exactly the expected publication-policy attestation" >&2 + return 1 + fi + + local tagger_line tagger_name tagger_email + tagger_line="$(sed -n 's/^tagger //p' <<<"${tag_header}")" + # shellcheck disable=SC2001 # POSIX character-class match is clearer here. + tagger_name="$(sed 's/ <[^<>]*> [0-9][0-9]* [+-][0-9][0-9][0-9][0-9]$//' <<<"${tagger_line}")" + tagger_email="$(sed -n 's/^.* <\([^<>]*\)> [0-9][0-9]* [+-][0-9][0-9][0-9][0-9]$/\1/p' <<<"${tagger_line}")" + if [ "${tagger_name}" != "${THREELEAPS_SYNTHCORPUS_TAGGER_NAME}" ] || + [ "${tagger_email}" != "${THREELEAPS_SYNTHCORPUS_TAGGER_EMAIL}" ]; then + echo "error: signed tag tagger identity does not match the required release identity" >&2 + return 1 + fi + + local verification primary_fingerprint + if ! verification="$(GNUPGHOME="${GNUPGHOME}" git verify-tag --raw "${tag}" 2>&1)"; then + printf '%s\n' "${verification}" >&2 + echo "error: tag signature verification failed" >&2 + return 1 + fi + if grep -Eq '^\[GNUPG:\] (EXPKEYSIG|EXPSIG|REVKEYSIG|KEYEXPIRED|SIGEXPIRED) ' <<<"${verification}"; then + echo "error: tag signature or signing key is expired or revoked" >&2 + return 1 + fi + primary_fingerprint="$(release_validsig_primary_fingerprint "${verification}" || true)" + if [ "${primary_fingerprint}" != "${THREELEAPS_SYNTHCORPUS_GPG_SIGNING_FINGERPRINT}" ]; then + echo "error: tag signature was not made by the required primary signing fingerprint" >&2 + return 1 + fi + + peeled_commit="$(git rev-parse "${tag}^{}" 2>/dev/null || true)" + if [ "${peeled_commit}" != "${expected_commit}" ]; then + echo "error: signed tag does not peel to the check-verified release commit" >&2 + return 1 + fi +} diff --git a/scripts/release-guard-signing-env.sh b/scripts/release-guard-signing-env.sh new file mode 100755 index 0000000..a601e1d --- /dev/null +++ b/scripts/release-guard-signing-env.sh @@ -0,0 +1,13 @@ +#!/usr/bin/env bash + +set -euo pipefail + +script_dir="$(CDPATH='' cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)" +# shellcheck source=scripts/release-common.sh +source "${script_dir}/release-common.sh" + +root="$(release_repo_root)" +cd "${root}" +release_validate_signing_env + +echo "[ok] release signing environment is ready" diff --git a/scripts/release-guard-tag-ruleset.sh b/scripts/release-guard-tag-ruleset.sh new file mode 100755 index 0000000..a3d4e88 --- /dev/null +++ b/scripts/release-guard-tag-ruleset.sh @@ -0,0 +1,118 @@ +#!/usr/bin/env bash + +set -euo pipefail + +readonly expected_repository="3leaps/synthcorpus" +readonly expected_ruleset_name="Tag Publish Protection" + +require_command() { + local name="$1" + if ! command -v "${name}" >/dev/null 2>&1; then + echo "error: ${name} is required on PATH" >&2 + exit 1 + fi +} + +expected_policy_json() { + jq -cnS \ + --arg repository "${expected_repository}" \ + --arg ruleset_name "${expected_ruleset_name}" \ + '{ + repository: $repository, + ruleset_name: $ruleset_name, + source_type: "Repository", + target: "tag", + enforcement: "active", + conditions: {ref_name: {exclude: [], include: ["refs/tags/v*"]}}, + rules: ["creation", "deletion", "non_fast_forward", "update"], + bypass_actors: [{actor_id: null, actor_type: "OrganizationAdmin", bypass_mode: "always"}] + }' +} + +policy_digest() { + if command -v sha256sum >/dev/null 2>&1; then + expected_policy_json | sha256sum | awk '{print $1}' + return + fi + if command -v shasum >/dev/null 2>&1; then + expected_policy_json | shasum -a 256 | awk '{print $1}' + return + fi + echo "error: sha256sum or shasum is required" >&2 + return 1 +} + +policy_attestation() { + printf 'Tag-Publish-Policy-SHA256: %s\n' "$(policy_digest)" +} + +resolve_ruleset() { + local pages ids count id + pages="$(gh api --paginate --slurp "repos/${expected_repository}/rulesets?per_page=100")" + ids="$(printf '%s\n' "${pages}" | jq -r \ + --arg name "${expected_ruleset_name}" \ + 'flatten | map(select(.name == $name)) | .[].id')" + count="$(printf '%s\n' "${ids}" | awk 'NF { count++ } END { print count + 0 }')" + if [ "${count}" -ne 1 ]; then + echo "error: expected exactly one '${expected_ruleset_name}' ruleset; found ${count}" >&2 + return 1 + fi + id="$(printf '%s\n' "${ids}" | awk 'NF { print; exit }')" + gh api "repos/${expected_repository}/rulesets/${id}" +} + +validate_ruleset() { + local ruleset="$1" + if ! printf '%s\n' "${ruleset}" | jq -e \ + --arg name "${expected_ruleset_name}" \ + --arg repository "${expected_repository}" ' + .name == $name and + .source_type == "Repository" and + .source == $repository and + .target == "tag" and + .enforcement == "active" and + .conditions == {"ref_name":{"exclude":[],"include":["refs/tags/v*"]}} and + (.rules | length) == 4 and + ([.rules[].type] | sort) == ["creation","deletion","non_fast_forward","update"] and + all(.rules[]; (keys | sort) == ["type"]) and + .bypass_actors == [{"actor_id":null,"actor_type":"OrganizationAdmin","bypass_mode":"always"}] + ' >/dev/null; then + echo "error: live tag ruleset does not match the required publication policy" >&2 + return 1 + fi +} + +main() { + local print_attestation=0 + local expected_attestation=0 + if [ "${1:-}" = "--print-attestation" ]; then + print_attestation=1 + elif [ "${1:-}" = "--expected-attestation" ]; then + expected_attestation=1 + elif [ "$#" -ne 0 ]; then + echo "error: unknown argument: $1" >&2 + exit 1 + fi + + require_command jq + if [ "${expected_attestation}" -eq 1 ]; then + policy_attestation + exit 0 + fi + require_command gh + + local ruleset + ruleset="$(resolve_ruleset)" + validate_ruleset "${ruleset}" + + if [ "${print_attestation}" -eq 1 ]; then + echo "[ok] tag ruleset matches the full publication policy" >&2 + policy_attestation + else + echo "[ok] tag ruleset matches the full publication policy" + fi +} + +if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then + main "$@" +fi diff --git a/scripts/release-guard-tag-version.sh b/scripts/release-guard-tag-version.sh new file mode 100755 index 0000000..9d3a99e --- /dev/null +++ b/scripts/release-guard-tag-version.sh @@ -0,0 +1,14 @@ +#!/usr/bin/env bash + +set -euo pipefail + +script_dir="$(CDPATH='' cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)" +# shellcheck source=scripts/release-common.sh +source "${script_dir}/release-common.sh" + +root="$(release_repo_root)" +cd "${root}" +release_assert_tag_version +release_validate_release_commit + +echo "[ok] release tag matches VERSION and release commit is a full commit SHA" diff --git a/scripts/release-push-tag.sh b/scripts/release-push-tag.sh new file mode 100755 index 0000000..053f70c --- /dev/null +++ b/scripts/release-push-tag.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash + +set -euo pipefail + +script_dir="$(CDPATH='' cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)" +# shellcheck source=scripts/release-common.sh +source "${script_dir}/release-common.sh" + +root="$(release_repo_root)" +cd "${root}" + +release_validate_signing_env +release_assert_checkout +release_verify_local_tag +"${script_dir}/release-guard-tag-ruleset.sh" + +git push origin "refs/tags/${THREELEAPS_SYNTHCORPUS_RELEASE_TAG}" + +echo "[ok] pushed signed tag ${THREELEAPS_SYNTHCORPUS_RELEASE_TAG}" +echo "[--] run make release-verify-remote-tag before creating the GitHub release" diff --git a/scripts/release-tag.sh b/scripts/release-tag.sh new file mode 100755 index 0000000..254c280 --- /dev/null +++ b/scripts/release-tag.sh @@ -0,0 +1,38 @@ +#!/usr/bin/env bash + +set -euo pipefail + +script_dir="$(CDPATH='' cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)" +# shellcheck source=scripts/release-common.sh +source "${script_dir}/release-common.sh" + +root="$(release_repo_root)" +cd "${root}" + +release_validate_signing_env +release_setup_gpg_tty +release_assert_checkout +release_assert_tag_absent + +policy_attestation="$("${script_dir}/release-guard-tag-ruleset.sh" --print-attestation)" +readonly policy_attestation +if ! [[ "${policy_attestation}" =~ ^Tag-Publish-Policy-SHA256:\ [0-9a-f]{64}$ ]]; then + echo "error: tag publication-policy attestation is malformed" >&2 + exit 1 +fi + +export GIT_COMMITTER_NAME="${THREELEAPS_SYNTHCORPUS_TAGGER_NAME}" +export GIT_COMMITTER_EMAIL="${THREELEAPS_SYNTHCORPUS_TAGGER_EMAIL}" + +echo "Creating GPG-signed tag ${THREELEAPS_SYNTHCORPUS_RELEASE_TAG} at ${THREELEAPS_SYNTHCORPUS_RELEASE_COMMIT}" +GNUPGHOME="${GNUPGHOME}" git tag -s -a \ + -u "${THREELEAPS_SYNTHCORPUS_GPG_SIGNING_FINGERPRINT}" \ + -m "synthcorpus ${THREELEAPS_SYNTHCORPUS_RELEASE_TAG}" \ + -m "${policy_attestation}" \ + "${THREELEAPS_SYNTHCORPUS_RELEASE_TAG}" \ + "${THREELEAPS_SYNTHCORPUS_RELEASE_COMMIT}" + +release_verify_local_tag + +echo "[ok] created and verified signed tag ${THREELEAPS_SYNTHCORPUS_RELEASE_TAG}" +echo "[--] the tag is local only; run make release-push-tag from this unchanged checkout" diff --git a/scripts/release-verify-remote-tag.sh b/scripts/release-verify-remote-tag.sh new file mode 100755 index 0000000..7572a00 --- /dev/null +++ b/scripts/release-verify-remote-tag.sh @@ -0,0 +1,39 @@ +#!/usr/bin/env bash + +set -euo pipefail + +script_dir="$(CDPATH='' cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)" +# shellcheck source=scripts/release-common.sh +source "${script_dir}/release-common.sh" + +root="$(release_repo_root)" +cd "${root}" + +release_assert_tag_version +release_validate_release_commit +release_require_command gh +release_require_command jq + +tag_ref="$(gh api \ + "repos/3leaps/synthcorpus/git/ref/tags/${THREELEAPS_SYNTHCORPUS_RELEASE_TAG}")" +if ! jq -e '.object.type == "tag"' >/dev/null <<<"${tag_ref}"; then + echo "error: remote version-tag ref does not target an annotated tag object" >&2 + exit 1 +fi + +tag_object_sha="$(jq -r '.object.sha' <<<"${tag_ref}")" +tag_object="$(gh api "repos/3leaps/synthcorpus/git/tags/${tag_object_sha}")" +if ! jq -e \ + --arg tag "${THREELEAPS_SYNTHCORPUS_RELEASE_TAG}" \ + --arg commit "${THREELEAPS_SYNTHCORPUS_RELEASE_COMMIT}" ' + .tag == $tag and + .object.type == "commit" and + .object.sha == $commit and + .verification.verified == true and + .verification.reason == "valid" + ' >/dev/null <<<"${tag_object}"; then + echo "error: GitHub does not report the expected verified signed tag and target" >&2 + exit 1 +fi + +echo "[ok] GitHub reports a verified signed tag at ${THREELEAPS_SYNTHCORPUS_RELEASE_COMMIT}" diff --git a/scripts/release-verify-tag.sh b/scripts/release-verify-tag.sh new file mode 100755 index 0000000..1727b75 --- /dev/null +++ b/scripts/release-verify-tag.sh @@ -0,0 +1,15 @@ +#!/usr/bin/env bash + +set -euo pipefail + +script_dir="$(CDPATH='' cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)" +# shellcheck source=scripts/release-common.sh +source "${script_dir}/release-common.sh" + +root="$(release_repo_root)" +cd "${root}" + +release_validate_verification_env +release_verify_local_tag + +echo "[ok] verified signed tag ${THREELEAPS_SYNTHCORPUS_RELEASE_TAG}" diff --git a/scripts/test-release-controls.sh b/scripts/test-release-controls.sh new file mode 100755 index 0000000..ed75b32 --- /dev/null +++ b/scripts/test-release-controls.sh @@ -0,0 +1,303 @@ +#!/usr/bin/env bash + +set -euo pipefail + +script_dir="$(CDPATH='' cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)" +# shellcheck source=scripts/release-common.sh +source "${script_dir}/release-common.sh" +# shellcheck source=scripts/release-guard-tag-ruleset.sh +source "${script_dir}/release-guard-tag-ruleset.sh" + +tests_run=0 + +pass() { + tests_run=$((tests_run + 1)) +} + +expect_success() { + local description="$1" + shift + local output + if ! output="$("$@" 2>&1)"; then + echo "not ok: ${description}" >&2 + printf '%s\n' "${output}" >&2 + exit 1 + fi + pass +} + +expect_failure() { + local description="$1" + shift + if ("$@" >/dev/null 2>&1); then + echo "not ok: ${description} unexpectedly passed" >&2 + exit 1 + fi + pass +} + +tmp_root="$(mktemp -d)" +trap 'rm -rf "${tmp_root}"' EXIT + +remote="${tmp_root}/remote.git" +repo="${tmp_root}/repo" +publisher="${tmp_root}/publisher" +gpg_home="${tmp_root}/release-gnupg" +mkdir -p "${gpg_home}" + +git init --bare -q "${remote}" +git init -q -b main "${repo}" +git -C "${repo}" config user.name "Release Test" +git -C "${repo}" config user.email "release@example.invalid" +printf '0.1.0\n' >"${repo}/VERSION" +git -C "${repo}" add VERSION +git -C "${repo}" commit -q -m initial +first_commit="$(git -C "${repo}" rev-parse HEAD)" +git -C "${repo}" remote add origin "${remote}" +git -C "${repo}" push -q -u origin main + +readonly expected_fingerprint="0123456789ABCDEF0123456789ABCDEF01234567" +readonly expected_name="3 Leaps Release" +readonly expected_email="release@example.invalid" + +export THREELEAPS_SYNTHCORPUS_RELEASE_TAG="v0.1.0" +export THREELEAPS_SYNTHCORPUS_RELEASE_COMMIT="${first_commit}" +export THREELEAPS_SYNTHCORPUS_GPG_HOMEDIR="${gpg_home}" +export THREELEAPS_SYNTHCORPUS_GPG_SIGNING_FINGERPRINT="${expected_fingerprint}" +export THREELEAPS_SYNTHCORPUS_TAGGER_NAME="${expected_name}" +export THREELEAPS_SYNTHCORPUS_TAGGER_EMAIL="${expected_email}" + +real_git="$(command -v git)" +verify_mode="good" + +gpg() { + case " $* " in + *' --list-secret-keys '*) + printf 'sec:u:255:22:0123456789ABCDEF:0:0:::::scESC:::\n' + printf 'fpr:::::::::%s:\n' "${expected_fingerprint}" + printf 'uid:u::::0::HASH::3 Leaps Release <%s>::::::::::0:\n' "${expected_email}" + ;; + *' --list-keys '*) + printf 'pub:u:255:22:0123456789ABCDEF:0:0:::::scESC:::\n' + printf 'fpr:::::::::%s:\n' "${expected_fingerprint}" + printf 'uid:u::::0::HASH::3 Leaps Release <%s>::::::::::0:\n' "${expected_email}" + ;; + *) return 1 ;; + esac +} + +git() { + if [ "${1:-}" = "verify-tag" ]; then + case "${verify_mode}" in + good) + printf '[GNUPG:] VALIDSIG %s 2026-07-22 1784736000 0 4 0 22 10 00 %s\n' \ + "${expected_fingerprint}" "${expected_fingerprint}" + return 0 + ;; + wrong-signer) + printf '[GNUPG:] VALIDSIG %s 2026-07-22 1784736000 0 4 0 22 10 00 %s\n' \ + "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" \ + "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" + return 0 + ;; + expired) + printf '[GNUPG:] EXPKEYSIG 0123456789ABCDEF expired\n' + printf '[GNUPG:] VALIDSIG %s 2026-07-22 1784736000 0 4 0 22 10 00 %s\n' \ + "${expected_fingerprint}" "${expected_fingerprint}" + return 0 + ;; + invalid) return 1 ;; + esac + fi + command "${real_git}" "$@" +} + +cd "${repo}" + +expect_success "valid tag and full commit inputs" release_assert_tag_version +expect_success "full commit object accepted" release_validate_release_commit +# shellcheck disable=SC2016 # $1 is intentionally expanded by the child shell. +expect_failure "missing verified SHA rejected" bash -c \ + 'unset THREELEAPS_SYNTHCORPUS_RELEASE_COMMIT; source "$1"; release_validate_release_commit' \ + _ "${script_dir}/release-common.sh" + +saved_commit="${THREELEAPS_SYNTHCORPUS_RELEASE_COMMIT}" +THREELEAPS_SYNTHCORPUS_RELEASE_COMMIT="${saved_commit:0:12}" +expect_failure "abbreviated verified SHA rejected" release_validate_release_commit +THREELEAPS_SYNTHCORPUS_RELEASE_COMMIT="0000000000000000000000000000000000000000" +expect_failure "unknown verified SHA rejected" release_validate_release_commit +blob_commit="$(printf blob | command git hash-object -w --stdin)" +THREELEAPS_SYNTHCORPUS_RELEASE_COMMIT="${blob_commit}" +expect_failure "non-commit object rejected as release SHA" release_validate_release_commit +THREELEAPS_SYNTHCORPUS_RELEASE_COMMIT="${saved_commit}" + +expect_success "valid OOB signing environment accepted" release_validate_signing_env +unset THREELEAPS_SYNTHCORPUS_TAGGER_NAME +expect_failure "absent tagger name rejected" release_validate_verification_env +export THREELEAPS_SYNTHCORPUS_TAGGER_NAME="${expected_name}" +THREELEAPS_SYNTHCORPUS_TAGGER_EMAIL="wrong@example.invalid" +expect_failure "tagger email absent from key UID rejected" release_validate_verification_env +THREELEAPS_SYNTHCORPUS_TAGGER_EMAIL="${expected_email}" + +mkdir -p .test-gnupg +THREELEAPS_SYNTHCORPUS_GPG_HOMEDIR="${repo}/.test-gnupg" +expect_failure "in-worktree GPG home rejected" release_configure_gpg +ln -s "${repo}/.test-gnupg" "${tmp_root}/gnupg-alias" +THREELEAPS_SYNTHCORPUS_GPG_HOMEDIR="${tmp_root}/gnupg-alias" +expect_failure "symlink alias into worktree rejected" release_configure_gpg +git init -q "${tmp_root}/other-repo" +mkdir -p "${tmp_root}/other-repo/gnupg" +THREELEAPS_SYNTHCORPUS_GPG_HOMEDIR="${tmp_root}/other-repo/gnupg" +expect_failure "GPG home in another Git worktree rejected" release_configure_gpg +THREELEAPS_SYNTHCORPUS_GPG_HOMEDIR="${gpg_home}" +release_configure_gpg + +expect_success "clean main at verified origin/main accepted" release_assert_checkout +git switch -q -c topic +expect_failure "wrong branch rejected" release_assert_checkout +git switch -q main +printf dirty >dirty.txt +expect_failure "dirty checkout rejected" release_assert_checkout +rm dirty.txt + +git clone -q "${remote}" "${publisher}" +git -C "${publisher}" config user.name "Publisher" +git -C "${publisher}" config user.email "publisher@example.invalid" +printf 'advance\n' >"${publisher}/advance.txt" +git -C "${publisher}" add advance.txt +git -C "${publisher}" commit -q -m advance +git -C "${publisher}" push -q origin main +expect_failure "moved origin/main rejected against verified SHA" release_assert_checkout +git fetch -q origin main +git reset -q --hard origin/main +second_commit="$(command git rev-parse HEAD)" +THREELEAPS_SYNTHCORPUS_RELEASE_COMMIT="${second_commit}" +expect_success "rebased literal verified SHA accepted" release_assert_checkout + +expect_success "absent local and remote tag accepted" release_assert_tag_absent +command git tag "${THREELEAPS_SYNTHCORPUS_RELEASE_TAG}" +expect_failure "existing local tag rejected" release_assert_tag_absent +command git tag -d "${THREELEAPS_SYNTHCORPUS_RELEASE_TAG}" >/dev/null +command git tag "${THREELEAPS_SYNTHCORPUS_RELEASE_TAG}" +command git push -q origin "refs/tags/${THREELEAPS_SYNTHCORPUS_RELEASE_TAG}" +command git tag -d "${THREELEAPS_SYNTHCORPUS_RELEASE_TAG}" >/dev/null +expect_failure "existing remote tag rejected" release_assert_tag_absent +command git push -q origin --delete "refs/tags/${THREELEAPS_SYNTHCORPUS_RELEASE_TAG}" + +expected_attestation="$(release_expected_policy_attestation)" + +write_tag_object() { + local target="$1" + local policy_mode="$2" + local tagger_name="$3" + local tagger_email="$4" + local signature_mode="$5" + local target_type="${6:-commit}" + local object_file="${tmp_root}/tag-object" + + { + printf 'object %s\n' "${target}" + printf 'type %s\n' "${target_type}" + printf 'tag %s\n' "${THREELEAPS_SYNTHCORPUS_RELEASE_TAG}" + printf 'tagger %s <%s> 1784736000 -0400\n\n' "${tagger_name}" "${tagger_email}" + printf 'synthcorpus %s\n\n' "${THREELEAPS_SYNTHCORPUS_RELEASE_TAG}" + case "${policy_mode}" in + good) printf '%s\n' "${expected_attestation}" ;; + missing) ;; + duplicate) printf '%s\n%s\n' "${expected_attestation}" "${expected_attestation}" ;; + wrong) printf 'Tag-Publish-Policy-SHA256: %064d\n' 0 ;; + esac + if [ "${signature_mode}" = "signed" ]; then + printf '%s\n' '-----BEGIN PGP SIGNATURE-----' '' 'synthetic-test-signature' '-----END PGP SIGNATURE-----' + fi + } >"${object_file}" + + local object_sha + object_sha="$(command git hash-object -t tag -w "${object_file}")" + command git update-ref "refs/tags/${THREELEAPS_SYNTHCORPUS_RELEASE_TAG}" "${object_sha}" +} + +write_tag_object "${second_commit}" good "${expected_name}" "${expected_email}" signed +verify_mode=good +expect_success "fully bound signed tag accepted" release_verify_local_tag +write_tag_object "${second_commit}" good "${expected_name}" "${expected_email}" unsigned +inner_tag="$(command git rev-parse "refs/tags/${THREELEAPS_SYNTHCORPUS_RELEASE_TAG}")" +write_tag_object "${inner_tag}" good "${expected_name}" "${expected_email}" signed tag +expect_failure "indirect nested annotated tag target rejected" release_verify_local_tag +write_tag_object "${second_commit}" good "${expected_name}" "${expected_email}" signed +verify_mode=wrong-signer +expect_failure "wrong signer rejected" release_verify_local_tag +verify_mode=expired +expect_failure "expired signer rejected" release_verify_local_tag +verify_mode=invalid +expect_failure "invalid signature rejected" release_verify_local_tag +verify_mode=good + +write_tag_object "${second_commit}" missing "${expected_name}" "${expected_email}" signed +expect_failure "missing policy attestation rejected" release_verify_local_tag +write_tag_object "${second_commit}" duplicate "${expected_name}" "${expected_email}" signed +expect_failure "duplicate policy attestation rejected" release_verify_local_tag +write_tag_object "${second_commit}" wrong "${expected_name}" "${expected_email}" signed +expect_failure "wrong policy attestation rejected" release_verify_local_tag +write_tag_object "${second_commit}" good "Wrong Name" "${expected_email}" signed +expect_failure "mismatched tagger name rejected" release_verify_local_tag +write_tag_object "${second_commit}" good "${expected_name}" "wrong@example.invalid" signed +expect_failure "mismatched tagger email rejected" release_verify_local_tag +write_tag_object "${first_commit}" good "${expected_name}" "${expected_email}" signed +expect_failure "wrong peeled release commit rejected" release_verify_local_tag +write_tag_object "${second_commit}" good "${expected_name}" "${expected_email}" unsigned +expect_failure "unsigned annotated tag rejected" release_verify_local_tag +command git update-ref "refs/tags/${THREELEAPS_SYNTHCORPUS_RELEASE_TAG}" "${second_commit}" +expect_failure "lightweight tag rejected" release_verify_local_tag +command git update-ref -d "refs/tags/${THREELEAPS_SYNTHCORPUS_RELEASE_TAG}" +expect_failure "absent tag rejected" release_verify_local_tag + +good_policy="$(expected_policy_json | jq '{ + name: .ruleset_name, + source_type: .source_type, + source: .repository, + target: .target, + enforcement: .enforcement, + conditions: .conditions, + rules: (.rules | map({type: .})), + bypass_actors: .bypass_actors +}')" +expected_digest="092d1bc03f4ade53d425dcb91b0d45913a765013bb25f4945added4764d3068b" +if [ "$(policy_digest)" != "${expected_digest}" ]; then + echo "not ok: canonical policy digest changed unexpectedly" >&2 + exit 1 +fi +pass +expect_success "exact ruleset policy accepted" validate_ruleset "${good_policy}" +expect_failure "ruleset repository scope mutation rejected" validate_ruleset \ + "$(jq '.source = "3leaps/other"' <<<"${good_policy}")" +expect_failure "ruleset name mutation rejected" validate_ruleset \ + "$(jq '.name = "Other Protection"' <<<"${good_policy}")" +expect_failure "ruleset source type mutation rejected" validate_ruleset \ + "$(jq '.source_type = "Organization"' <<<"${good_policy}")" +expect_failure "ruleset target mutation rejected" validate_ruleset \ + "$(jq '.target = "branch"' <<<"${good_policy}")" +expect_failure "ruleset ref pattern mutation rejected" validate_ruleset \ + "$(jq '.conditions.ref_name.include = ["refs/tags/*"]' <<<"${good_policy}")" +expect_failure "ruleset ref exclusion mutation rejected" validate_ruleset \ + "$(jq '.conditions.ref_name.exclude = ["refs/tags/v0.1.0"]' <<<"${good_policy}")" +expect_failure "ruleset enforcement mutation rejected" validate_ruleset \ + "$(jq '.enforcement = "evaluate"' <<<"${good_policy}")" +expect_failure "ruleset rule-set mutation rejected" validate_ruleset \ + "$(jq '.rules |= map(select(.type != "deletion"))' <<<"${good_policy}")" +expect_failure "ruleset extra rule rejected" validate_ruleset \ + "$(jq '.rules += [{"type":"required_status_checks","parameters":{}}]' <<<"${good_policy}")" +expect_failure "ruleset rule parameters rejected" validate_ruleset \ + "$(jq '.rules[0].parameters = {}' <<<"${good_policy}")" +expect_failure "ruleset bypass actor mutation rejected" validate_ruleset \ + "$(jq '.bypass_actors[0].actor_type = "RepositoryRole"' <<<"${good_policy}")" +expect_failure "ruleset bypass mode mutation rejected" validate_ruleset \ + "$(jq '.bypass_actors[0].bypass_mode = "pull_request"' <<<"${good_policy}")" +expect_failure "ruleset bypass actor id mutation rejected" validate_ruleset \ + "$(jq '.bypass_actors[0].actor_id = 5' <<<"${good_policy}")" +expect_failure "ruleset extra bypass actor rejected" validate_ruleset \ + "$(jq '.bypass_actors += [{"actor_id":5,"actor_type":"RepositoryRole","bypass_mode":"always"}]' <<<"${good_policy}")" +expect_failure "missing ruleset response rejected" validate_ruleset "" +expect_failure "malformed ruleset response rejected" validate_ruleset "not-json" + +echo "[ok] release controls: ${tests_run} hermetic checks passed"