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
70 changes: 70 additions & 0 deletions .github/next-version.sh
Original file line number Diff line number Diff line change
@@ -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 "<subject>" [<latest-tag>]
#
# 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 <subject> [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"
30 changes: 2 additions & 28 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
89 changes: 89 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -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
98 changes: 30 additions & 68 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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@<full-sha>
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@<full-sha>
- uses: DivergentCodes/commitlint-action@<full-sha> # 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

Expand All @@ -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

Expand Down
Loading