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
3 changes: 3 additions & 0 deletions .claude/memory/MEMORY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
15 changes: 15 additions & 0 deletions .claude/memory/release-closes-milestone.md
Original file line number Diff line number Diff line change
@@ -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
---

<!-- SPDX-FileCopyrightText: Cadasto B.V. -->
<!-- SPDX-License-Identifier: BUSL-1.1 -->

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.
5 changes: 4 additions & 1 deletion .claude/memory/release-tag.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions .claude/rules/ci-cd.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
8 changes: 8 additions & 0 deletions .claude/rules/issue-workflow.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.**
Expand Down
2 changes: 2 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
41 changes: 39 additions & 2 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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 }}
Expand Down Expand Up @@ -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 }}
Expand Down Expand Up @@ -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"
194 changes: 194 additions & 0 deletions scripts/release/milestone.sh
Original file line number Diff line number Diff line change
@@ -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 <tag>
# Exit 0 when the milestone <tag> exists and holds no open issue or pull
# request. Exit 1 naming the missing milestone or every open item.
# milestone.sh close <tag>
# 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 <tag> | close <tag> | --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

Check warning on line 47 in scripts/release/milestone.sh

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Assign this positional parameter to a local variable.

See more on https://sonarcloud.io/project/issues?id=FerroHEALTH_FerroUI&issues=AaFWO8d2cXHYd9SaG-e1&open=AaFWO8d2cXHYd9SaG-e1&pullRequest=129
echo "::error::milestone: '$1' is not a vX.Y.Z tag" >&2
exit 2
fi
}

# find_milestone REPO TAG: prints "<number> <state>" 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" |

Check warning on line 56 in scripts/release/milestone.sh

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Assign this positional parameter to a local variable.

See more on https://sonarcloud.io/project/issues?id=FerroHEALTH_FerroUI&issues=AaFWO8d2cXHYd9SaG-e2&open=AaFWO8d2cXHYd9SaG-e2&pullRequest=129
jq -rs --arg t "$2" 'add // [] | map(select(.title == $t)) | first // empty | "\(.number) \(.state)"'

Check warning on line 57 in scripts/release/milestone.sh

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Assign this positional parameter to a local variable.

See more on https://sonarcloud.io/project/issues?id=FerroHEALTH_FerroUI&issues=AaFWO8d2cXHYd9SaG-e3&open=AaFWO8d2cXHYd9SaG-e3&pullRequest=129
}

# 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" |

Check warning on line 63 in scripts/release/milestone.sh

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Assign this positional parameter to a local variable.

See more on https://sonarcloud.io/project/issues?id=FerroHEALTH_FerroUI&issues=AaFWO8d2cXHYd9SaG-e5&open=AaFWO8d2cXHYd9SaG-e5&pullRequest=129

Check warning on line 63 in scripts/release/milestone.sh

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Assign this positional parameter to a local variable.

See more on https://sonarcloud.io/project/issues?id=FerroHEALTH_FerroUI&issues=AaFWO8d2cXHYd9SaG-e4&open=AaFWO8d2cXHYd9SaG-e4&pullRequest=129
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

Check warning on line 70 in scripts/release/milestone.sh

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Assign this positional parameter to a local variable.

See more on https://sonarcloud.io/project/issues?id=FerroHEALTH_FerroUI&issues=AaFWO8d2cXHYd9SaG-e6&open=AaFWO8d2cXHYd9SaG-e6&pullRequest=129
while IFS= read -r item; do
echo "::error::milestone: $1 still holds $item" >&2
done <<<"$2"

Check warning on line 73 in scripts/release/milestone.sh

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Assign this positional parameter to a local variable.

See more on https://sonarcloud.io/project/issues?id=FerroHEALTH_FerroUI&issues=AaFWO8d2cXHYd9SaG-e7&open=AaFWO8d2cXHYd9SaG-e7&pullRequest=129
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 warning on line 74 in scripts/release/milestone.sh

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Assign this positional parameter to a local variable.

See more on https://sonarcloud.io/project/issues?id=FerroHEALTH_FerroUI&issues=AaFWO8d2cXHYd9SaG-e8&open=AaFWO8d2cXHYd9SaG-e8&pullRequest=129

Check warning on line 74 in scripts/release/milestone.sh

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Assign this positional parameter to a local variable.

See more on https://sonarcloud.io/project/issues?id=FerroHEALTH_FerroUI&issues=AaFWO8d2cXHYd9SaG-e9&open=AaFWO8d2cXHYd9SaG-e9&pullRequest=129
}

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
Loading