diff --git a/.github/PULL_REQUEST_TEMPLATE/promotion.md b/.github/PULL_REQUEST_TEMPLATE/promotion.md index 9d488e9c..eec969d6 100644 --- a/.github/PULL_REQUEST_TEMPLATE/promotion.md +++ b/.github/PULL_REQUEST_TEMPLATE/promotion.md @@ -31,9 +31,10 @@ The `Promotion gate` check directly depends on every constituent below and fails | Exact-candidate clean install and startup | `Clean install and startup` | Pending | | Real objects install, update, unload, delete | `Real objects install, update, unload, delete` | Pending | | Aggregate | `Promotion gate` | Pending | +| Semantic version and notes | `Release plan` | Pending | - [ ] The candidate SHA has not changed since every required check completed. -- [ ] The `Guard main branch source` and `Promotion gate` required contexts pass. +- [ ] The `Guard main branch source`, `Promotion gate`, and `Release plan` required contexts pass. - [ ] The complete file and commit compare contains only reviewed work. - [ ] PR conversations, review summaries, and all review threads have been read; actionable findings are addressed and required code-owner approvals exist. @@ -47,6 +48,10 @@ List each unresolved issue and its disposition. Write `None` only after checking Summarize behavioral changes. If no migration is required, state why. +### Release plan + +Review the `Release Plan` workflow comment. Confirm the proposed semantic tag and deterministic notes describe the complete candidate, or confirm that the workflow reports a no-op. If the version is wrong, change the Conventional Commit history on `next` through a reviewed pull request before merging this promotion. + ### Public-contract follow-ups Link documentation and consumer follow-ups identified by the public-contract impact check. Write `None` only with a rationale. @@ -102,4 +107,4 @@ Never force-push either persistent branch. Roll back with a reviewed revert comm ## Stable consumption boundary -Merging this promotion updates the Git-consumed stable `main` ref. It does not create a semantic tag or GitHub release. Any later tag is a separately approved action with its own release notes and exact-ref validation. +Merging this promotion updates the Git-consumed stable `main` ref and authorizes the reviewed release plan. When releasable commits exist, publication waits until every required workflow succeeds on the exact merge SHA, then creates the annotated tag and GitHub release automatically. A no-op plan creates neither. The signed manual tag path remains available for recovery. diff --git a/.github/workflows/promotion-readiness.yml b/.github/workflows/promotion-readiness.yml index a824616d..9043baf9 100644 --- a/.github/workflows/promotion-readiness.yml +++ b/.github/workflows/promotion-readiness.yml @@ -23,8 +23,8 @@ jobs: with: ref: ${{ github.event.pull_request.head.sha }} - - name: Install Zsh and archive tools - run: sudo apt-get update && sudo apt-get install -yq zsh zip unzip + - name: Install Zsh, jq, and archive tools + run: sudo apt-get update && sudo apt-get install -yq zsh jq zip unzip - name: Check and compile Zsh sources shell: bash @@ -68,9 +68,18 @@ jobs: if: ${{ hashFiles('tests/snippet-directory-mirror.zsh') != '' }} run: zsh -f tests/snippet-directory-mirror.zsh + - name: Test release planning + run: zsh -f tests/release-plan.zsh + + - name: Test promotion release verification + run: zsh -f tests/promotion-release-verification.zsh + + - name: Test idempotent promotion publication + run: zsh -f tests/promotion-release-publication.zsh + zd: name: ZD integration - uses: z-shell/zd/.github/workflows/test-native.yml@01c3477e48c0c31bb7225986bb1bac4270e151ff # z-shell/zd#121 + uses: z-shell/zd/.github/workflows/test-native.yml@846591255b19558f4c61d9502982dc1ad6a049db # z-shell/zd#123 with: zi_repo: ${{ github.event.pull_request.head.repo.full_name }} zi_ref: ${{ github.event.pull_request.head.sha }} diff --git a/.github/workflows/release-plan.yml b/.github/workflows/release-plan.yml new file mode 100644 index 00000000..23c87a17 --- /dev/null +++ b/.github/workflows/release-plan.yml @@ -0,0 +1,75 @@ +--- +name: Release Plan + +on: + pull_request: + branches: [main] + types: [opened, reopened, synchronize, ready_for_review] + workflow_dispatch: {} + +permissions: + contents: read + pull-requests: write + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.run_id }} + cancel-in-progress: true + +jobs: + plan: + name: Release plan + runs-on: ubuntu-latest + steps: + - name: Check out the candidate + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.event.pull_request.head.sha || github.sha }} + fetch-depth: 0 + fetch-tags: true + persist-credentials: false + + - name: Install Zsh + run: sudo apt-get update && sudo apt-get install -yq zsh + + - name: Compute release plan + id: release + if: "${{ github.event_name == 'workflow_dispatch' || (github.event.pull_request.head.repo.full_name == github.repository && github.event.pull_request.head.ref == 'next') }}" + env: + RELEASE_NOTES_FILE: ${{ runner.temp }}/release-notes.md + RELEASE_PLAN_OUTPUT: ${{ runner.temp }}/release-plan.env + RELEASE_PLAN_BODY: ${{ runner.temp }}/release-plan.md + run: | + zsh -f scripts/release-plan.zsh HEAD > "$RELEASE_PLAN_BODY" + cat "$RELEASE_PLAN_BODY" >> "$GITHUB_STEP_SUMMARY" + cat "$RELEASE_PLAN_OUTPUT" >> "$GITHUB_OUTPUT" + + - name: Record non-promotion result + if: "${{ github.event_name == 'pull_request' && (github.event.pull_request.head.repo.full_name != github.repository || github.event.pull_request.head.ref != 'next') }}" + run: echo 'This pull request is not an internal next-to-main promotion; automatic publication does not apply.' >> "$GITHUB_STEP_SUMMARY" + + - name: Update promotion pull request + if: "${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository && github.event.pull_request.head.ref == 'next' }}" + env: + GH_TOKEN: ${{ github.token }} + PR_NUMBER: ${{ github.event.pull_request.number }} + RELEASE_PLAN_BODY: ${{ runner.temp }}/release-plan.md + run: | + set -euo pipefail + marker='' + body="${RUNNER_TEMP}/release-plan-comment.md" + { + echo "$marker" + cat "$RELEASE_PLAN_BODY" + echo + echo '_Merging this reviewed promotion authorizes publication after every required workflow succeeds on the exact merge SHA._' + } > "$body" + comment_id="$(gh api --paginate \ + "repos/${GITHUB_REPOSITORY}/issues/${PR_NUMBER}/comments" \ + --jq ".[] | select(.user.login == \"github-actions[bot]\" and (.body | contains(\"${marker}\"))) | .id" | head -n 1)" + if [[ -n "$comment_id" ]]; then + gh api --method PATCH "repos/${GITHUB_REPOSITORY}/issues/comments/${comment_id}" \ + -F body=@"$body" >/dev/null + else + gh api --method POST "repos/${GITHUB_REPOSITORY}/issues/${PR_NUMBER}/comments" \ + -F body=@"$body" >/dev/null + fi diff --git a/.github/workflows/release-prepare.yml b/.github/workflows/release-prepare.yml deleted file mode 100644 index cd15c777..00000000 --- a/.github/workflows/release-prepare.yml +++ /dev/null @@ -1,21 +0,0 @@ ---- -name: Release Prepare - -on: - push: - branches: [main] - -permissions: - contents: read - issues: write - models: read - -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: false - -jobs: - propose: - uses: z-shell/.github/.github/workflows/release-prepare.yml@6f3d88335ca0ae77b795ec2883b4402b51f15c6a # main - with: - signed_tag: true diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b4ca3044..4e34b01c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -4,20 +4,24 @@ name: Release on: push: tags: ["v*.*.*"] + workflow_run: + workflows: [Zsh, ZD Integration, CodeQL, Trunk Code Quality] + types: [completed] -permissions: - actions: read - contents: write +permissions: {} concurrency: - group: ${{ github.workflow }}-${{ github.ref }} + group: release-${{ github.event.workflow_run.head_sha || github.ref_name }} cancel-in-progress: false jobs: - publish: - name: Verify and publish - if: github.repository == 'z-shell/zi' + manual: + name: Verify and publish recovery tag + if: github.event_name == 'push' && github.repository == 'z-shell/zi' runs-on: ubuntu-latest + permissions: + actions: read + contents: write steps: - name: Check out the tagged commit uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -49,3 +53,55 @@ jobs: --title "Zi $TAG" \ --generate-notes \ --latest + + automatic: + name: Publish reviewed promotion + if: "${{ github.event_name == 'workflow_run' && github.repository == 'z-shell/zi' && github.event.workflow_run.head_branch == 'main' }}" + runs-on: ubuntu-latest + permissions: + actions: read + contents: write + pull-requests: read + steps: + - name: Check out the trusted main branch + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: refs/heads/main + fetch-depth: 0 + fetch-tags: true + + - name: Install Zsh + run: sudo apt-get update && sudo apt-get install -yq zsh + + - name: Verify promotion and exact-SHA validation + id: verify + env: + GH_TOKEN: ${{ github.token }} + PROMOTION_SHA: ${{ github.event.workflow_run.head_sha }} + run: zsh -f scripts/verify-promotion-release.zsh + + - name: Compute the authorized release plan + id: plan + if: steps.verify.outputs.ready == 'true' + env: + RELEASE_NOTES_FILE: ${{ runner.temp }}/release-notes.md + RELEASE_TARGET: ${{ github.event.workflow_run.head_sha }} + run: | + RELEASE_PLAN_OUTPUT="$GITHUB_OUTPUT" \ + zsh -f scripts/release-plan.zsh \ + "$RELEASE_TARGET" >> "$GITHUB_STEP_SUMMARY" + + - name: Record no-op promotion + if: steps.verify.outputs.ready == 'true' && steps.plan.outputs.release != 'true' + run: echo 'The reviewed promotion contains no releasable Conventional Commits; no tag or release was created.' >> "$GITHUB_STEP_SUMMARY" + + - name: Create annotated tag and release + if: steps.verify.outputs.ready == 'true' && steps.plan.outputs.release == 'true' + env: + GH_TOKEN: ${{ github.token }} + RELEASE_NOTES_FILE: ${{ runner.temp }}/release-notes.md + RELEASE_TAG: ${{ steps.plan.outputs.tag }} + RELEASE_TARGET: ${{ github.event.workflow_run.head_sha }} + run: | + zsh -f scripts/publish-promotion-release.zsh >> "$GITHUB_STEP_SUMMARY" + echo "Authorized by reviewed promotion #${{ steps.verify.outputs.promotion_pr }}." >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/zd-integration.yml b/.github/workflows/zd-integration.yml index 0b9f6d72..17260f65 100644 --- a/.github/workflows/zd-integration.yml +++ b/.github/workflows/zd-integration.yml @@ -4,10 +4,6 @@ name: ZD Integration on: push: branches: [main, next] - paths: - - ".github/workflows/zd-integration.yml" - - "zi.zsh" - - "lib/**" pull_request: paths: - ".github/workflows/zd-integration.yml" @@ -24,7 +20,7 @@ permissions: jobs: zd-test: - uses: z-shell/zd/.github/workflows/test-native.yml@01c3477e48c0c31bb7225986bb1bac4270e151ff # z-shell/zd#121 + uses: z-shell/zd/.github/workflows/test-native.yml@846591255b19558f4c61d9502982dc1ad6a049db # z-shell/zd#123 with: zi_repo: ${{ github.event.pull_request.head.repo.full_name || github.repository }} zi_ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} diff --git a/.github/workflows/zsh-n.yml b/.github/workflows/zsh-n.yml index 68e4f9f5..63c3294e 100644 --- a/.github/workflows/zsh-n.yml +++ b/.github/workflows/zsh-n.yml @@ -6,56 +6,15 @@ on: branches: - main - next - paths: - - "zi.zsh" - - "lib/**" - - "contracts/package-manifest-v1.json" - - "scripts/validate-package-manifest.py" - - "tests/**" - - ".github/workflows/*.yml" - - "tests/annex-unregister.zsh" - - "tests/atinit-deferred-marker.zsh" - - "tests/benchmark-harness.zsh" - - "benchmarks/**" - - "tests/ci-registration.zsh" - - "tests/archive-extraction.zsh" - - "tests/completion-refresh.zsh" - - "tests/disk-ice-resolution.zsh" - - "tests/home-preparation.zsh" - - "tests/hook-ownership.zsh" - - "tests/ice-tokenizer.zsh" - - "tests/load-object-status.zsh" - - "tests/message-formatting.zsh" - - "tests/package-manifest-contract.zsh" - - "tests/package-manifest-fixtures.zsh" - - "tests/fixtures/package-manifests/**" - - "scripts/refresh-package-manifests.zsh" - - "tests/package-manifest-parsing.zsh" - - "tests/path-resolution.zsh" - - "tests/parallel-update.zsh" - - "tests/plugin-autoload-fpath-scope.zsh" - - "tests/plugin-autoload-ice.zsh" - - "tests/nested-load-state.zsh" - - "tests/pack-service-first-install.zsh" - - "tests/plugin-autoload-ownership.zsh" - - "tests/plugin-standard-callbacks.zsh" - - "tests/release-tag-verification.zsh" - - "tests/scheduler-idle.zsh" - - "tests/fixtures/plugin-standard-callbacks/**" - - "tests/self-update-reload.zsh" - - "tests/snippet-directory-mirror.zsh" - - "tests/snippet-update-status.zsh" - - "tests/source-hygiene.zsh" - - "tests/subst-nesting.zsh" - - "tests/unload-hook-dispatch.zsh" - - "tests/unload-ownership-contracts.zsh" - - "tests/version-reporting.zsh" pull_request: paths: - "zi.zsh" - "lib/**" - "contracts/package-manifest-v1.json" - "scripts/validate-package-manifest.py" + - "scripts/release-plan.zsh" + - "scripts/verify-promotion-release.zsh" + - "scripts/publish-promotion-release.zsh" - "tests/**" - ".github/workflows/*.yml" - "tests/annex-unregister.zsh" @@ -208,6 +167,39 @@ jobs: - name: Test release tag verification run: zsh -f tests/release-tag-verification.zsh + release-plan: + name: Release Plan + runs-on: ubuntu-latest + steps: + - name: Check out code + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Install Zsh + run: sudo apt update && sudo apt-get install -yq zsh + - name: Test release planning + run: zsh -f tests/release-plan.zsh + + promotion-release-verification: + name: Promotion Release Verification + runs-on: ubuntu-latest + steps: + - name: Check out code + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Install Zsh and jq + run: sudo apt update && sudo apt-get install -yq zsh jq + - name: Test promotion release verification + run: zsh -f tests/promotion-release-verification.zsh + + promotion-release-publication: + name: Promotion Release Publication + runs-on: ubuntu-latest + steps: + - name: Check out code + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Install Zsh + run: sudo apt update && sudo apt-get install -yq zsh + - name: Test idempotent promotion publication + run: zsh -f tests/promotion-release-publication.zsh + benchmark-harness: name: Benchmark Harness runs-on: ubuntu-latest diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md index d43fe263..b453cd79 100644 --- a/docs/CONTRIBUTING.md +++ b/docs/CONTRIBUTING.md @@ -49,25 +49,16 @@ Repository rules intentionally omit linear-history requirements on both persiste ## Releases -A promotion to `main` publishes nothing. It updates the Git-consumed stable ref and stops there. +A same-repository `next` to `main` promotion is the normal publication authorization. Reviewers see the deterministic version and release-note plan on the promotion pull request before deciding whether to merge. -**A signed annotated tag is the sole publication authorization.** Nothing else creates a release: not a merge, not a green pipeline, not the automated proposal. - -1. After a promotion reaches `main`, `Release Prepare` opens or updates a proposal issue with the next semantic version computed from Conventional Commits and a draft changelog. It never creates a tag. -2. A maintainer reviews the proposed version, adjusts it if the computed bump does not describe the change, and pushes a signed annotated tag: - - ```text - git switch main && git pull --ff-only - git tag -s vX.Y.Z -F - git push origin vX.Y.Z - ``` - -3. `scripts/verify-release-tag.zsh` rejects the tag unless every one of the following holds: it matches `vX.Y.Z`, it is annotated rather than lightweight, GitHub reports its signature as verified, its target is the current `origin/main`, and the `Zsh`, `ZD Integration`, `CodeQL` and `Trunk Code Quality` workflows all succeeded on that exact commit. -4. Only then is a GitHub release published, idempotently, with generated notes. +1. `Release Plan` computes the next semantic version from Conventional Commits since the latest `vX.Y.Z` tag. A breaking change produces a major bump, `feat` produces a minor bump, and `fix` or `perf` produces a patch bump. A promotion with none of those commits is an explicit no-op. +2. Merging the reviewed promotion authorizes publication of that displayed plan. The merge still updates the Git-consumed stable `main` ref immediately. +3. The automatic publisher proves that the exact merge commit came from the reviewed same-repository `next` pull request and is still current `main`. It waits for `Zsh`, `ZD Integration`, `CodeQL`, and `Trunk Code Quality` to succeed on that exact SHA. +4. The publisher creates an annotated tag and the GitHub release in one idempotent workflow. It fails closed if `main` moves, the promotion identity cannot be proven, validation fails, or the proposed tag already targets another commit. The repository stores no version file. `ZI[VERSION]` is derived at runtime from `git describe --tags --exact-match`, so the tag is the version and there is nothing to keep in step with it. -Closing a proposal issue without tagging skips that release; the next promotion opens a new proposal. +The signed manual-tag flow remains available for recovery or exceptional publication. A maintainer may push a signed annotated `vX.Y.Z` tag to the exact current `main`; `scripts/verify-release-tag.zsh` then requires a valid GitHub signature, the exact target, and the same four successful workflows before it creates the release. No personal signing key is stored in Actions. ## What not to add diff --git a/scripts/publish-promotion-release.zsh b/scripts/publish-promotion-release.zsh new file mode 100644 index 00000000..6aecfc78 --- /dev/null +++ b/scripts/publish-promotion-release.zsh @@ -0,0 +1,56 @@ +#!/usr/bin/env zsh + +emulate -L zsh +setopt err_return no_unset pipe_fail + +fail() { + print -u2 -r -- "promotion release publication: $*" + return 1 +} + +repository=${GITHUB_REPOSITORY:-} +tag=${RELEASE_TAG:-} +target=${RELEASE_TARGET:-} +notes_file=${RELEASE_NOTES_FILE:-} + +[[ $repository == z-shell/zi ]] || fail "unexpected repository: ${repository:-unset}" +[[ $tag =~ '^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$' ]] || + fail "tag must match vX.Y.Z: ${tag:-unset}" +[[ $target =~ '^[0-9a-f]{40}$' ]] || fail "invalid release target: ${target:-unset}" +[[ -r $notes_file ]] || fail "release notes are not readable: ${notes_file:-unset}" + +git fetch --quiet --force --no-tags origin \ + refs/heads/main:refs/remotes/origin/main || + fail 'could not fetch origin/main' +current_main=$(git rev-parse refs/remotes/origin/main) || fail 'could not resolve origin/main' +[[ $current_main == $target ]] || + fail "main moved from $target to $current_main before publication" + +if git ls-remote --exit-code --tags origin "refs/tags/${tag}" >/dev/null 2>&1; then + git fetch --quiet --force origin "refs/tags/${tag}:refs/tags/${tag}" || + fail "could not fetch existing tag: $tag" + existing_target=$(git rev-parse "refs/tags/${tag}^{}") || + fail "could not resolve existing tag: $tag" + [[ $existing_target == $target ]] || + fail "$tag already targets $existing_target, not $target" + [[ $(git cat-file -t "refs/tags/${tag}") == tag ]] || + fail "$tag exists but is not annotated" +else + git config user.name 'github-actions[bot]' + git config user.email '41898282+github-actions[bot]@users.noreply.github.com' + git tag -a "$tag" "$target" -F "$notes_file" || fail "could not create tag: $tag" + git push origin "refs/tags/${tag}" || fail "could not push tag: $tag" +fi + +if gh release view "$tag" --repo "$repository" >/dev/null 2>&1; then + print -r -- "Release $tag already exists at $target." + return 0 +fi + +gh release create "$tag" \ + --repo "$repository" \ + --verify-tag \ + --title "Zi $tag" \ + --notes-file "$notes_file" \ + --latest || fail "could not create release: $tag" +print -r -- "Published $tag at $target." diff --git a/scripts/release-plan.zsh b/scripts/release-plan.zsh new file mode 100644 index 00000000..d3ea3111 --- /dev/null +++ b/scripts/release-plan.zsh @@ -0,0 +1,153 @@ +#!/usr/bin/env zsh + +emulate -L zsh +setopt err_return no_unset pipe_fail extended_glob + +fail() { + print -u2 -r -- "release plan: $*" + return 1 +} + +target=${1:-HEAD} +output=${RELEASE_PLAN_OUTPUT:-} +notes_file=${RELEASE_NOTES_FILE:-} + +target=$(git rev-parse --verify "${target}^{commit}") || + fail "could not resolve target commit: ${1:-HEAD}" + +typeset previous_tag='' +typeset candidate +for candidate in ${(f)"$(git tag --list 'v[0-9]*.[0-9]*.[0-9]*' --sort=-v:refname)"}; do + if [[ $candidate =~ '^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$' ]]; then + previous_tag=$candidate + break + fi +done + +typeset range=$target +if [[ -n $previous_tag ]]; then + tag_target=$(git rev-parse "${previous_tag}^{}") || + fail "could not resolve previous tag: $previous_tag" + base_commit=$tag_target + if ! git merge-base --is-ancestor "$base_commit" "$target"; then + typeset -a tag_parents + tag_parents=( ${(s: :)"$(git rev-list --parents -n 1 "$tag_target")"} ) + (( $#tag_parents == 3 )) || + fail "$previous_tag is not an ancestor or a promotion merge" + base_commit=${tag_parents[3]} + git merge-base --is-ancestor "$base_commit" "$target" || + fail "$previous_tag does not describe this promotion lineage" + fi + range="${base_commit}..${target}" +fi + +typeset -a records breaking features fixes performance other +records=( ${(f)"$(git log --no-merges --format='%H%x09%s' "$range")"} ) + +typeset bump=0 record sha subject body short +for record in "${records[@]}"; do + sha=${record%%$'\t'*} + subject=${record#*$'\t'} + short=${sha[1,7]} + body=$(git show -s --format='%B' "$sha") || fail "could not read commit $sha" + + if print -r -- "$subject" | grep -qE '^[a-z]+(\([^)]*\))?!:' || + print -r -- "$body" | grep -qE '^BREAKING[ -]CHANGE:'; then + (( bump < 3 )) && bump=3 + breaking+=( "- ${subject} (${short})" ) + elif print -r -- "$subject" | grep -qE '^feat(\([^)]*\))?:'; then + (( bump < 2 )) && bump=2 + features+=( "- ${subject} (${short})" ) + elif print -r -- "$subject" | grep -qE '^fix(\([^)]*\))?:'; then + (( bump < 1 )) && bump=1 + fixes+=( "- ${subject} (${short})" ) + elif print -r -- "$subject" | grep -qE '^perf(\([^)]*\))?:'; then + (( bump < 1 )) && bump=1 + performance+=( "- ${subject} (${short})" ) + else + other+=( "- ${subject} (${short})" ) + fi +done + +typeset release=false version='' tag='' +if (( bump > 0 )); then + release=true + if [[ -z $previous_tag ]]; then + version=0.1.0 + else + typeset base=${previous_tag#v} + typeset -a parts + parts=( ${(s:.:)base} ) + (( $#parts == 3 )) || fail "invalid previous semantic tag: $previous_tag" + case $bump in + 3) version="$(( parts[1] + 1 )).0.0" ;; + 2) version="${parts[1]}.$(( parts[2] + 1 )).0" ;; + 1) version="${parts[1]}.${parts[2]}.$(( parts[3] + 1 ))" ;; + esac + fi + tag="v${version}" +fi + +if [[ -n $output ]]; then + { + print -r -- "release=${release}" + print -r -- "previous_tag=${previous_tag}" + print -r -- "version=${version}" + print -r -- "tag=${tag}" + print -r -- "target=${target}" + } >| "$output" || fail "could not write plan output: $output" +fi + +if [[ $release == false ]]; then + print -r -- '## Release plan' + print + print -r -- "No semantic release is proposed. The commits since ${previous_tag:-repository start} contain no feature, fix, performance, or breaking Conventional Commit." + return 0 +fi + +typeset generated_notes +generated_notes=$(mktemp "${TMPDIR:-/tmp}/zi-release-notes.XXXXXXXX") || + fail 'could not create a notes file' +trap 'rm -f -- "$generated_notes"' EXIT HUP INT TERM + +{ + print -r -- "## Changes since ${previous_tag:-repository start}" + if (( $#breaking )); then + print + print -r -- '### Breaking changes' + print -rl -- "${breaking[@]}" + fi + if (( $#features )); then + print + print -r -- '### Features' + print -rl -- "${features[@]}" + fi + if (( $#fixes )); then + print + print -r -- '### Fixes' + print -rl -- "${fixes[@]}" + fi + if (( $#performance )); then + print + print -r -- '### Performance' + print -rl -- "${performance[@]}" + fi + if (( $#other )); then + print + print -r -- '### Other' + print -rl -- "${other[@]}" + fi +} >| "$generated_notes" || fail 'could not render release notes' + +if [[ -n $notes_file ]]; then + command cp -- "$generated_notes" "$notes_file" || + fail "could not write release notes: $notes_file" +fi + +print -r -- '## Release plan' +print +print -r -- "- Proposed tag: \`${tag}\`" +print -r -- "- Previous tag: \`${previous_tag:-none}\`" +print -r -- "- Candidate commit: \`${target}\`" +print +command cat -- "$generated_notes" diff --git a/scripts/verify-promotion-release.zsh b/scripts/verify-promotion-release.zsh new file mode 100644 index 00000000..96de41ed --- /dev/null +++ b/scripts/verify-promotion-release.zsh @@ -0,0 +1,85 @@ +#!/usr/bin/env zsh + +emulate -L zsh +setopt err_return no_unset pipe_fail + +fail() { + print -u2 -r -- "promotion release verification: $*" + return 1 +} + +emit() { + [[ -z ${GITHUB_OUTPUT:-} ]] || print -r -- "$1=$2" >> "$GITHUB_OUTPUT" +} + +repository=${GITHUB_REPOSITORY:-} +target=${PROMOTION_SHA:-} + +emit ready false +[[ $repository == z-shell/zi ]] || fail "unexpected repository: ${repository:-unset}" +[[ $target =~ '^[0-9a-f]{40}$' ]] || fail "invalid promotion SHA: ${target:-unset}" + +git fetch --quiet --force --no-tags origin \ + refs/heads/main:refs/remotes/origin/main || + fail 'could not fetch origin/main' + +main=$(git rev-parse refs/remotes/origin/main) || fail 'could not resolve origin/main' +[[ $target == $main ]] || fail 'promotion SHA is not the current origin/main' + +typeset -a parents +parents=( ${(s: :)"$(git rev-list --parents -n 1 "$target")"} ) +(( $#parents == 3 )) || fail 'promotion commit must have exactly two parents' + +pulls_json=$(gh api -H 'Accept: application/vnd.github+json' \ + "repos/${repository}/commits/${target}/pulls") || + fail 'could not read pull requests for the promotion commit' + +promotion=$(jq -c --arg repository "$repository" --arg target "$target" \ + '[.[] | select( + .merged_at != null and + .merge_commit_sha == $target and + .base.ref == "main" and + .head.ref == "next" and + .head.repo.full_name == $repository + )] | first // empty' <<<"$pulls_json") || fail 'could not inspect promotion pull request' +[[ -n $promotion ]] || fail 'commit is not a merged next-to-main promotion' + +promotion_pr=$(jq -r '.number' <<<"$promotion") +promotion_head=$(jq -r '.head.sha' <<<"$promotion") +[[ ${parents[3]} == $promotion_head ]] || + fail 'promotion second parent does not match the reviewed next head' + +runs_json=$(gh api --method GET "repos/${repository}/actions/runs" \ + -f branch=main -f head_sha="$target" -f per_page=100) || + fail 'could not read workflow runs' + +typeset workflow run run_status run_conclusion +for workflow in Zsh 'ZD Integration' CodeQL 'Trunk Code Quality'; do + run=$(jq -c --arg name "$workflow" --arg target "$target" \ + '[.workflow_runs[] | select( + .name == $name and + .head_branch == "main" and + .head_sha == $target + )] | sort_by(.id) | last // empty' <<<"$runs_json") || + fail "could not inspect required workflow: $workflow" + + if [[ -z $run ]]; then + print -r -- "Waiting for required workflow: $workflow" + emit promotion_pr "$promotion_pr" + return 0 + fi + + run_status=$(jq -r '.status' <<<"$run") + run_conclusion=$(jq -r '.conclusion // ""' <<<"$run") + if [[ $run_status != completed ]]; then + print -r -- "Waiting for required workflow: $workflow ($run_status)" + emit promotion_pr "$promotion_pr" + return 0 + fi + [[ $run_conclusion == success ]] || + fail "required workflow did not succeed: $workflow ($run_conclusion)" +done + +emit promotion_pr "$promotion_pr" +emit ready true +print -r -- "Promotion #${promotion_pr} and required workflows verified at ${target}." diff --git a/tests/ci-registration.zsh b/tests/ci-registration.zsh index 5d35f259..bbc02175 100644 --- a/tests/ci-registration.zsh +++ b/tests/ci-registration.zsh @@ -37,6 +37,9 @@ promotion_set=( archive-extraction.zsh completion-refresh.zsh snippet-directory-mirror.zsh + release-plan.zsh + promotion-release-verification.zsh + promotion-release-publication.zsh ) # A test is invoked only by an executable `run:` step. The scanner reads the diff --git a/tests/promotion-release-publication.zsh b/tests/promotion-release-publication.zsh new file mode 100644 index 00000000..ab2264a4 --- /dev/null +++ b/tests/promotion-release-publication.zsh @@ -0,0 +1,85 @@ +#!/usr/bin/env zsh + +emulate -L zsh +setopt err_exit no_unset pipe_fail + +root=${0:A:h:h} +tmp=$(mktemp -d "${TMPDIR:-/tmp}/zi-promotion-publish-test.XXXXXX") +trap 'rm -rf -- "$tmp"' EXIT HUP INT TERM + +typeset -a content_writers +content_writers=( ${(f)"$(grep -l '^[[:space:]]*contents:[[:space:]]*write' "$root"/.github/workflows/*.yml || true)"} ) +(( $#content_writers == 1 )) +[[ ${content_writers[1]:t} == release.yml ]] +grep -q 'workflow_run:' "$root/.github/workflows/release.yml" +! grep -q 'pull_request_target:' "$root/.github/workflows/release.yml" +grep -q 'ref: refs/heads/main' "$root/.github/workflows/release.yml" +! grep -q 'ref:.*github.event.workflow_run.head_sha' "$root/.github/workflows/release.yml" + +git init --bare "$tmp/origin.git" >/dev/null +git clone "$tmp/origin.git" "$tmp/repository" >/dev/null 2>&1 +git -C "$tmp/repository" config user.email release-test@example.invalid +git -C "$tmp/repository" config user.name 'Release Test' +print base > "$tmp/repository/file" +git -C "$tmp/repository" add file +git -C "$tmp/repository" commit -m 'chore: base' >/dev/null +print release >> "$tmp/repository/file" +git -C "$tmp/repository" commit -am 'fix: candidate' >/dev/null +git -C "$tmp/repository" branch -M main +git -C "$tmp/repository" push -u origin main >/dev/null 2>&1 +target=$(git -C "$tmp/repository" rev-parse HEAD) +print notes > "$tmp/notes.md" + +mkdir "$tmp/bin" +cat > "$tmp/bin/gh" <<'FAKE_GH' +#!/usr/bin/env zsh +state=${FAKE_RELEASE_STATE:?} +if [[ $1 == release && $2 == view ]]; then + [[ -e $state ]] + exit $? +fi +if [[ $1 == release && $2 == create ]]; then + [[ ! -e $state ]] || exit 1 + print -r -- "$3" > "$state" + exit 0 +fi +exit 2 +FAKE_GH +chmod +x "$tmp/bin/gh" + +publish() { + ( + cd "$tmp/repository" + PATH="$tmp/bin:$PATH" \ + GITHUB_REPOSITORY=z-shell/zi \ + RELEASE_TAG=v1.0.0 \ + RELEASE_TARGET=$target \ + RELEASE_NOTES_FILE=$tmp/notes.md \ + FAKE_RELEASE_STATE=$tmp/release-state \ + zsh -f "$root/scripts/publish-promotion-release.zsh" + ) +} + +publish >/dev/null +[[ $(git --git-dir="$tmp/origin.git" cat-file -t refs/tags/v1.0.0) == tag ]] +[[ $(git --git-dir="$tmp/origin.git" rev-parse 'refs/tags/v1.0.0^{}') == $target ]] +[[ $(<"$tmp/release-state") == v1.0.0 ]] + +first_tag=$(git --git-dir="$tmp/origin.git" rev-parse refs/tags/v1.0.0) +publish >/dev/null +[[ $(git --git-dir="$tmp/origin.git" rev-parse refs/tags/v1.0.0) == $first_tag ]] + +git -C "$tmp/repository" tag -a v1.0.1 -m v1.0.1 "${target}^" +git -C "$tmp/repository" push origin refs/tags/v1.0.1 >/dev/null 2>&1 +( + cd "$tmp/repository" + PATH="$tmp/bin:$PATH" \ + GITHUB_REPOSITORY=z-shell/zi \ + RELEASE_TAG=v1.0.1 \ + RELEASE_TARGET=$target \ + RELEASE_NOTES_FILE=$tmp/notes.md \ + FAKE_RELEASE_STATE=$tmp/other-release-state \ + zsh -f "$root/scripts/publish-promotion-release.zsh" +) >/dev/null 2>&1 && { print -u2 -- 'expected conflicting tag target to fail'; exit 1; } + +print 'promotion release publication tests passed' diff --git a/tests/promotion-release-verification.zsh b/tests/promotion-release-verification.zsh new file mode 100644 index 00000000..74e3dff3 --- /dev/null +++ b/tests/promotion-release-verification.zsh @@ -0,0 +1,110 @@ +#!/usr/bin/env zsh + +emulate -L zsh +setopt err_exit no_unset pipe_fail + +root=${0:A:h:h} +tmp=$(mktemp -d "${TMPDIR:-/tmp}/zi-promotion-release-test.XXXXXX") +trap 'rm -rf -- "$tmp"' EXIT HUP INT TERM + +git init --bare "$tmp/origin.git" >/dev/null +git clone "$tmp/origin.git" "$tmp/repository" >/dev/null 2>&1 +git -C "$tmp/repository" config user.email release-test@example.invalid +git -C "$tmp/repository" config user.name 'Release Test' +print base > "$tmp/repository/file" +git -C "$tmp/repository" add file +git -C "$tmp/repository" commit -m 'chore: base' >/dev/null +git -C "$tmp/repository" branch -M main +git -C "$tmp/repository" push -u origin main >/dev/null 2>&1 +base=$(git -C "$tmp/repository" rev-parse HEAD) + +git -C "$tmp/repository" switch -c next >/dev/null 2>&1 +print feature >> "$tmp/repository/file" +git -C "$tmp/repository" commit -am 'fix: release candidate' >/dev/null +head=$(git -C "$tmp/repository" rev-parse HEAD) +git -C "$tmp/repository" push -u origin next >/dev/null 2>&1 +git -C "$tmp/repository" switch main >/dev/null 2>&1 +git -C "$tmp/repository" merge --no-ff next -m 'chore: promote next to main' >/dev/null +target=$(git -C "$tmp/repository" rev-parse HEAD) +git -C "$tmp/repository" push origin main >/dev/null 2>&1 + +mkdir "$tmp/bin" +cat > "$tmp/bin/gh" <<'FAKE_GH' +#!/usr/bin/env zsh +if [[ $* == *'/commits/'*'/pulls'* ]]; then + if [[ ${FAKE_PR_MODE:-valid} == missing ]]; then + print -r -- '[]' + exit 0 + fi + source=${FAKE_PR_SOURCE:-next} + print -r -- "[{\"number\":600,\"merged_at\":\"2026-09-20T00:00:00Z\",\"merge_commit_sha\":\"${FAKE_TARGET}\",\"base\":{\"ref\":\"main\"},\"head\":{\"ref\":\"${source}\",\"sha\":\"${FAKE_HEAD}\",\"repo\":{\"full_name\":\"z-shell/zi\"}}}]" + exit 0 +fi + +mode=${FAKE_WORKFLOW_MODE:-success} +names=( Zsh 'ZD Integration' CodeQL 'Trunk Code Quality' ) +print -n -r -- '{"workflow_runs":[' +separator='' +id=10 +for name in "${names[@]}"; do + [[ $mode == missing && $name == 'ZD Integration' ]] && continue + run_status=completed + run_conclusion=success + [[ $mode == pending && $name == CodeQL ]] && { run_status=in_progress; run_conclusion=''; } + [[ $mode == failure && $name == 'Trunk Code Quality' ]] && run_conclusion=failure + print -n -r -- "${separator}{\"id\":${id},\"name\":\"${name}\",\"head_branch\":\"main\",\"head_sha\":\"${FAKE_TARGET}\",\"status\":\"${run_status}\",\"conclusion\":\"${run_conclusion}\"}" + separator=, + (( id += 1 )) +done +print -r -- ']}' +FAKE_GH +chmod +x "$tmp/bin/gh" + +run_verifier() { + local mode=${1:-success} source=${2:-next} pr_mode=${3:-valid} + local output=$tmp/output + : > "$output" + ( + cd "$tmp/repository" + PATH="$tmp/bin:$PATH" \ + GITHUB_OUTPUT=$output \ + GITHUB_REPOSITORY=z-shell/zi \ + PROMOTION_SHA=$target \ + FAKE_TARGET=$target \ + FAKE_HEAD=$head \ + FAKE_WORKFLOW_MODE=$mode \ + FAKE_PR_SOURCE=$source \ + FAKE_PR_MODE=$pr_mode \ + zsh -f "$root/scripts/verify-promotion-release.zsh" + ) +} + +expect_fail() { + if run_verifier "$@" >/dev/null 2>&1; then + print -u2 -- "expected promotion verification to fail: $*" + return 1 + fi +} + +expect_fail success feature valid +expect_fail success next missing +expect_fail failure next valid +run_verifier missing >/dev/null +grep -q '^ready=false$' "$tmp/output" +run_verifier pending >/dev/null +grep -q '^ready=false$' "$tmp/output" +run_verifier success >/dev/null +grep -q '^ready=true$' "$tmp/output" +grep -q '^promotion_pr=600$' "$tmp/output" + +print moved >> "$tmp/repository/file" +git -C "$tmp/repository" commit -am 'chore: move main' >/dev/null +git -C "$tmp/repository" push origin main >/dev/null 2>&1 +expect_fail success next valid + +GITHUB_REPOSITORY=other/repo PROMOTION_SHA=$target \ + PATH="$tmp/bin:$PATH" zsh -f "$root/scripts/verify-promotion-release.zsh" \ + >/dev/null 2>&1 && { print -u2 -- 'expected repository mismatch to fail'; exit 1; } + +[[ $(git -C "$tmp/repository" rev-parse "${target}^1") == $base ]] +print 'promotion release verification tests passed' diff --git a/tests/release-plan.zsh b/tests/release-plan.zsh new file mode 100644 index 00000000..33118cae --- /dev/null +++ b/tests/release-plan.zsh @@ -0,0 +1,71 @@ +#!/usr/bin/env zsh + +emulate -L zsh +setopt err_exit no_unset pipe_fail + +root=${0:A:h:h} +tmp=$(mktemp -d "${TMPDIR:-/tmp}/zi-release-plan-test.XXXXXX") +trap 'rm -rf -- "$tmp"' EXIT HUP INT TERM + +git init "$tmp/repository" >/dev/null +git -C "$tmp/repository" config user.email release-test@example.invalid +git -C "$tmp/repository" config user.name 'Release Test' +print initial > "$tmp/repository/file" +git -C "$tmp/repository" add file +git -C "$tmp/repository" commit -m 'chore: initial commit' >/dev/null +git -C "$tmp/repository" tag -a v1.2.3 -m v1.2.3 + +run_plan() { + local output=$tmp/output notes=$tmp/notes body=$tmp/body + ( + cd "$tmp/repository" + RELEASE_PLAN_OUTPUT=$output RELEASE_NOTES_FILE=$notes \ + zsh -f "$root/scripts/release-plan.zsh" HEAD > "$body" + ) +} + +value() { + sed -n "s/^$1=//p" "$tmp/output" +} + +commit() { + print -r -- "$1" >> "$tmp/repository/file" + git -C "$tmp/repository" commit -am "$2" >/dev/null +} + +commit docs 'docs: explain releases' +run_plan +[[ $(value release) == false && -z $(value tag) ]] + +commit fix 'fix(load): preserve explicit disk ices' +run_plan +[[ $(value release) == true && $(value tag) == v1.2.4 ]] +grep -q '^### Fixes$' "$tmp/notes" +grep -q 'fix(load): preserve explicit disk ices' "$tmp/notes" + +commit feature 'feat: add a compatible command' +run_plan +[[ $(value tag) == v1.3.0 ]] +grep -q '^### Features$' "$tmp/notes" + +commit breaking $'feat!: replace command output\n\nBREAKING CHANGE: callers must migrate' +run_plan +[[ $(value tag) == v2.0.0 ]] +grep -q '^### Breaking changes$' "$tmp/notes" +grep -q 'Candidate commit' "$tmp/body" + +git -C "$tmp/repository" branch previous-next HEAD +git -C "$tmp/repository" switch -c stable v1.2.3 >/dev/null 2>&1 +git -C "$tmp/repository" merge --no-ff previous-next -m 'chore: promotion merge' >/dev/null +git -C "$tmp/repository" tag -a v2.0.0 -m v2.0.0 +git -C "$tmp/repository" switch -C next previous-next >/dev/null 2>&1 +commit post-release-fix 'fix: change after a promotion tag' +run_plan +[[ $(value previous_tag) == v2.0.0 && $(value tag) == v2.0.1 ]] +grep -q 'fix: change after a promotion tag' "$tmp/notes" +if grep -q 'docs: explain releases' "$tmp/notes"; then + print -u2 -- 'release notes crossed the previous promotion boundary' + exit 1 +fi + +print 'release plan tests passed'