diff --git a/.claude/memory/MEMORY.md b/.claude/memory/MEMORY.md index 406bc34..97f97a1 100644 --- a/.claude/memory/MEMORY.md +++ b/.claude/memory/MEMORY.md @@ -22,3 +22,6 @@ crates-io environment waits for the owner's approval - [All consoles in lockstep](ferroui.md): every release rolls out to all four consoles at once, one uniform design, screenshots read before a tag +- [Release closes milestone](release-closes-milestone.md): release.yml refuses + a tag whose milestone is missing or not empty and closes it after every + leg; check it after a manual or recovery release; owner 2026-10-19 (#124) diff --git a/.claude/memory/release-closes-milestone.md b/.claude/memory/release-closes-milestone.md new file mode 100644 index 0000000..6453908 --- /dev/null +++ b/.claude/memory/release-closes-milestone.md @@ -0,0 +1,15 @@ +--- +name: release-closes-milestone +description: The release workflow refuses a tag whose milestone is missing or not empty, and closes the milestone after every leg succeeds; after a manual or recovery release, check it is closed +metadata: + type: feedback +--- + + + + +A published release closes its milestone: `release.yml` refuses a `vX.Y.Z` tag in `plan` when the milestone titled exactly as the tag is missing or holds an open item, and its last job closes that milestone once every leg, crates.io included, has succeeded (`scripts/release/milestone.sh`). + +**Why:** FerroSMART v0.1.0 was tagged and published on 2026-10-19 while its milestone stayed open, and the owner asked that this be impossible in every product (#124). + +**How to apply:** the workflow enforces it. After any manual or recovery release (a re-run, a `publish-crates.yml` dispatch, a leg finished by hand), check that the milestone is closed, and run `GITHUB_REPOSITORY=FerroHEALTH/FerroUI bash scripts/release/milestone.sh close vX.Y.Z` when it is not. diff --git a/.claude/memory/release-tag.md b/.claude/memory/release-tag.md index 9f585f2..9677b22 100644 --- a/.claude/memory/release-tag.md +++ b/.claude/memory/release-tag.md @@ -12,7 +12,10 @@ The release pull request bumps the workspace version, the changelog section and the lock. After it merges, the tag `vX.Y.Z` is created signed (`git tag -s`) on that merge commit and pushed; `release.yml` builds the draft, attests the `.crate`, publishes the release, and its `crates` leg -pauses on the `crates-io` environment until the owner approves. +pauses on the `crates-io` environment until the owner approves. `plan` +refuses the tag while the milestone titled as it is missing or holds an open +item, and the last job closes the milestone once every leg succeeded +([[release-closes-milestone]]). **How to apply:** never move or delete a pushed tag (the `release-tags` ruleset refuses it); a bad cut is fixed by a new patch version. Tell the owner diff --git a/.claude/rules/ci-cd.md b/.claude/rules/ci-cd.md index 644ce76..dbf8d01 100644 --- a/.claude/rules/ci-cd.md +++ b/.claude/rules/ci-cd.md @@ -40,6 +40,11 @@ Actions Security Cheat Sheet, SLSA v1.0, OpenSSF Scorecard, and Sigstore. publishes the release, and then its `crates` leg uploads the crate through Trusted Publishing behind the `crates-io` environment (`crates-publishing.md`). + Its `plan` job refuses a tag when no milestone is titled exactly as the tag + or that milestone still holds an open issue or pull request, naming each + one, and its last job, `milestone`, closes that milestone once every leg, + crates included, has succeeded; a failed leg leaves it open and the job log + names the leg. Both call `scripts/release/milestone.sh`. - `.github/workflows/publish-crates.yml`: the manual crates.io lane, a dry run unless `publish` is set, sharing `scripts/release/publish-crates.sh` with the release lane. diff --git a/.claude/rules/issue-workflow.md b/.claude/rules/issue-workflow.md index 88a6068..187f05d 100644 --- a/.claude/rules/issue-workflow.md +++ b/.claude/rules/issue-workflow.md @@ -146,6 +146,14 @@ milestone, never the next. A new `vX.Y.Z` milestone gets a due date (the board's Roadmap view places items by it, via `scripts/gh/project.sh sync-dates`). +**The release workflow enforces both ends.** Its `plan` job refuses a tag +when no milestone is titled exactly as the tag or that milestone still holds +an open issue or pull request, and names each one. Its last job closes the +milestone once every leg has succeeded, crates.io included, so a published +release never leaves its milestone open. Both call +`scripts/release/milestone.sh` (`.claude/rules/ci-cd.md`). After a manual or +recovery release, check that the milestone is closed. + ## The fix-first cadence **An issue filed while working a unit is FIXED before the next unit starts.** diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 24d87d8..04d3385 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -90,6 +90,8 @@ jobs: tool: shellcheck@0.11.0 - name: Every tracked shell program is clean at severity=style run: git ls-files -z '*.sh' | xargs -0 -r shellcheck --severity=style + - name: The release milestone script refuses open items and closes only after every leg + run: bash scripts/release/milestone.sh --self-test comment-style: name: comment-style diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 62b4210..489a9f0 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -3,12 +3,14 @@ # The release lane, dormant until a `v*` tag is pushed: # -# plan ── github-release (draft) ── crate (reusable) ── finalize-release ── crates +# plan ── github-release (draft) ── crate (reusable) ── finalize-release ── crates ── milestone # # The release is created as a draft, the attested .crate is attached, and it # is published only once the asset set is complete; then the crates leg # uploads the crate through Trusted Publishing behind the `crates-io` -# environment's required reviewer. +# environment's required reviewer. plan refuses a tag whose milestone (titled +# exactly as the tag) is missing or still holds an open item, and milestone +# closes it once every leg succeeded (scripts/release/milestone.sh). name: Release on: @@ -34,6 +36,7 @@ jobs: timeout-minutes: 10 permissions: contents: read + issues: read # the milestone titled as the tag outputs: tag: ${{ steps.tag.outputs.tag }} version: ${{ steps.tag.outputs.version }} @@ -73,6 +76,15 @@ jobs: persist-credentials: false ref: ${{ github.sha }} + # A release is cut only when its milestone is empty (no specification + # governs this: our own design), so this refuses the tag before anything + # is created and names every open item. + - name: The milestone titled as the tag holds no open item + env: + GH_TOKEN: ${{ github.token }} + TAG: ${{ steps.tag.outputs.tag }} + run: bash scripts/release/milestone.sh check "$TAG" + - name: The tag matches the workspace version env: TAG: ${{ steps.tag.outputs.tag }} @@ -234,3 +246,28 @@ jobs: - name: Verify the crate resolves at its manifest version if: always() run: bash scripts/release/publish-crates.sh verify + + # Closes the release's milestone after every leg, the approval-gated crates + # leg included. `always()` keeps the job in the graph when a leg fails, so + # its log names that leg and the milestone stays open; a re-run of the + # failed jobs closes it. An already-closed milestone counts as done. + milestone: + name: close the milestone + needs: [plan, github-release, crate, finalize-release, crates] + if: ${{ always() && needs.plan.result == 'success' }} + runs-on: ubuntu-24.04 + timeout-minutes: 5 + permissions: + contents: read # check out the script + issues: write # close the milestone + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + ref: ${{ github.sha }} + - name: Close the milestone titled as the tag + env: + GH_TOKEN: ${{ github.token }} + TAG: ${{ needs.plan.outputs.tag }} + NEEDS_JSON: ${{ toJSON(needs) }} + run: bash scripts/release/milestone.sh close "$TAG" diff --git a/scripts/release/milestone.sh b/scripts/release/milestone.sh new file mode 100755 index 0000000..ef14f07 --- /dev/null +++ b/scripts/release/milestone.sh @@ -0,0 +1,194 @@ +#!/usr/bin/env bash +# SPDX-FileCopyrightText: Cadasto B.V. +# SPDX-License-Identifier: BUSL-1.1 +# +# The release's milestone (no specification governs this: our own design). A +# release is cut only when the milestone titled exactly as its tag holds no +# open issue, and the release workflow closes that milestone once every +# publishing leg has succeeded, so a published release never leaves its +# milestone open. +# +# milestone.sh check +# Exit 0 when the milestone exists and holds no open issue or pull +# request. Exit 1 naming the missing milestone or every open item. +# milestone.sh close +# Checks again, then closes the milestone. An already-closed milestone +# counts as done. When NEEDS_JSON holds the workflow's `needs` context, +# every leg in it must have succeeded first; otherwise the milestone +# stays open and each leg that did not succeed is named. +# milestone.sh --self-test +# Proves both modes against a stub gh. +# +# A pre-release tag (vX.Y.Z-rc.1) finishes no milestone, so both modes accept +# it and change nothing. The repository is GITHUB_REPOSITORY, or the one gh +# resolves from the checkout; gh authenticates with GH_TOKEN. Exit 2 on a +# usage error. Requires gh and jq. +set -euo pipefail + +usage() { + echo "usage: milestone.sh check | close | --self-test" >&2 + exit 2 +} + +die() { + echo "::error::milestone: $*" >&2 + exit 1 +} + +repo() { + if [[ -n "${GITHUB_REPOSITORY:-}" ]]; then + printf '%s' "$GITHUB_REPOSITORY" + else + gh repo view --json nameWithOwner --jq .nameWithOwner + fi +} + +valid_tag() { + if [[ ! "$1" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$ ]]; then + echo "::error::milestone: '$1' is not a vX.Y.Z tag" >&2 + exit 2 + fi +} + +# find_milestone REPO TAG: prints " " of the milestone titled +# exactly TAG, or nothing when there is none. +find_milestone() { + gh api --paginate "repos/$1/milestones?state=all&per_page=100" | + jq -rs --arg t "$2" 'add // [] | map(select(.title == $t)) | first // empty | "\(.number) \(.state)"' +} + +# open_items REPO NUMBER: one line per open issue or pull request in the +# milestone, both of which the milestone's open count includes. +open_items() { + gh api --paginate "repos/$1/issues?milestone=$2&state=open&per_page=100" | + jq -rs 'add // [] | .[] | "#\(.number) \(if .pull_request then "(pull request) " else "" end)\(.title)"' +} + +# refuse_open TAG ITEMS: fails naming every open item, when there is one. +refuse_open() { + local item + [[ -n "$2" ]] || return 0 + while IFS= read -r item; do + echo "::error::milestone: $1 still holds $item" >&2 + done <<<"$2" + die "milestone $1 has $(wc -l <<<"$2" | tr -d ' ') open item(s). Close or move them to the next milestone before the release is cut" +} + +check() { + local tag="$1" repository found number state + valid_tag "$tag" + if [[ "$tag" == *-* ]]; then + echo "milestone: $tag is a pre-release, which finishes no milestone." + return 0 + fi + repository="$(repo)" + found="$(find_milestone "$repository" "$tag")" + [[ -n "$found" ]] || die "no milestone of $repository is titled '$tag'. Create it before the release is cut" + read -r number state <<<"$found" + refuse_open "$tag" "$(open_items "$repository" "$number")" + echo "milestone: $tag (#$number, $state) holds no open item." +} + +close() { + local tag="$1" repository failed leg found number state + valid_tag "$tag" + if [[ "$tag" == *-* ]]; then + echo "milestone: $tag is a pre-release, which finishes no milestone." + return 0 + fi + if [[ -n "${NEEDS_JSON:-}" ]]; then + failed="$(jq -r 'to_entries[] | select(.value.result != "success") | "\(.key) (\(.value.result))"' <<<"$NEEDS_JSON")" + if [[ -n "$failed" ]]; then + while IFS= read -r leg; do + echo "::error::milestone: the release leg $leg did not succeed" >&2 + done <<<"$failed" + die "the release of $tag is incomplete, so milestone $tag stays open. Re-run the failed jobs and this job closes it" + fi + fi + repository="$(repo)" + found="$(find_milestone "$repository" "$tag")" + [[ -n "$found" ]] || die "no milestone of $repository is titled '$tag', so there is nothing to close" + read -r number state <<<"$found" + if [[ "$state" == closed ]]; then + echo "milestone: $tag (#$number) is already closed." + return 0 + fi + refuse_open "$tag" "$(open_items "$repository" "$number")" + gh api -X PATCH "repos/$repository/milestones/$number" -f state=closed >/dev/null + echo "milestone: closed $tag (#$number)." +} + +self_test() { + local scratch fails=0 out + scratch="$(mktemp -d)" + trap 'rm -rf "$scratch"' RETURN + export GITHUB_REPOSITORY=o/r + STUB_MILESTONES='[{"number":4,"title":"v1.1.0","state":"open"}][{"number":3,"title":"v1.0.0","state":"open"}]' + STUB_ISSUES='[]' + # The stub answers the three calls the script makes and records each close. + # shellcheck disable=SC2329 # the functions under test call it in place of gh + gh() { + case "$*" in + "api --paginate repos/o/r/milestones?state=all&per_page=100") printf '%s' "$STUB_MILESTONES" ;; + "api --paginate repos/o/r/issues?milestone=3&state=open&per_page=100") printf '%s' "$STUB_ISSUES" ;; + "api -X PATCH repos/o/r/milestones/3 -f state=closed") echo closed >>"$scratch/patched" ;; + *) + echo "stub gh: unexpected call: $*" >&2 + return 1 + ;; + esac + } + # expect NAME WANT_RC WANT_TEXT WANT_PATCHED -- COMMAND... + expect() { + local name="$1" want_rc="$2" want_text="$3" want_patched="$4" rc patched + shift 5 + rm -f "$scratch/patched" + if out="$("$@" 2>&1)"; then rc=0; else rc=$?; fi + patched=no + [[ -f "$scratch/patched" ]] && patched=yes + if [[ "$rc" != "$want_rc" || "$out" != *"$want_text"* || "$patched" != "$want_patched" ]]; then + echo "FAIL: $name (exit $rc, closed $patched): $out" >&2 + fails=$((fails + 1)) + else + echo "ok: $name" + fi + } + expect "check passes an empty milestone" 0 "holds no open item" no -- check v1.0.0 + expect "check passes a pre-release" 0 "pre-release" no -- check v1.0.0-rc.1 + expect "check refuses a malformed tag" 2 "is not a vX.Y.Z tag" no -- check 1.0.0 + expect "check refuses a missing milestone" 1 "no milestone of o/r is titled 'v9.9.9'" no -- check v9.9.9 + expect "close closes an empty milestone" 0 "closed v1.0.0 (#3)" yes -- close v1.0.0 + NEEDS_JSON='{"plan":{"result":"success"},"crates":{"result":"failure"}}' \ + expect "close keeps the milestone open after a failed leg" 1 "the release leg crates (failure) did not succeed" no -- close v1.0.0 + NEEDS_JSON='{"plan":{"result":"success"},"crates":{"result":"success"}}' \ + expect "close closes after every leg succeeded" 0 "closed v1.0.0" yes -- close v1.0.0 + expect "close refuses a missing milestone" 1 "nothing to close" no -- close v9.9.9 + NEEDS_JSON='{"crates":{"result":"skipped"}}' \ + expect "close leaves a pre-release alone" 0 "pre-release" no -- close v1.0.0-rc.1 + STUB_ISSUES='[{"number":7,"title":"A straggler"}][{"number":8,"title":"A fix","pull_request":{}}]' + expect "check names each open issue" 1 "v1.0.0 still holds #7 A straggler" no -- check v1.0.0 + expect "check names each open pull request" 1 "#8 (pull request) A fix" no -- check v1.0.0 + expect "close refuses a milestone with open items" 1 "has 2 open item(s)" no -- close v1.0.0 + STUB_MILESTONES='[{"number":3,"title":"v1.0.0","state":"closed"}]' + STUB_ISSUES='[]' + expect "close counts a closed milestone as done" 0 "already closed" no -- close v1.0.0 + expect "check passes a closed, empty milestone" 0 "holds no open item" no -- check v1.0.0 + unset -f gh + if [[ "$fails" -ne 0 ]]; then + echo "milestone: self-test FAILED ($fails)" >&2 + return 1 + fi + echo "milestone: self-test OK." +} + +case "${1:-}" in + check | close) + [[ $# -eq 2 ]] || usage + "$1" "$2" + ;; + --self-test) + [[ $# -eq 1 ]] || usage + self_test + ;; + *) usage ;; +esac