From 004b3f2f6c03716df289dc50496cbe94bafc3da5 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Fri, 25 Sep 2026 08:22:57 +0530 Subject: [PATCH 1/2] Tag reviewed main for releases --- .github/scripts/tag-reviewed-release.sh | 64 ++++++++++ .github/scripts/test-tag-reviewed-release.sh | 78 ++++++++++++ .github/workflows/ci.yml | 3 + .github/workflows/release.yml | 122 ++----------------- AGENTS.md | 24 ++-- docs/plans/tinybus-module-release.md | 4 +- docs/specs/tinybus-module-release.md | 3 + 7 files changed, 172 insertions(+), 126 deletions(-) create mode 100755 .github/scripts/tag-reviewed-release.sh create mode 100755 .github/scripts/test-tag-reviewed-release.sh diff --git a/.github/scripts/tag-reviewed-release.sh b/.github/scripts/tag-reviewed-release.sh new file mode 100755 index 0000000..fc232c0 --- /dev/null +++ b/.github/scripts/tag-reviewed-release.sh @@ -0,0 +1,64 @@ +#!/usr/bin/env bash +set -euo pipefail + +: "${EXPECTED_VERSION:?set EXPECTED_VERSION to the merged release version}" +: "${GITHUB_OUTPUT:?set GITHUB_OUTPUT for release job outputs}" +: "${RELEASE_PACKAGE:=tinybrowser}" + +if [[ ! "$EXPECTED_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "release version must be major.minor.patch: $EXPECTED_VERSION" >&2 + exit 1 +fi +if [[ "${GITHUB_REF:-}" != refs/heads/main ]]; then + echo "release tagging is only allowed from main" >&2 + exit 1 +fi +root="$(git rev-parse --show-toplevel)" +if [[ "$(pwd -P)" != "$(cd "$root" && pwd -P)" ]]; then + echo "run release tagging from the repository root" >&2 + exit 1 +fi +if ! git diff --quiet || ! git diff --cached --quiet; then + echo "release checkout has uncommitted tracked changes" >&2 + exit 1 +fi + +metadata="$(cargo metadata --format-version 1 --no-deps --locked)" +crate_name="$(jq -r --arg name "$RELEASE_PACKAGE" \ + '.packages[] | select(.name == $name) | .name' <<< "$metadata")" +current_version="$(jq -r --arg name "$RELEASE_PACKAGE" \ + '.packages[] | select(.name == $name) | .version' <<< "$metadata")" +if [[ "$crate_name" != "$RELEASE_PACKAGE" || "$current_version" != "$EXPECTED_VERSION" ]]; then + echo "merged $RELEASE_PACKAGE version is $current_version; expected $EXPECTED_VERSION" >&2 + exit 1 +fi + +head_sha="$(git rev-parse HEAD)" +main_sha="$(git ls-remote origin refs/heads/main | awk '{print $1}')" +if [[ -z "$main_sha" || "$head_sha" != "$main_sha" ]]; then + echo "release HEAD is not the current protected main commit" >&2 + exit 1 +fi + +tag="v${current_version}" +git fetch --tags origin +if git rev-parse --verify --quiet "refs/tags/${tag}" >/dev/null; then + tagged_sha="$(git rev-list -n 1 "$tag")" + if [[ "$tagged_sha" != "$head_sha" ]]; then + echo "existing tag $tag points to $tagged_sha, not reviewed main $head_sha" >&2 + exit 1 + fi + echo "using existing tag $tag on reviewed main $head_sha" +else + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git tag -a "$tag" -m "Release $tag" + git push origin "refs/tags/${tag}" + echo "created tag $tag on reviewed main $head_sha" +fi + +{ + echo "crate_name=$crate_name" + echo "next_version=$current_version" + echo "tag=$tag" +} >> "$GITHUB_OUTPUT" diff --git a/.github/scripts/test-tag-reviewed-release.sh b/.github/scripts/test-tag-reviewed-release.sh new file mode 100755 index 0000000..038a6f9 --- /dev/null +++ b/.github/scripts/test-tag-reviewed-release.sh @@ -0,0 +1,78 @@ +#!/usr/bin/env bash +set -euo pipefail + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)" +scratch="$(mktemp -d)" +trap 'rm -rf "$scratch"' EXIT + +git init -q --bare "$scratch/remote.git" +git init -q -b main "$scratch/work" +git -C "$scratch/work" config user.name "Release test" +git -C "$scratch/work" config user.email "release-test@example.invalid" +printf 'first reviewed commit\n' > "$scratch/work/source.txt" +git -C "$scratch/work" add source.txt +git -C "$scratch/work" commit -qm 'Initial reviewed source' +git -C "$scratch/work" remote add origin "$scratch/remote.git" +git -C "$scratch/work" push -q -u origin main + +mkdir "$scratch/fakebin" +cat > "$scratch/fakebin/cargo" <<'FAKE_CARGO' +#!/usr/bin/env bash +set -euo pipefail +[[ "$1" == metadata ]] +printf '{"packages":[{"name":"tinybrowser","version":"%s"}]}\n' "$TEST_VERSION" +FAKE_CARGO +chmod +x "$scratch/fakebin/cargo" + +run_tag() { + ( + cd "$scratch/work" + PATH="$scratch/fakebin:$PATH" \ + TEST_VERSION=0.2.2 EXPECTED_VERSION="$1" \ + GITHUB_REF="${2:-refs/heads/main}" \ + GITHUB_OUTPUT="$scratch/outputs" RELEASE_PACKAGE=tinybrowser \ + "$script_dir/tag-reviewed-release.sh" + ) +} + +if run_tag 0.2.3 > "$scratch/mismatch.out" 2>&1; then + echo "tagged a version that was not merged" >&2 + exit 1 +fi +grep -q 'expected 0.2.3' "$scratch/mismatch.out" +if run_tag 0.2.2 refs/heads/feature > "$scratch/branch.out" 2>&1; then + echo "tagged a non-main branch" >&2 + exit 1 +fi +grep -q 'only allowed from main' "$scratch/branch.out" + +printf 'unreviewed local commit\n' >> "$scratch/work/source.txt" +git -C "$scratch/work" commit -qam 'Local unreviewed change' +if run_tag 0.2.2 > "$scratch/unreviewed.out" 2>&1; then + echo "tagged a commit not on protected main" >&2 + exit 1 +fi +grep -q 'not the current protected main commit' "$scratch/unreviewed.out" + +git -C "$scratch/work" push -q origin main +reviewed_sha="$(git -C "$scratch/work" rev-parse HEAD)" +run_tag 0.2.2 > "$scratch/created.out" +[[ "$(git -C "$scratch/work" rev-list -n 1 v0.2.2)" == "$reviewed_sha" ]] +[[ "$(git -C "$scratch/work" ls-remote origin refs/heads/main | awk '{print $1}')" == "$reviewed_sha" ]] +[[ -n "$(git -C "$scratch/work" ls-remote origin refs/tags/v0.2.2)" ]] +grep -q '^next_version=0.2.2$' "$scratch/outputs" +grep -q '^tag=v0.2.2$' "$scratch/outputs" + +run_tag 0.2.2 > "$scratch/existing.out" +grep -q 'using existing tag v0.2.2' "$scratch/existing.out" + +printf 'later reviewed commit\n' >> "$scratch/work/source.txt" +git -C "$scratch/work" commit -qam 'Later reviewed source' +git -C "$scratch/work" push -q origin main +if run_tag 0.2.2 > "$scratch/stale-tag.out" 2>&1; then + echo "accepted a tag pointing to an older commit" >&2 + exit 1 +fi +grep -q 'not reviewed main' "$scratch/stale-tag.out" + +echo "reviewed release tag tests passed" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4abde47..052083b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -109,6 +109,9 @@ jobs: - name: Verify the coverage gate run: .github/scripts/test-check-file-coverage.sh + - name: Verify protected release tagging + run: .github/scripts/test-tag-reviewed-release.sh + - name: Require 90% line coverage in every production source file run: .github/scripts/check-file-coverage.sh 90 coverage.json diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3c39827..da48130 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -3,15 +3,10 @@ name: Release on: workflow_dispatch: inputs: - bump: - description: Version bump to release - type: choice + release_version: + description: Version already merged to protected main (for example 0.2.2) + type: string required: true - options: - - patch - - minor - - major - - current concurrency: group: release-${{ github.ref_name }} @@ -82,6 +77,9 @@ jobs: - name: Verify the coverage gate run: .github/scripts/test-check-file-coverage.sh + - name: Verify protected release tagging + run: .github/scripts/test-tag-reviewed-release.sh + - name: Require 90% line coverage in every production source file run: .github/scripts/check-file-coverage.sh 90 target/coverage.json @@ -90,113 +88,11 @@ jobs: RUSTDOCFLAGS: -D warnings run: cargo doc --no-deps --all-features - - name: Compute next version + - name: Tag reviewed release source id: version - shell: bash - run: | - set -euo pipefail - - metadata="$(cargo metadata --format-version 1 --no-deps)" - crate_name="$(jq -r --arg name "$RELEASE_PACKAGE" \ - '.packages[] | select(.name == $name) | .name' <<< "$metadata")" - current_version="$(jq -r --arg name "$RELEASE_PACKAGE" \ - '.packages[] | select(.name == $name) | .version' <<< "$metadata")" - if [[ -z "$crate_name" || "$crate_name" == "null" ]]; then - echo "Could not resolve the crate name" >&2 - exit 1 - fi - if [[ -z "$current_version" || "$current_version" == "null" ]]; then - echo "Could not resolve the current crate version" >&2 - exit 1 - fi - - IFS=. read -r major minor patch <<< "$current_version" - case "${{ inputs.bump }}" in - current) - ;; - major) - major=$((major + 1)) - minor=0 - patch=0 - ;; - minor) - minor=$((minor + 1)) - patch=0 - ;; - patch) - patch=$((patch + 1)) - ;; - *) - echo "Unsupported bump: ${{ inputs.bump }}" >&2 - exit 1 - ;; - esac - - next_version="${major}.${minor}.${patch}" - tag="v${next_version}" - git fetch --tags origin - - if git rev-parse --verify --quiet "refs/tags/${tag}"; then - if [[ "${{ inputs.bump }}" != "current" ]]; then - echo "Tag ${tag} already exists" >&2 - exit 1 - fi - tagged_version="$( - git show "${tag}:Cargo.toml" \ - | sed -n '/^\[workspace\.package\]/,/^\[/ s/^version = "\([^"]*\)"/\1/p' \ - | head -n 1 - )" - if [[ "$tagged_version" != "$current_version" ]]; then - echo "Tag ${tag} does not contain version ${current_version}" >&2 - exit 1 - fi - elif [[ "${{ inputs.bump }}" == "current" ]]; then - echo "Tag ${tag} does not exist; choose a semantic version bump" >&2 - exit 1 - fi - - { - echo "crate_name=${crate_name}" - echo "current_version=${current_version}" - echo "next_version=${next_version}" - echo "tag=${tag}" - } >> "$GITHUB_OUTPUT" - - - name: Update crate version - if: ${{ inputs.bump != 'current' }} - env: - CRATE_NAME: ${{ steps.version.outputs.crate_name }} - NEXT_VERSION: ${{ steps.version.outputs.next_version }} - run: | - set -euo pipefail - # One version for the whole workspace: every member inherits it with - # `version.workspace = true`, so this is the only edit needed. - perl -0pi -e 's/(\[workspace\.package\][\s\S]*?\nversion = ")[^"]+(")/$1$ENV{NEXT_VERSION}$2/' Cargo.toml - # `--workspace` re-resolves the local packages only, which is what a - # version bump changes. `-p --precise` cannot express "and the - # other member moved too". - cargo update --workspace - released="$(cargo metadata --format-version 1 --no-deps \ - | jq -r --arg name "$CRATE_NAME" \ - '.packages[] | select(.name == $name) | .version')" - if [[ "$released" != "$NEXT_VERSION" ]]; then - echo "version bump did not take: expected ${NEXT_VERSION}, got ${released}" >&2 - exit 1 - fi - - - name: Commit version bump and tag - if: ${{ inputs.bump != 'current' }} env: - RELEASE_TAG: ${{ steps.version.outputs.tag }} - run: | - set -euo pipefail - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add Cargo.toml Cargo.lock - git commit -m "Release ${RELEASE_TAG}" - git tag -a "${RELEASE_TAG}" -m "Release ${RELEASE_TAG}" - git push origin "HEAD:${GITHUB_REF_NAME}" - git push origin "${RELEASE_TAG}" + EXPECTED_VERSION: ${{ inputs.release_version }} + run: .github/scripts/tag-reviewed-release.sh native-bundles: name: Rust module bundle (${{ matrix.id }}) diff --git a/AGENTS.md b/AGENTS.md index e065745..ff6bd30 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -329,20 +329,22 @@ explicitly declined with a reason. ## Releases -Releases run from `.github/workflows/release.yml` via a manual -`workflow_dispatch` with a `patch` / `minor` / `major` bump; `current` resumes -an interrupted release after its version commit and tag exist. The workflow -re-runs the full validation suite, computes the next version, updates -the root `[workspace.package]` version and `Cargo.lock`, commits and tags -`vX.Y.Z`, builds `crates/tinybrowser` as a TinyBus module for every supported -platform, pushes, and creates an immutable GitHub release with installable -native packages. +Prepare a release by changing the root `[workspace.package]` version and +`Cargo.lock` in a focused PR, then merge it through protected `main` with its +required checks. Run `.github/workflows/release.yml` manually with +`release_version` equal to that merged version. The workflow re-runs the full +validation suite, verifies that the checkout is the current protected main +commit, creates or reuses an annotated `vX.Y.Z` tag on exactly that commit, +builds `crates/tinybrowser` as a TinyBus module for every supported platform, +and creates an immutable GitHub release with installable native packages. A +rerun with the same version resumes only when its tag still points to that +same main commit. The workflow never pushes a new commit to `main`. Consequently: -- Do not hand-edit the `version` field in the root `[workspace.package]`; the - release workflow owns it. Every member inherits it with - `version.workspace = true`, so the whole workspace releases as one version. +- Change the root version only in a reviewed release-version PR and update + `Cargo.lock` with it. Every member inherits `version.workspace = true`, so + the whole workspace releases as one version. - Follow semantic versioning. Any change to the public surface that is not purely additive is a breaking change and needs a major bump (pre-1.0: a minor bump). diff --git a/docs/plans/tinybus-module-release.md b/docs/plans/tinybus-module-release.md index 6e8e5fc..a36dba4 100644 --- a/docs/plans/tinybus-module-release.md +++ b/docs/plans/tinybus-module-release.md @@ -7,5 +7,5 @@ Linked specification: [`../specs/tinybus-module-release.md`](../specs/tinybus-mo 3. Exercise the declared interface over the real in-memory bus. 4. Replace TinyBus host bundles with tagged `tinybrowser` module archives for every supported platform runner and distribution container. -5. Run the repository validation and coverage contracts, push `main`, and - trigger a patch release. +5. Run the repository validation and coverage contracts, merge a version-bump + PR through protected `main`, then dispatch the release for that version. diff --git a/docs/specs/tinybus-module-release.md b/docs/specs/tinybus-module-release.md index 2419666..dcd3461 100644 --- a/docs/specs/tinybus-module-release.md +++ b/docs/specs/tinybus-module-release.md @@ -23,6 +23,9 @@ distributable without also shipping the TinyBus host runtime. images exist for the architecture. - TinyBus itself remains a pinned SDK submodule and is not shipped as a release asset from this repository. +- A version bump changes the workspace manifest and lockfile in a reviewed PR. + The release workflow tags the checked, protected `main` commit; it never + pushes a version commit directly to `main`. ## Verification From 57e0f4dd554d3d65809a7cbd45e9f4965aff58cc Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Fri, 25 Sep 2026 08:39:09 +0530 Subject: [PATCH 2/2] Reject lightweight release tags --- .github/scripts/tag-reviewed-release.sh | 4 ++++ .github/scripts/test-tag-reviewed-release.sh | 12 +++++++++++- 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/.github/scripts/tag-reviewed-release.sh b/.github/scripts/tag-reviewed-release.sh index fc232c0..fe88559 100755 --- a/.github/scripts/tag-reviewed-release.sh +++ b/.github/scripts/tag-reviewed-release.sh @@ -43,6 +43,10 @@ fi tag="v${current_version}" git fetch --tags origin if git rev-parse --verify --quiet "refs/tags/${tag}" >/dev/null; then + if [[ "$(git cat-file -t "refs/tags/${tag}")" != tag ]]; then + echo "existing release tag $tag must be annotated" >&2 + exit 1 + fi tagged_sha="$(git rev-list -n 1 "$tag")" if [[ "$tagged_sha" != "$head_sha" ]]; then echo "existing tag $tag points to $tagged_sha, not reviewed main $head_sha" >&2 diff --git a/.github/scripts/test-tag-reviewed-release.sh b/.github/scripts/test-tag-reviewed-release.sh index 038a6f9..53b505e 100755 --- a/.github/scripts/test-tag-reviewed-release.sh +++ b/.github/scripts/test-tag-reviewed-release.sh @@ -28,7 +28,7 @@ run_tag() { ( cd "$scratch/work" PATH="$scratch/fakebin:$PATH" \ - TEST_VERSION=0.2.2 EXPECTED_VERSION="$1" \ + TEST_VERSION="${3:-0.2.2}" EXPECTED_VERSION="$1" \ GITHUB_REF="${2:-refs/heads/main}" \ GITHUB_OUTPUT="$scratch/outputs" RELEASE_PACKAGE=tinybrowser \ "$script_dir/tag-reviewed-release.sh" @@ -66,9 +66,19 @@ grep -q '^tag=v0.2.2$' "$scratch/outputs" run_tag 0.2.2 > "$scratch/existing.out" grep -q 'using existing tag v0.2.2' "$scratch/existing.out" +git -C "$scratch/work" update-ref refs/tags/v0.2.3 HEAD +git -C "$scratch/work" push -q origin refs/tags/v0.2.3 +if run_tag 0.2.3 refs/heads/main 0.2.3 > "$scratch/lightweight.out" 2>&1; then + echo "accepted a lightweight release tag" >&2 + exit 1 +fi +grep -q 'must be annotated' "$scratch/lightweight.out" + printf 'later reviewed commit\n' >> "$scratch/work/source.txt" git -C "$scratch/work" commit -qam 'Later reviewed source' git -C "$scratch/work" push -q origin main +[[ "$(git -C "$scratch/work" ls-remote origin refs/heads/main | awk '{print $1}')" == \ + "$(git -C "$scratch/work" rev-parse HEAD)" ]] if run_tag 0.2.2 > "$scratch/stale-tag.out" 2>&1; then echo "accepted a tag pointing to an older commit" >&2 exit 1