From b8348cd0c5304e2968c99c150e88a5ec138c48b7 Mon Sep 17 00:00:00 2001 From: satyaborg Date: Fri, 17 Jul 2026 13:29:37 +1000 Subject: [PATCH] feat: add two-step release flow --- .github/workflows/release.yml | 88 ++++++++ AGENTS.md | 2 +- CONTRIBUTING.md | 12 +- README.md | 2 +- scripts/devloop_test.sh | 173 +++++++++++++-- scripts/release.sh | 404 ++++++++++++++++++++++------------ 6 files changed, 517 insertions(+), 164 deletions(-) create mode 100644 .github/workflows/release.yml diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..8fbc7e0 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,88 @@ +name: Release + +on: + push: + tags: + - "v*" + +permissions: + contents: read + +jobs: + test: + name: test (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest] + steps: + - uses: actions/checkout@v4 + + - name: Configure git identity + run: | + git config --global user.name "devloop-ci" + git config --global user.email "ci@devloop.sh" + git config --global init.defaultBranch main + + - name: Syntax check + run: bash -n devloop scripts/install.sh scripts/release.sh scripts/skill_helpers.sh scripts/install.remote.sh scripts/devloop_test.sh + + - name: Test suite + run: bash scripts/devloop_test.sh + + lint: + name: shellcheck + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Ensure shellcheck + run: command -v shellcheck >/dev/null 2>&1 || { sudo apt-get update && sudo apt-get install -y shellcheck; } + + - name: Run shellcheck + run: shellcheck devloop scripts/install.sh scripts/skill_helpers.sh scripts/release.sh scripts/install.remote.sh scripts/devloop_test.sh + + release: + needs: [test, lint] + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Verify release ref + run: | + export DEVLOOP_RELEASE_LIB=1 + source scripts/release.sh + tag_version="${GITHUB_REF_NAME#v}" + root_version="$(sed -n '1p' VERSION)" + site_version="$(sed -n '1p' site/public/VERSION)" + echo "tag=$tag_version root=$root_version site=$site_version" + if ! release_version_valid "$tag_version"; then + echo "::error::tag must be v followed by a valid SemVer version" + exit 1 + fi + if [ "$tag_version" != "$root_version" ] || [ "$tag_version" != "$site_version" ]; then + echo "::error::tag, VERSION, and site/public/VERSION must match" + exit 1 + fi + git fetch --quiet origin main:refs/remotes/origin/main + if ! git merge-base --is-ancestor "$GITHUB_SHA" refs/remotes/origin/main; then + echo "::error::release tag commit must belong to main" + exit 1 + fi + + - name: Build release assets + run: | + mkdir -p dist + export DEVLOOP_RELEASE_LIB=1 + source scripts/release.sh + release_create_artifacts "$(sed -n '1p' VERSION)" dist + + - name: Create GitHub Release + env: + GH_TOKEN: ${{ github.token }} + run: gh release create "${GITHUB_REF_NAME}" --verify-tag --generate-notes dist/* diff --git a/AGENTS.md b/AGENTS.md index 32dfc5e..6ca4277 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,7 +2,7 @@ ## Project Structure & Module Organization -This is a Bash CLI project. The active runtime is the root `devloop` executable. `VERSION` is the single version source, `scripts/release.sh` cuts local release commits and annotated tags, `scripts/install.sh` links the CLI into a local bin directory and installs bundled skills into `~/.agents/skills` and `~/.claude/skills`, `scripts/uninstall.sh` reverses that install (symlink, staged runtime, devloop-managed skills), `scripts/devloop_test.sh` covers the shell runtime, `skills/devloop-spec/SKILL.md` is the spec-generation skill, `skills/devloop-review/SKILL.md` is the review skill, and `skills/devloop-spec/references/spec-template.md` is the starter spec. Generated runtime output belongs under `.devloop/` in target repositories and should not be committed here. +This is a Bash CLI project. The active runtime is the root `devloop` executable. `VERSION` is the single version source, `scripts/release.sh` opens version pull requests and publishes merged versions without bumping them, `.github/workflows/release.yml` builds tagged releases, `scripts/install.sh` links the CLI into a local bin directory and installs bundled skills into `~/.agents/skills` and `~/.claude/skills`, `scripts/uninstall.sh` reverses that install (symlink, staged runtime, devloop-managed skills), `scripts/devloop_test.sh` covers the shell runtime, `skills/devloop-spec/SKILL.md` is the spec-generation skill, `skills/devloop-review/SKILL.md` is the review skill, and `skills/devloop-spec/references/spec-template.md` is the starter spec. Generated runtime output belongs under `.devloop/` in target repositories and should not be committed here. ## Build, Test, and Development Commands diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 578feba..3df2b82 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -44,7 +44,17 @@ See [AGENTS.md](AGENTS.md) for the repository map and the same guidelines in age ## Releases -Releases are cut by maintainers from a CI-green `main` with [`scripts/release.sh`](scripts/release.sh), which bumps `VERSION`, regenerates `CHANGELOG.md` from commit history with [git-cliff](https://git-cliff.org/), tags, and publishes the GitHub Release. The release script skips the full local shell suite by default because CI already runs it on merge to `main`; for `--push` and `--publish`, it first verifies local `HEAD` matches upstream. Pass `--run-tests` when you want an extra local preflight. You do not need to touch `VERSION` or `CHANGELOG.md` in a normal PR. +Releases use a version pull request followed by a separate publish step: + +```sh +./scripts/release.sh minor +# Merge the generated pull request after CI passes. +git switch main +git pull --ff-only +./scripts/release.sh publish +``` + +The bump command requires a clean `main` matching `origin/main`, creates `chore/release-vX.Y.Z`, updates both version files and `CHANGELOG.md`, and opens a pull request. After that pull request merges, `publish` tags the current version without incrementing it. Publication refuses a stale changelog, so commits added after release preparation require a pull request that refreshes `CHANGELOG.md`. The tag-triggered release workflow reruns the shell suite and shellcheck, validates SemVer, requires the tagged commit to belong to `main`, verifies the tag matches both version files, builds the archive and checksum, and creates the GitHub Release. Pass `--run-tests` to either command for an extra local preflight. You do not need to touch `VERSION` or `CHANGELOG.md` in a normal pull request. ## Reporting bugs and proposing features diff --git a/README.md b/README.md index e30ab31..9b3b3d6 100644 --- a/README.md +++ b/README.md @@ -79,7 +79,7 @@ cd devloop bash scripts/devloop_test.sh ``` -See [CONTRIBUTING.md](CONTRIBUTING.md) for setup, full gates, and release notes. +See [CONTRIBUTING.md](CONTRIBUTING.md) for setup, full gates, and the two-step release flow. ## Privacy diff --git a/scripts/devloop_test.sh b/scripts/devloop_test.sh index 99e380f..2f2551e 100755 --- a/scripts/devloop_test.sh +++ b/scripts/devloop_test.sh @@ -122,6 +122,12 @@ contains "$contributing_text" "tmux" "CONTRIBUTING required dependency" contains "$contributing_text" "--no-tmux" "CONTRIBUTING foreground override" ok "README install docs" +release_workflow_text="$(cat "$REPO_ROOT/.github/workflows/release.yml")" +contains "$release_workflow_text" "fetch-depth: 0" "release workflow full history" +contains "$release_workflow_text" "release_version_valid \"\$tag_version\"" "release workflow SemVer guard" +contains "$release_workflow_text" "git merge-base --is-ancestor \"\$GITHUB_SHA\" refs/remotes/origin/main" "release workflow main ancestry guard" +ok "release workflow guards" + skill_path="$("$REPO_ROOT/devloop" spec --skill-path)" [[ "$skill_path" == "$REPO_ROOT/skills/devloop-spec/SKILL.md" ]] || fail "unexpected skill path: $skill_path" contains "$("$REPO_ROOT/devloop" spec --print-skill)" "name: devloop-spec" "spec skill" @@ -1824,7 +1830,8 @@ ok "pure helpers" export DEVLOOP_RELEASE_LIB # shellcheck disable=SC1091 source "$SCRIPTS_DIR/release.sh" - contains "$(release_usage)" "usage: ./scripts/release.sh" "release usage" + contains "$(release_usage)" "usage:" "release usage" + contains "$(release_usage)" "./scripts/release.sh publish" "release publish usage" release_version_valid "0.1.0" || fail "release version rejected valid patch" release_version_valid "1.2.3-alpha.1+build.7" || fail "release version rejected valid prerelease" if release_version_valid "01.2.3"; then fail "release version accepted leading zero"; fi @@ -1843,11 +1850,6 @@ ok "pure helpers" equals "$(release_next_version minor "0.1.0")" "0.2.0" "minor bump" equals "$(release_next_version major "0.1.0")" "1.0.0" "major bump" if release_next_version patch "0.1.0-alpha.1" >/dev/null 2>&1; then fail "release bump accepted prerelease"; fi - # shellcheck disable=SC2329 - release_require_command() { - if [ "$1" = "git-cliff" ]; then return 1; fi - return 0 - } ROOT="$work/release-root" mkdir -p "$ROOT/site/public" git init -q "$ROOT" @@ -1856,17 +1858,26 @@ ok "pure helpers" equals "$(sed -n '1p' "$ROOT/site/public/VERSION")" "9.9.8" "release writes site version" printf '%s\n' "9.9.9" > "$ROOT/VERSION" printf '%s\n' "9.9.9" > "$ROOT/site/public/VERSION" - dry_run_output="$(release_main "patch" --dry-run)" || fail "release dry-run required git-cliff" + dry_run_output="$(release_main "patch" --dry-run)" || fail "release pull request dry run failed" contains "$dry_run_output" "next: 9.9.10 (v9.9.10)" "release dry-run" - contains "$dry_run_output" "would skip local tests (use --run-tests to run bash scripts/devloop_test.sh)" "release dry-run" - contains "$dry_run_output" "would tag: v9.9.10" "release dry-run" - test_dry_run_output="$(release_main "patch" --run-tests --dry-run)" || fail "release test dry-run required git-cliff" + contains "$dry_run_output" "would create branch: chore/release-v9.9.10" "release dry-run" + contains "$dry_run_output" "would update VERSION, site/public/VERSION, and CHANGELOG.md" "release dry-run" + contains "$dry_run_output" "would open pull request: chore: release 9.9.10" "release dry-run" + contains "$dry_run_output" "pull request CI remains authoritative" "release dry-run" + test_dry_run_output="$(release_main "patch" --run-tests --dry-run)" || fail "release test dry-run failed" contains "$test_dry_run_output" "would run bash scripts/devloop_test.sh" "release test dry-run" - publish_dry_run_output="$(release_main "patch" --publish --dry-run)" || fail "release publish dry-run required git-cliff" - contains "$publish_dry_run_output" "would verify local HEAD matches upstream, then skip local tests" "release publish dry-run" - contains "$publish_dry_run_output" "would push branch and tag" "release publish dry-run" - contains "$publish_dry_run_output" "would build release assets: devloop-9.9.10.tar.gz and devloop-9.9.10.tar.gz.sha256" "release publish dry-run" - contains "$publish_dry_run_output" "would create GitHub release: gh release create v9.9.10 --verify-tag --generate-notes" "release publish dry-run" + publish_dry_run_output="$(release_main publish --dry-run)" || fail "release publish dry-run failed" + contains "$publish_dry_run_output" "version: 9.9.9" "release publish dry-run" + contains "$publish_dry_run_output" "tag: v9.9.9" "release publish dry-run" + contains "$publish_dry_run_output" "would require CHANGELOG.md to match current main" "release publish dry-run" + contains "$publish_dry_run_output" "would create and push tag: v9.9.9" "release publish dry-run" + contains "$publish_dry_run_output" "tag workflow would build assets" "release publish dry-run" + publish_test_dry_run_output="$(release_main publish --run-tests --dry-run)" || fail "release publish test dry-run failed" + contains "$publish_test_dry_run_output" "would run bash scripts/devloop_test.sh" "release publish test dry-run" + if old_publish_output="$(release_main patch --publish --dry-run 2>&1)"; then + fail "release accepted the removed bump-and-publish flag" + fi + contains "$old_publish_output" "unknown option: --publish" "release removed publish flag" git -C "$ROOT" config user.email devloop-test@example.com git -C "$ROOT" config user.name "devloop test" git -C "$ROOT" add VERSION site/public/VERSION @@ -1875,14 +1886,130 @@ ok "pure helpers" printf '%s\n' "dirty" > "$ROOT/dirty" if release_assert_clean_tree >/dev/null 2>&1; then fail "release clean tree accepted dirty repo"; fi rm "$ROOT/dirty" - if release_assert_head_matches_upstream >/dev/null 2>&1; then fail "release upstream accepted missing upstream"; fi - git -C "$ROOT" branch verified-main - git -C "$ROOT" branch --set-upstream-to=verified-main >/dev/null - release_assert_head_matches_upstream || fail "release upstream rejected matching HEAD" - git -C "$ROOT" commit --allow-empty -q -m local-ahead - if release_assert_head_matches_upstream >/dev/null 2>&1; then fail "release upstream accepted local ahead HEAD"; fi - [ -n "$(release_current_branch)" ] || fail "release current branch missing" - DEVLOOP_RELEASE_ALLOW_BRANCH=1 release_assert_push_branch || fail "release push branch rejected" + release_assert_version_files "9.9.9" || fail "release rejected matching version files" + printf '%s\n' "9.9.8" > "$ROOT/site/public/VERSION" + if release_assert_version_files "9.9.9" >/dev/null 2>&1; then fail "release accepted mismatched version files"; fi + printf '%s\n' "9.9.9" > "$ROOT/site/public/VERSION" + + release_flow_root="$work/release-flow" + release_flow_remote="$work/release-flow-remote.git" + release_flow_bin="$work/release-flow-bin" + release_gh_log="$work/release-gh.log" + mkdir -p "$release_flow_root/site/public" "$release_flow_bin" + cp "$REPO_ROOT/cliff.toml" "$release_flow_root/cliff.toml" + printf '%s\n' "9.9.9" > "$release_flow_root/VERSION" + printf '%s\n' "9.9.9" > "$release_flow_root/site/public/VERSION" + printf '%s\n' "# Changelog" > "$release_flow_root/CHANGELOG.md" + git init -q "$release_flow_root" + git -C "$release_flow_root" branch -m main + git -C "$release_flow_root" config user.email devloop-test@example.com + git -C "$release_flow_root" config user.name "devloop test" + git -C "$release_flow_root" add . + git -C "$release_flow_root" commit -q -m "feat: initial release fixture" + git init -q --bare "$release_flow_remote" + git -C "$release_flow_root" remote add origin "$release_flow_remote" + git -C "$release_flow_root" push -q -u origin main + cat > "$release_flow_bin/git-cliff" <<'GIT_CLIFF' +#!/usr/bin/env bash +set -euo pipefail +output="" +while [ "$#" -gt 0 ]; do + if [ "$1" = "--output" ]; then + shift + output="$1" + fi + shift +done +{ + printf '%s\n\n' "# Changelog" + printf '## [9.9.10](https://github.com/satyaborg/devloop/releases/tag/v9.9.10) - %s\n\n' "$(git for-each-ref --format='%(taggerdate:short)' refs/tags/v9.9.10)" + git log --reverse --format='- %s' --grep='^feat:' +} > "$output" +GIT_CLIFF + cat > "$release_flow_bin/gh" <<'GH' +#!/usr/bin/env bash +set -euo pipefail +printf '%s\n' "$*" >> "$RELEASE_GH_LOG" +printf '%s\n' "https://example.test/release-pr" +GH + chmod +x "$release_flow_bin/git-cliff" "$release_flow_bin/gh" + export RELEASE_GH_LOG="$release_gh_log" + PATH="$release_flow_bin:$PATH" + ROOT="$release_flow_root" + + GIT_AUTHOR_DATE="2026-07-14T10:00:00Z" + GIT_COMMITTER_DATE="2026-07-14T10:00:00Z" + export GIT_AUTHOR_DATE GIT_COMMITTER_DATE + release_prepare_output="$(release_prepare_pr patch false false 2>&1)" || fail "release pull request preparation failed" + unset GIT_AUTHOR_DATE GIT_COMMITTER_DATE + equals "$(release_current_branch)" "chore/release-v9.9.10" "release pull request branch" + equals "$(release_current_version)" "9.9.10" "release pull request root version" + equals "$(sed -n '1p' "$ROOT/site/public/VERSION")" "9.9.10" "release pull request site version" + contains "$(cat "$ROOT/CHANGELOG.md")" " - 2026-07-14" "release pull request changelog date" + contains "$(cat "$ROOT/CHANGELOG.md")" "- feat: initial release fixture" "release pull request changelog" + contains "$(cat "$release_gh_log")" "pr create --base main --head chore/release-v9.9.10" "release pull request creation" + contains "$(cat "$release_gh_log")" "./scripts/release.sh publish" "release pull request instructions" + contains "$release_prepare_output" "opened release pull request for v9.9.10" "release pull request output" + if git -C "$ROOT" rev-parse -q --verify refs/tags/v9.9.10 >/dev/null; then fail "release pull request left a local tag"; fi + git -C "$ROOT" ls-remote --exit-code --heads origin refs/heads/chore/release-v9.9.10 >/dev/null || fail "release pull request branch was not pushed" + + git -C "$ROOT" switch -q main + GIT_AUTHOR_DATE="2026-07-15T10:00:00Z" GIT_COMMITTER_DATE="2026-07-15T10:00:00Z" \ + git -C "$ROOT" merge -q --no-ff -m "Merge release pull request" chore/release-v9.9.10 + git -C "$ROOT" push -q origin main + next_day_changelog="$work/release-next-day-changelog.md" + GIT_COMMITTER_DATE="2026-07-15T10:00:00Z" release_render_changelog "9.9.10" "$next_day_changelog" + contains "$(cat "$next_day_changelog")" " - 2026-07-15" "release next-day changelog date" + if cmp -s "$ROOT/CHANGELOG.md" "$next_day_changelog"; then fail "release next-day changelogs unexpectedly matched before normalization"; fi + release_changelog_matches "9.9.10" "$ROOT/CHANGELOG.md" "$next_day_changelog" || fail "release rejected a date-only changelog difference" + rm "$next_day_changelog" + + printf '%s\n' "late release change" > "$ROOT/late.txt" + git -C "$ROOT" add late.txt + GIT_AUTHOR_DATE="2026-07-15T11:00:00Z" GIT_COMMITTER_DATE="2026-07-15T11:00:00Z" \ + git -C "$ROOT" commit -q -m "feat: land after release preparation" + git -C "$ROOT" push -q origin main + if stale_changelog_output="$(GIT_COMMITTER_DATE="2026-07-15T11:00:00Z" release_publish false false 2>&1)"; then + fail "release publish accepted a stale changelog" + fi + contains "$stale_changelog_output" "CHANGELOG.md does not match v9.9.10 at current main" "release stale changelog" + contains "$stale_changelog_output" "refresh CHANGELOG.md in a release pull request" "release stale changelog instructions" + if git -C "$ROOT" rev-parse -q --verify refs/tags/v9.9.10 >/dev/null; then fail "stale changelog left a local tag"; fi + + GIT_COMMITTER_DATE="2026-07-15T11:00:00Z" release_render_changelog "9.9.10" "$ROOT/CHANGELOG.md" + git -C "$ROOT" add CHANGELOG.md + GIT_AUTHOR_DATE="2026-07-15T12:00:00Z" GIT_COMMITTER_DATE="2026-07-15T12:00:00Z" \ + git -C "$ROOT" commit -q -m "chore: refresh release changelog" + git -C "$ROOT" push -q origin main + release_head="$(git -C "$ROOT" rev-parse HEAD)" + + cat > "$release_flow_remote/hooks/pre-receive" <<'PRE_RECEIVE' +#!/usr/bin/env bash +set -euo pipefail +while read -r _old _new ref; do + if [ "$ref" = "refs/tags/v9.9.10" ]; then exit 1; fi +done +PRE_RECEIVE + chmod +x "$release_flow_remote/hooks/pre-receive" + if failed_push_output="$(GIT_COMMITTER_DATE="2026-07-15T12:00:00Z" release_publish false false 2>&1)"; then + fail "release publish accepted a rejected tag push" + fi + contains "$failed_push_output" "pre-receive hook declined" "release rejected tag push" + if git -C "$ROOT" rev-parse -q --verify refs/tags/v9.9.10 >/dev/null; then fail "failed tag push left a local tag"; fi + if git -C "$ROOT" ls-remote --exit-code --tags origin refs/tags/v9.9.10 >/dev/null 2>&1; then fail "failed tag push created a remote tag"; fi + rm "$release_flow_remote/hooks/pre-receive" + + release_publish_output="$(GIT_COMMITTER_DATE="2026-07-15T12:00:00Z" release_publish false false 2>&1)" || fail "release publish failed" + equals "$(release_current_version)" "9.9.10" "release publish preserved version" + equals "$(git -C "$ROOT" rev-parse HEAD)" "$release_head" "release publish preserved commit" + equals "$(git -C "$ROOT" rev-list -n 1 v9.9.10)" "$release_head" "release publish tag commit" + git -C "$ROOT" ls-remote --exit-code --tags origin refs/tags/v9.9.10 >/dev/null || fail "release publish tag was not pushed" + contains "$release_publish_output" "published v9.9.10" "release publish output" + contains "$release_publish_output" "GitHub Actions will build assets" "release publish workflow output" + if duplicate_publish_output="$(release_publish false false 2>&1)"; then + fail "release publish accepted an existing tag" + fi + contains "$duplicate_publish_output" "tag already exists: v9.9.10" "release duplicate publish" ) ok "release helpers" diff --git a/scripts/release.sh b/scripts/release.sh index d967834..b17221c 100755 --- a/scripts/release.sh +++ b/scripts/release.sh @@ -18,17 +18,21 @@ RELEASE_CHECKSUM="" release_usage() { cat <<'EOF' -usage: ./scripts/release.sh [--dry-run] [--run-tests] [--publish] [--push] +usage: + ./scripts/release.sh [--dry-run] [--run-tests] + ./scripts/release.sh publish [--dry-run] [--run-tests] -Bumps VERSION, creates a release commit, and creates an annotated tag. -Use --run-tests to run bash scripts/devloop_test.sh before changing files. -Use --publish to push the commit and tag, then create a GitHub Release. +Release flow: + 1. Run patch, minor, or major to open a version-bump pull request. + 2. Merge the pull request after CI passes. + 3. Run publish from main to push the existing version tag. + 4. GitHub Actions builds the assets and creates the GitHub Release. Examples: ./scripts/release.sh patch --dry-run - ./scripts/release.sh patch --run-tests - ./scripts/release.sh minor --publish - ./scripts/release.sh major --push + ./scripts/release.sh minor + ./scripts/release.sh publish --dry-run + ./scripts/release.sh publish EOF } @@ -109,18 +113,87 @@ release_assert_clean_tree() { return 1 } +release_assert_remote_ref_missing() { + local kind="$1" + local ref="$2" + local status + + set +e + git -C "$ROOT" ls-remote --exit-code "--$kind" origin "$ref" >/dev/null 2>&1 + status=$? + set -e + + case "$status" in + 0) + printf '%s already exists on origin: %s\n' "$kind" "$ref" >&2 + return 1 + ;; + 2) return 0 ;; + *) + printf 'failed to check origin for %s: %s\n' "$kind" "$ref" >&2 + return 1 + ;; + esac +} + release_assert_tag_available() { local tag="$1" if git -C "$ROOT" rev-parse -q --verify "refs/tags/$tag" >/dev/null; then printf 'tag already exists: %s\n' "$tag" >&2 return 1 fi + release_assert_remote_ref_missing tags "refs/tags/$tag" +} + +release_assert_branch_available() { + local branch="$1" + if git -C "$ROOT" rev-parse -q --verify "refs/heads/$branch" >/dev/null; then + printf 'branch already exists: %s\n' "$branch" >&2 + return 1 + fi + release_assert_remote_ref_missing heads "refs/heads/$branch" } release_current_branch() { git -C "$ROOT" branch --show-current } +release_assert_main_branch() { + local branch + branch="$(release_current_branch)" + if [ "$branch" = "main" ]; then return 0; fi + printf 'release requires main branch, on: %s\n' "${branch:-detached HEAD}" >&2 + return 1 +} + +release_assert_head_matches_origin_main() { + local head origin_head + if ! git -C "$ROOT" fetch --quiet origin refs/heads/main; then + printf '%s\n' "failed to fetch origin/main" >&2 + return 1 + fi + head="$(git -C "$ROOT" rev-parse HEAD)" + origin_head="$(git -C "$ROOT" rev-parse FETCH_HEAD)" + if [ "$head" = "$origin_head" ]; then return 0; fi + printf '%s\n' "local main must match origin/main before release" >&2 + printf 'local HEAD: %s\n' "$head" >&2 + printf 'origin/main: %s\n' "$origin_head" >&2 + return 1 +} + +release_assert_version_files() { + local version="$1" + local site_version="$ROOT/site/public/VERSION" + if [ "$(release_current_version)" != "$version" ]; then + printf 'root VERSION does not match release version: %s\n' "$version" >&2 + return 1 + fi + if [ ! -f "$site_version" ] || [ "$(sed -n '1p' "$site_version")" != "$version" ]; then + printf 'site VERSION does not match release version: %s\n' "$version" >&2 + return 1 + fi +} + release_checksum_file() { local file="$1" if command -v shasum >/dev/null 2>&1; then @@ -160,73 +233,208 @@ release_create_artifacts() { rm -rf "$staging" } -release_assert_push_branch() { - local branch - branch="$(release_current_branch)" - if [ -z "$branch" ]; then - printf '%s\n' "refusing to push release from detached HEAD" >&2 +release_render_changelog() { + local version="$1" + local output="$2" + local tag status + tag="$(release_tag_for_version "$version")" + status=0 + + git -C "$ROOT" tag -a "$tag" -m "devloop $version" || return $? + (cd "$ROOT" && git-cliff --config "$ROOT/cliff.toml" --output "$output") || status=$? + if ! git -C "$ROOT" tag -d "$tag" >/dev/null; then + printf 'failed to remove temporary tag: %s\n' "$tag" >&2 return 1 fi - if [ "$branch" = "main" ] || [ "${DEVLOOP_RELEASE_ALLOW_BRANCH:-0}" = "1" ]; then return 0; fi - printf 'refusing to push release from branch: %s\n' "$branch" >&2 - printf '%s\n' "checkout main, or set DEVLOOP_RELEASE_ALLOW_BRANCH=1" >&2 - return 1 + return "$status" } -release_assert_head_matches_upstream() { - local branch remote merge head upstream_head upstream_label - branch="$(release_current_branch)" - if [ -z "$branch" ]; then - printf '%s\n' "refusing to skip tests from detached HEAD" >&2 - return 1 +release_normalize_changelog_date() { + local version="$1" + local input="$2" + local output="$3" + awk -v version="$version" ' + index($0, "## [" version "](") == 1 { + sub(/ - [0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]$/, " - ") + } + { print } + ' "$input" > "$output" +} + +release_changelog_matches() { + local version="$1" + local actual="$2" + local expected="$3" + local actual_normalized expected_normalized status + + actual_normalized="$(mktemp "${TMPDIR:-/tmp}/devloop-changelog-actual.XXXXXX")" || return $? + expected_normalized="$(mktemp "${TMPDIR:-/tmp}/devloop-changelog-expected.XXXXXX")" || { + status=$? + rm -f "$actual_normalized" + return "$status" + } + status=0 + release_normalize_changelog_date "$version" "$actual" "$actual_normalized" || status=$? + if [ "$status" -eq 0 ]; then + release_normalize_changelog_date "$version" "$expected" "$expected_normalized" || status=$? fi + if [ "$status" -eq 0 ]; then + cmp -s "$actual_normalized" "$expected_normalized" || status=$? + fi + rm -f "$actual_normalized" "$expected_normalized" + return "$status" +} - remote="$(git -C "$ROOT" config "branch.$branch.remote" || true)" - merge="$(git -C "$ROOT" config "branch.$branch.merge" || true)" - if [ -z "$remote" ] || [ -z "$merge" ]; then - printf 'refusing to skip tests without an upstream for branch: %s\n' "$branch" >&2 - printf '%s\n' "set an upstream, sync with it, or pass --run-tests" >&2 - return 1 +release_pr_body() { + local version="$1" + cat <&2 - printf '%s\n' "fetch manually or pass --run-tests" >&2 - return 1 + printf '%s\n' "skip local tests; pull request CI remains authoritative" + fi + + git -C "$ROOT" push -u origin "$branch" || return $? + gh pr create --base main --head "$branch" --title "$title" --body "$(release_pr_body "$version")" || return $? + printf 'opened release pull request for %s\n' "$tag" +} + +release_publish() { + local dry_run="$1" + local run_tests="$2" + local version tag expected_changelog status + + version="$(release_current_version)" + if ! release_version_valid "$version"; then + printf 'invalid current VERSION: %s\n' "$version" >&2 + return 2 + fi + tag="$(release_tag_for_version "$version")" + + if [ "$dry_run" = true ]; then + printf 'version: %s\n' "$version" + printf 'tag: %s\n' "$tag" + printf '%s\n' "would require clean main matching origin/main" + printf '%s\n' "would require matching VERSION and site/public/VERSION" + printf '%s\n' "would require CHANGELOG.md to match current main" + if [ "$run_tests" = true ]; then + printf '%s\n' "would run bash scripts/devloop_test.sh" + else + printf '%s\n' "would skip local tests; tag workflow runs the release gates" fi - upstream_head="$(git -C "$ROOT" rev-parse FETCH_HEAD)" || return 1 + printf 'would create and push tag: %s\n' "$tag" + printf '%s\n' "tag workflow would build assets and create the GitHub Release" + return 0 fi - if [ "$head" = "$upstream_head" ]; then return 0; fi - printf '%s\n' "refusing to skip tests for a HEAD that differs from upstream" >&2 - printf 'local HEAD: %s\n' "$head" >&2 - printf 'upstream %s: %s\n' "$upstream_label" "$upstream_head" >&2 - printf '%s\n' "sync with upstream or pass --run-tests" >&2 - return 1 + release_require_command git || return $? + release_require_command git-cliff || return $? + release_require_command awk || return $? + release_require_command cmp || return $? + release_assert_main_branch || return $? + release_assert_head_matches_origin_main || return $? + release_assert_clean_tree || return $? + release_assert_version_files "$version" || return $? + release_assert_tag_available "$tag" || return $? + + expected_changelog="$(mktemp "${TMPDIR:-/tmp}/devloop-changelog.XXXXXX")" || return $? + release_render_changelog "$version" "$expected_changelog" || { + status=$? + rm -f "$expected_changelog" + return "$status" + } + if ! release_changelog_matches "$version" "$ROOT/CHANGELOG.md" "$expected_changelog"; then + rm -f "$expected_changelog" + printf 'CHANGELOG.md does not match %s at current main\n' "$tag" >&2 + printf '%s\n' "refresh CHANGELOG.md in a release pull request, merge it, then retry publish" >&2 + return 1 + fi + rm -f "$expected_changelog" + + if [ "$run_tests" = true ]; then + bash "$ROOT/scripts/devloop_test.sh" || return $? + else + printf '%s\n' "skip local tests; tag workflow runs the release gates" + fi + + git -C "$ROOT" tag -a "$tag" -m "devloop $version" || return $? + if git -C "$ROOT" push origin "$tag"; then + : + else + status=$? + if ! git -C "$ROOT" tag -d "$tag" >/dev/null; then + printf 'failed to remove local tag after push failure: %s\n' "$tag" >&2 + fi + return "$status" + fi + printf 'published %s\n' "$tag" + printf '%s\n' "GitHub Actions will build assets and create the GitHub Release." } release_main() { - local bump="" - local current version + local action="" local dry_run=false - local publish=false - local push=false local run_tests=false - local tag branch - local artifact_dir while [ "$#" -gt 0 ]; do case "$1" in --dry-run) dry_run=true ;; --run-tests) run_tests=true ;; - --publish) publish=true ;; - --push) push=true ;; -h|--help) release_usage; return 0 ;; --*) printf 'unknown option: %s\n' "$1" >&2 @@ -234,109 +442,29 @@ release_main() { return 2 ;; *) - if [ -n "$bump" ]; then + if [ -n "$action" ]; then release_usage >&2 return 2 fi - bump="$1" + action="$1" ;; esac shift done - case "$bump" in - patch|minor|major) ;; + case "$action" in + patch|minor|major) release_prepare_pr "$action" "$dry_run" "$run_tests" ;; + publish) release_publish "$dry_run" "$run_tests" ;; "") release_usage >&2 return 2 ;; *) - printf 'invalid bump: %s\n' "$bump" >&2 + printf 'invalid action: %s\n' "$action" >&2 release_usage >&2 return 2 ;; esac - - current="$(release_current_version)" - version="$(release_next_version "$bump" "$current")" || return $? - if [ -z "$version" ]; then - release_usage >&2 - return 2 - fi - if ! release_version_valid "$version"; then - printf 'invalid SemVer version: %s\n' "$version" >&2 - return 2 - fi - - tag="$(release_tag_for_version "$version")" - release_require_command git - if [ "$dry_run" = true ]; then - release_assert_tag_available "$tag" - printf 'current: %s\n' "$current" - printf 'next: %s (%s)\n' "$version" "$tag" - if [ -n "$(git -C "$ROOT" status --porcelain)" ]; then - printf '%s\n' "note: actual release requires a clean working tree" - fi - if [ "$run_tests" = true ]; then - printf '%s\n' "would run bash scripts/devloop_test.sh" - elif [ "$publish" = true ] || [ "$push" = true ]; then - printf '%s\n' "would verify local HEAD matches upstream, then skip local tests" - else - printf '%s\n' "would skip local tests (use --run-tests to run bash scripts/devloop_test.sh)" - fi - printf 'would commit: chore: release %s\n' "$version" - printf 'would tag: %s\n' "$tag" - printf '%s\n' "would regenerate CHANGELOG.md from tagged history, amend the release commit, and move the tag" - if [ "$publish" = true ] || [ "$push" = true ]; then printf '%s\n' "would push branch and tag"; fi - if [ "$publish" = true ]; then - printf 'would build release assets: devloop-%s.tar.gz and devloop-%s.tar.gz.sha256\n' "$version" "$version" - printf 'would create GitHub release: gh release create %s --verify-tag --generate-notes devloop-%s.tar.gz devloop-%s.tar.gz.sha256\n' "$tag" "$version" "$version" - fi - return 0 - fi - - release_require_command git-cliff - if [ "$publish" = true ]; then release_require_command gh; fi - release_assert_tag_available "$tag" - release_assert_clean_tree - if [ "$publish" = true ] || [ "$push" = true ]; then release_assert_push_branch; fi - - if [ "$run_tests" = true ]; then - bash "$ROOT/scripts/devloop_test.sh" - else - if [ "$publish" = true ] || [ "$push" = true ]; then release_assert_head_matches_upstream; fi - printf '%s\n' "skip local tests (use --run-tests to run bash scripts/devloop_test.sh)" - fi - release_write_version_files "$version" - git -C "$ROOT" add VERSION - if [ -f "$ROOT/site/public/VERSION" ]; then git -C "$ROOT" add site/public/VERSION; fi - git -C "$ROOT" commit -m "chore: release $version" - git -C "$ROOT" tag -a "$tag" -m "devloop $version" - # Regenerate the changelog after the tag exists and render from the tagged - # history. Two git-cliff (2.x) footguns to avoid: passing --workdir with an - # absolute path breaks its detection that HEAD is tagged and collapses the file - # to a single empty "Unreleased" header, and --tag for a version it treats as - # new truncates the same way. So run from $ROOT without --workdir or --tag, - # then fold the result into the release commit and move the tag onto it. - ( cd "$ROOT" && git-cliff --config "$ROOT/cliff.toml" --output "$ROOT/CHANGELOG.md" ) - git -C "$ROOT" add CHANGELOG.md - git -C "$ROOT" commit --amend --no-edit - git -C "$ROOT" tag -f -a "$tag" -m "devloop $version" - - if [ "$publish" = true ] || [ "$push" = true ]; then - branch="$(release_current_branch)" - git -C "$ROOT" push origin "$branch" - git -C "$ROOT" push origin "$tag" - fi - - if [ "$publish" = true ]; then - artifact_dir="$(mktemp -d "${TMPDIR:-/tmp}/devloop-release.XXXXXX")" - release_create_artifacts "$version" "$artifact_dir" - gh release create "$tag" --verify-tag --generate-notes "$RELEASE_ARCHIVE" "$RELEASE_CHECKSUM" - rm -rf "$artifact_dir" - fi - - printf 'released %s\n' "$tag" } if [ "${DEVLOOP_RELEASE_LIB:-}" != "1" ]; then