From 1f9ca30236bce69e27024489414db658efa2513f Mon Sep 17 00:00:00 2001 From: Jack Sullivan Date: Thu, 30 Jul 2026 13:38:19 -0700 Subject: [PATCH] feat: release automatically on merge to main Tagging this repo was manual, and unlike commitlint it also needs the major-version alias moved on every release, since consumers pin `@v1`. That meant a force-push by hand each time, which is how v1 came to sit on a commit whose action.yml could not even be parsed. Port next-version.sh from commitlint and add a release workflow that derives the version from the merged commit, tags it, moves the `vN` alias, and publishes. Manual `v*` tag pushes still work for re-cuts, and a duplicate tag is a no-op rather than a failed run. The release job parses action.yml before advertising the release. A tag is public the moment it is pushed, and v1.0.0 shipped an action.yml that could not load, so the check guards the failure that actually happened. Both repositories are public now, so drop the private-module scaffolding: the CI self-test no longer probes module reachability or passes a token, and it runs on fork PRs. Rewrite the README's permissions and cross-repo sections to lead with the public path instead of the token fallback. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01VSrrciEDBTuFNMtKocScML --- .github/next-version.sh | 70 +++++++++++++++++++++++++ .github/workflows/ci.yml | 30 +---------- .github/workflows/release.yml | 89 +++++++++++++++++++++++++++++++ README.md | 98 +++++++++++------------------------ 4 files changed, 191 insertions(+), 96 deletions(-) create mode 100755 .github/next-version.sh create mode 100644 .github/workflows/release.yml diff --git a/.github/next-version.sh b/.github/next-version.sh new file mode 100755 index 0000000..e7f3ca4 --- /dev/null +++ b/.github/next-version.sh @@ -0,0 +1,70 @@ +#!/bin/sh +# Derive the next semver tag from the latest tag and a conventional commit +# subject. Prints the new tag (e.g. v1.2.0) on stdout, or nothing at all when +# the commit does not warrant a release. +# +# next-version.sh "" [] +# +# Bump rules, per Conventional Commits: +# `!` before the colon, or a BREAKING CHANGE trailer -> major +# feat -> minor +# fix, perf -> patch +# anything else (docs, ci, chore, refactor, ...) -> no release +# +# Kept as a script rather than inline YAML so it can be tested directly. +set -eu + +subject="${1:?usage: next-version.sh [latest-tag]}" +latest="${2:-}" + +if [ -z "$latest" ]; then + latest="$(git tag --list 'v*' --sort=-v:refname | head -n1)" +fi +[ -n "$latest" ] || latest="v0.0.0" + +# Strip the leading v and any trailing pre-release/build metadata. +core="${latest#v}" +core="${core%%-*}" +major="${core%%.*}" +rest="${core#*.}" +minor="${rest%%.*}" +patch="${rest#*.}" + +case "$major$minor$patch" in +*[!0-9]*) + echo "cannot parse tag '$latest' as vMAJOR.MINOR.PATCH" >&2 + exit 1 + ;; +esac + +# type(scope)?!?: description -- capture the type and whether ! is present. +type="$(printf '%s' "$subject" | sed -n 's/^\([a-z][a-z0-9-]*\)\((.*)\)\{0,1\}!\{0,1\}:.*/\1/p')" +breaking=no +case "$subject" in +*'!:'* | *'!):'*) breaking=yes ;; +esac +case "$subject" in +*'BREAKING CHANGE'*) breaking=yes ;; +esac + +if [ "$breaking" = yes ]; then + major=$((major + 1)) + minor=0 + patch=0 +else + case "$type" in + feat) + minor=$((minor + 1)) + patch=0 + ;; + fix | perf) + patch=$((patch + 1)) + ;; + *) + # Not a releasable change. + exit 0 + ;; + esac +fi + +printf 'v%s.%s.%s\n' "$major" "$minor" "$patch" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4620e23..7207287 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -48,49 +48,23 @@ jobs: fi # Run the action against this very PR. A composite action can pass every - # static check and still fail at runtime, so exercise it for real. - # - # The action authenticates its module fetch, so this no longer depends on - # commitlint being public. It does depend on the token being able to read - # that repo: the default github.token is scoped to THIS repository, so while - # commitlint is private a cross-repo token (COMMITLINT_READ_TOKEN) is - # required. Skip when it is absent rather than fail for a reason unrelated - # to the code under review — once commitlint is public, neither is needed. + # static check and still fail at runtime, so exercise it for real. commitlint + # is public, so this needs no token and runs on fork PRs too. self-test: runs-on: ubuntu-latest steps: - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 - - name: check module is reachable - id: reach - env: - TOKEN: ${{ secrets.COMMITLINT_READ_TOKEN }} - run: | - if curl -fsS -o /dev/null "https://proxy.golang.org/github.com/!divergent!codes/commitlint/@v/list"; then - echo "reachable=true" >> "$GITHUB_OUTPUT" - echo "commitlint is public; running self-test with the default token" - elif [ -n "${TOKEN:-}" ]; then - echo "reachable=true" >> "$GITHUB_OUTPUT" - echo "commitlint is private; running self-test with COMMITLINT_READ_TOKEN" - else - echo "reachable=false" >> "$GITHUB_OUTPUT" - echo "::notice::self-test skipped: commitlint is private and COMMITLINT_READ_TOKEN is not set" - fi - - name: run this action (advisory) - if: steps.reach.outputs.reachable == 'true' uses: ./ with: # Never fail this repo's CI on a contributor's commit style; the point # is to prove the action executes, not to gate on its verdict. pr-title-mode: warn commits-mode: warn - github-token: ${{ secrets.COMMITLINT_READ_TOKEN || github.token }} - name: run with commits-mode off - if: steps.reach.outputs.reachable == 'true' uses: ./ with: pr-title-mode: warn commits-mode: off - github-token: ${{ secrets.COMMITLINT_READ_TOKEN || github.token }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..a4f1087 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,89 @@ +name: release + +# Releases are automatic: merging a feat/fix/perf (or a breaking change) to +# main derives the next version, tags it, moves the major-version alias, and +# publishes. Pushing a `v*` tag by hand still works for re-cuts. +on: + push: + branches: [main] + tags: ["v*"] + +permissions: + contents: write # push tags, move the major alias, create the release + +jobs: + # Decides the version. On a main-branch push it derives the next tag from the + # merged commit and pushes it; on a manual tag push it reports that tag. + version: + runs-on: ubuntu-latest + outputs: + tag: ${{ steps.pick.outputs.tag }} + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + with: + fetch-depth: 0 # all tags, to find the latest version + + - name: pick version + id: pick + env: + REF_TYPE: ${{ github.ref_type }} + REF_NAME: ${{ github.ref_name }} + SUBJECT: ${{ github.event.head_commit.message }} + run: | + if [ "$REF_TYPE" = tag ]; then + echo "manual tag push: $REF_NAME" + echo "tag=$REF_NAME" >> "$GITHUB_OUTPUT" + exit 0 + fi + + next="$(.github/next-version.sh "$SUBJECT")" + if [ -z "$next" ]; then + echo "commit is not a releasable change; no release" + exit 0 + fi + if git rev-parse -q --verify "refs/tags/$next" >/dev/null; then + echo "tag $next already exists; no release" + exit 0 + fi + + echo "tagging $next" + git tag "$next" + git push origin "$next" + echo "tag=$next" >> "$GITHUB_OUTPUT" + + # Consumers pin `@v1`, so that alias has to follow every release or it rots + # at whatever commit it was last moved to by hand. Moving it is the step that + # was previously manual, and the reason a force-push was ever needed. + release: + needs: version + if: needs.version.outputs.tag != '' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + with: + ref: ${{ needs.version.outputs.tag }} + fetch-depth: 0 + + # An action whose action.yml does not parse cannot be loaded at all, and + # a tag is public the moment it is pushed. v1.0.0 shipped exactly that + # way, so verify before advertising the release. + - name: action.yml parses + run: python3 -c "import yaml,sys; yaml.safe_load(open('action.yml')); print('action.yml parses')" + + - name: move major alias + env: + TAG: ${{ needs.version.outputs.tag }} + run: | + major="${TAG%%.*}" # v1.2.3 -> v1 + git tag -f "$major" "$TAG" + git push --force origin "$major" + echo "moved $major to $TAG" + + - name: publish release + env: + GH_TOKEN: ${{ github.token }} + TAG: ${{ needs.version.outputs.tag }} + run: | + gh release create "$TAG" \ + --title "$TAG" \ + --generate-notes diff --git a/README.md b/README.md index c4464fa..5af5c0a 100644 --- a/README.md +++ b/README.md @@ -68,75 +68,24 @@ merge with rebase or merge commits instead, set `commits-mode: block`. to fetch the commits. The action makes no API calls and never writes anything. With `commits-mode: off` it reads only the event payload. -The action installs the linter with a public `go install` first. If the -`commitlint` repository is public, that path is used and the module proxy's -**checksum-database verification applies** — nothing else is needed. - -If that fetch fails, the repository is private and credentials are required. -The action then rewrites only `github.com/DivergentCodes/` URLs to carry -`github-token`, so the token is never offered to another host or org, and sets -`GOPRIVATE` for the retry. `GOPRIVATE` bypasses the proxy and checksum -database — unavoidable for a private module, which is why it is scoped to the -fallback rather than applied unconditionally. Pass a token that can read the -repo: +The linter is installed with a plain `go install`, which resolves through the +Go module proxy with **checksum-database verification**. No token is involved. -```yaml - - uses: DivergentCodes/commitlint-action@ - with: - github-token: ${{ secrets.COMMITLINT_READ_TOKEN }} -``` - -Once `commitlint` is public this is unnecessary and the default applies. - -## Using this in another DivergentCodes repository - -Both repositories are private, which needs two one-time settings. Neither has -to be made public. - -**1. Let other org repos use this action.** Private actions are not callable -across repositories by default. In **this** repo: Settings → Actions → General -→ Access → *Accessible from repositories in the DivergentCodes organization*. -Without it, consuming workflows fail before the action runs. - -**2. Provide a token that can read `DivergentCodes/commitlint`.** The default -`github.token` is scoped to the repository running the workflow, so it cannot -read a *different* private repo. Create a fine-grained PAT (or GitHub App -token) with **Contents: read** on `DivergentCodes/commitlint` only, and add it -as an organization secret named `COMMITLINT_READ_TOKEN`. - -Then, in the consuming repository: - -```yaml -name: pr-lint -on: - pull_request: - types: [opened, edited, synchronize, reopened] -permissions: - contents: read -jobs: - commitlint: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@ - - uses: DivergentCodes/commitlint-action@ # v1.0.1 - with: - github-token: ${{ secrets.COMMITLINT_READ_TOKEN }} - pr-title-mode: block - commits-mode: warn -``` +If that fetch fails — which it will if the linter is being pulled from a +private fork or mirror — the action retries with `github-token`, rewriting only +`github.com/DivergentCodes/` URLs so the token is never offered to another host +or org. That retry sets `GOPRIVATE`, which bypasses the proxy and checksum +database; it is scoped to the fallback precisely so the ordinary path keeps its +verification. -If `commitlint` later becomes public, drop the `github-token` line and delete -the secret; nothing else changes. +## Using this in another repository -### Troubleshooting org setup +Both this action and `DivergentCodes/commitlint` are public, so no token or +extra configuration is needed — paste the workflow from [Usage](#usage) and it +works. -- **`terminal prompts disabled`** during *Install commitlint* → the token is - missing, expired, or lacks Contents: read on `DivergentCodes/commitlint`. -- **The workflow fails before any step runs**, with a message about the - action not being found → step 1 above has not been applied. -- **`could not read Username`** with a token set → the secret is defined in - the wrong scope; organization secrets must be made visible to the consuming - repository. +The `github-token` input remains for the case where the linter is fetched from +a private fork or mirror; it is unused otherwise. ## Runner requirements @@ -155,9 +104,22 @@ uses `actions/setup-go` to install the linter and reads commits with `git`. On ## Releases -Published as **GitHub tagged releases** with changelogs in the release notes. -Reference a release by SHA (preferred) or tag; there is no committed -changelog file. +Releases are **automatic on merge to `main`**, with autogenerated changelogs in +the release notes (no committed changelog file). The version comes from the +merged commit's conventional type: + +| Merged commit | Bump | Example | +|---|---|---| +| `feat!:` / `BREAKING CHANGE` | major | `v1.2.3` → `v2.0.0` | +| `feat:` | minor | `v1.2.3` → `v1.3.0` | +| `fix:` / `perf:` | patch | `v1.2.3` → `v1.2.4` | +| `docs:` / `ci:` / `chore:` … | none | no release | + +The major-version alias (`v1`) moves to each new release automatically, so +`@v1` always resolves to the newest compatible version. Pushing a `v*` tag by +hand still works for re-cuts. + +Reference a release by SHA (preferred) or tag. ## License