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
68 changes: 68 additions & 0 deletions .github/scripts/tag-reviewed-release.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
#!/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)"
Comment thread
senamakel marked this conversation as resolved.
Comment thread
senamakel marked this conversation as resolved.
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
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")"
Comment thread
senamakel marked this conversation as resolved.
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"
88 changes: 88 additions & 0 deletions .github/scripts/test-tag-reviewed-release.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
#!/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="${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"
)
}

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"

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"
Comment thread
senamakel marked this conversation as resolved.
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
Comment thread
senamakel marked this conversation as resolved.
echo "accepted a tag pointing to an older commit" >&2
exit 1
fi
grep -q 'not reviewed main' "$scratch/stale-tag.out"
Comment thread
senamakel marked this conversation as resolved.

echo "reviewed release tag tests passed"
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
122 changes: 9 additions & 113 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
Expand Down Expand Up @@ -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

Expand All @@ -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 <name> --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 }})
Expand Down
24 changes: 13 additions & 11 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
4 changes: 2 additions & 2 deletions docs/plans/tinybus-module-release.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
3 changes: 3 additions & 0 deletions docs/specs/tinybus-module-release.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Loading