From 8319e1bafaf0b5b701ad8793a041894f683f61f6 Mon Sep 17 00:00:00 2001 From: Gonzalo Casas Date: Wed, 19 Aug 2026 09:51:15 +0200 Subject: [PATCH 1/3] feat: release by workflow dispatch Releasing meant tagging by hand and remembering to force-move the major tag afterwards. This does both from the Actions tab, given a patch, minor or major choice, and publishes the release with generated notes. The version comes from the existing tags. When no point release has been tagged yet -- which is the case now, since only the movable `v1` has ever existed -- it seeds from that major tag rather than starting at zero. Otherwise picking `minor` today would produce v0.1.0 and a `v0` tag, quietly leaving every `@v1` caller pinned to a tag that had stopped moving. Follows the release-pr action for pushing: checkout keeps no credentials, and `gh auth setup-git` grants them only for the tag push. Co-Authored-By: Claude Opus 5 --- .github/workflows/release.yml | 120 ++++++++++++++++++++++++++++++++++ README.md | 11 ++++ 2 files changed, 131 insertions(+) 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..bab2701 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,120 @@ +name: release + +on: + workflow_dispatch: + inputs: + release-type: + description: Part of the version to bump + type: choice + options: + - patch + - minor + - major + default: minor + +permissions: + contents: read + +concurrency: + group: release + cancel-in-progress: false + +jobs: + release: + name: Tag and publish + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/checkout@v7.0.1 + with: + fetch-depth: 0 + persist-credentials: false + + - name: Check the release branch + env: + DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} + run: | + if [[ "$GITHUB_REF_NAME" != "$DEFAULT_BRANCH" ]]; then + echo "::error::Release from $DEFAULT_BRANCH, not $GITHUB_REF_NAME" + exit 1 + fi + + - name: Work out the next version + id: version + env: + RELEASE_TYPE: ${{ inputs.release-type }} + run: | + latest=$(git tag --list 'v[0-9]*.[0-9]*.[0-9]*' | sort -V | tail -n 1) + + if [[ -n "$latest" ]]; then + IFS=. read -r major minor patch <<< "${latest#v}" + echo "Latest release is $latest" + else + # No point release has ever been tagged, only the movable major tag. Seed from + # that, so the series consumers already pin to carries on rather than restarting + # at v0 and stranding every `@v1` caller on a tag that stops moving. + series=$(git tag --list 'v[0-9]*' | grep -E '^v[0-9]+$' | sort -V | tail -n 1) + major=${series#v} + major=${major:-0} + minor=0 + patch=0 + echo "No point releases yet, continuing the ${series:-v0} series" + fi + + case "$RELEASE_TYPE" in + major) + major=$((major + 1)) + minor=0 + patch=0 + ;; + minor) + minor=$((minor + 1)) + patch=0 + ;; + patch) + patch=$((patch + 1)) + ;; + *) + echo "::error::Unknown release type: $RELEASE_TYPE" + exit 1 + ;; + esac + + version="v${major}.${minor}.${patch}" + + if git rev-parse -q --verify "refs/tags/${version}" >/dev/null; then + echo "::error::${version} already exists" + exit 1 + fi + + { + echo "version=${version}" + echo "major=v${major}" + } >> "$GITHUB_OUTPUT" + + echo "Releasing ${version}" + + - name: Tag the release + env: + GH_TOKEN: ${{ github.token }} + MAJOR: ${{ steps.version.outputs.major }} + VERSION: ${{ steps.version.outputs.version }} + run: | + git config user.name github-actions[bot] + git config user.email 41898282+github-actions[bot]@users.noreply.github.com + gh auth setup-git + + git tag -a "$VERSION" -m "compas-actions $VERSION" + git push origin "$VERSION" + + # Consumers pin the movable major tag, so it follows every release in that + # series. A major release creates a new one and leaves the previous in place. + git tag -f -a "$MAJOR" -m "compas-actions $MAJOR" + git push --force origin "$MAJOR" + + - name: Publish the release + env: + GH_TOKEN: ${{ github.token }} + VERSION: ${{ steps.version.outputs.version }} + run: gh release create "$VERSION" --title "$VERSION" --generate-notes diff --git a/README.md b/README.md index 8d0e840..de26a23 100644 --- a/README.md +++ b/README.md @@ -128,6 +128,17 @@ can instead pass a GitHub App installation token. `@main` is useful while bootstrapping this repository. Consumers should move to the readable `@v1` release tag after the first release. +## Releasing + +Run the `release` workflow from the Actions tab and pick `patch`, `minor` or +`major`. It works out the next version from the existing tags, creates the +annotated point tag, moves the major tag consumers pin to, and publishes a +GitHub release with generated notes. + +Releases run from the default branch only. A major release creates a new major +tag and leaves the previous one where it is, so callers on the old one keep +working until they choose to move. + ## Deliberate exclusions - IronPython and Rhino 7 component generation From d802ec9e66730be25c245ecad2d3fec35cbe0914 Mon Sep 17 00:00:00 2001 From: Gonzalo Casas Date: Wed, 19 Aug 2026 09:59:02 +0200 Subject: [PATCH 2/3] refactor: create release tags through the API Pushing tags meant configuring a committer identity, because `git tag -a` records a tagger and fails without one whatever permissions the token holds. That is unrelated to authorization, and hardcoding the bot's address to satisfy it was noise. Creating the refs through the API removes it: `contents: write` on the job is now the whole authorization story, the tagger comes from the token, and the job needs no git credentials at all -- so `gh auth setup-git` goes too. The only git left is reading tags to work out the next version, which needs nothing. The point tag stays annotated, created as a tag object and then a ref. The movable major tag becomes a lightweight ref pointing straight at the commit, which suits a pointer that moves better than carrying a tagger and a date from whichever release last moved it. Co-Authored-By: Claude Opus 5 --- .github/workflows/release.yml | 36 ++++++++++++++++++++++++----------- 1 file changed, 25 insertions(+), 11 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index bab2701..d95bf27 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -101,17 +101,31 @@ jobs: MAJOR: ${{ steps.version.outputs.major }} VERSION: ${{ steps.version.outputs.version }} run: | - git config user.name github-actions[bot] - git config user.email 41898282+github-actions[bot]@users.noreply.github.com - gh auth setup-git - - git tag -a "$VERSION" -m "compas-actions $VERSION" - git push origin "$VERSION" - - # Consumers pin the movable major tag, so it follows every release in that - # series. A major release creates a new one and leaves the previous in place. - git tag -f -a "$MAJOR" -m "compas-actions $MAJOR" - git push --force origin "$MAJOR" + # Refs are created through the API rather than pushed, so the job needs no git + # credentials and no tagger identity: contents: write on the token is the whole + # authorization story, and the API stamps the tagger from the token itself. + tag_sha=$(gh api "repos/${GITHUB_REPOSITORY}/git/tags" \ + -f tag="$VERSION" \ + -f message="compas-actions $VERSION" \ + -f object="$GITHUB_SHA" \ + -f type=commit \ + --jq .sha) + + gh api "repos/${GITHUB_REPOSITORY}/git/refs" \ + -f ref="refs/tags/${VERSION}" \ + -f sha="$tag_sha" >/dev/null + + # Consumers pin the movable major tag, so it follows every release in the series. + # A major release creates a new one and leaves the previous where it is. + if gh api "repos/${GITHUB_REPOSITORY}/git/ref/tags/${MAJOR}" >/dev/null 2>&1; then + gh api --method PATCH "repos/${GITHUB_REPOSITORY}/git/refs/tags/${MAJOR}" \ + -f sha="$GITHUB_SHA" \ + -F force=true >/dev/null + else + gh api "repos/${GITHUB_REPOSITORY}/git/refs" \ + -f ref="refs/tags/${MAJOR}" \ + -f sha="$GITHUB_SHA" >/dev/null + fi - name: Publish the release env: From 431e8b4590f4a711e8adc3c7125db639acd3f007 Mon Sep 17 00:00:00 2001 From: Gonzalo Casas Date: Wed, 19 Aug 2026 10:03:58 +0200 Subject: [PATCH 3/3] refactor: fold the version fallback into one code path The seeding case duplicated the major/minor/patch assignment just to reach the same arithmetic. Normalising the fallback to a version string instead lets both paths share the parse and the bump, and the step reads top to bottom: find the current version, bump it, check it is free. Behaviour is unchanged, including the ordering case sort -V exists for (v1.10.0 sorts above v1.9.0, which a lexical sort gets backwards). Co-Authored-By: Claude Opus 5 --- .github/workflows/release.yml | 43 ++++++++++------------------------- 1 file changed, 12 insertions(+), 31 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d95bf27..6be51cd 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -47,40 +47,21 @@ jobs: run: | latest=$(git tag --list 'v[0-9]*.[0-9]*.[0-9]*' | sort -V | tail -n 1) - if [[ -n "$latest" ]]; then - IFS=. read -r major minor patch <<< "${latest#v}" - echo "Latest release is $latest" - else - # No point release has ever been tagged, only the movable major tag. Seed from - # that, so the series consumers already pin to carries on rather than restarting - # at v0 and stranding every `@v1` caller on a tag that stops moving. - series=$(git tag --list 'v[0-9]*' | grep -E '^v[0-9]+$' | sort -V | tail -n 1) - major=${series#v} - major=${major:-0} - minor=0 - patch=0 - echo "No point releases yet, continuing the ${series:-v0} series" + # With no point release tagged yet, carry on from the movable major tag, so the + # series consumers already pin to continues instead of restarting at v0 and + # stranding them on a tag that stops moving. + if [[ -z "$latest" ]]; then + series=$(git tag --list 'v[0-9]*' | grep -Ex 'v[0-9]+' | sort -V | tail -n 1) + latest="${series:-v0}.0.0" fi + IFS=. read -r major minor patch <<< "${latest#v}" case "$RELEASE_TYPE" in - major) - major=$((major + 1)) - minor=0 - patch=0 - ;; - minor) - minor=$((minor + 1)) - patch=0 - ;; - patch) - patch=$((patch + 1)) - ;; - *) - echo "::error::Unknown release type: $RELEASE_TYPE" - exit 1 - ;; + major) major=$((major + 1)); minor=0; patch=0 ;; + minor) minor=$((minor + 1)); patch=0 ;; + patch) patch=$((patch + 1)) ;; + *) echo "::error::Unknown release type: $RELEASE_TYPE"; exit 1 ;; esac - version="v${major}.${minor}.${patch}" if git rev-parse -q --verify "refs/tags/${version}" >/dev/null; then @@ -93,7 +74,7 @@ jobs: echo "major=v${major}" } >> "$GITHUB_OUTPUT" - echo "Releasing ${version}" + echo "Releasing ${version}, up from ${latest}" - name: Tag the release env: