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