Skip to content

Commit 02fc125

Browse files
author
kyle
committed
init
0 parents  commit 02fc125

File tree

5 files changed

+406
-0
lines changed

5 files changed

+406
-0
lines changed

LICENSE

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
MIT License
2+
3+
Copyright (c) 2025 hwrok
4+
5+
Permission is hereby granted, free of charge, to any person obtaining a copy
6+
of this software and associated documentation files (the "Software"), to deal
7+
in the Software without restriction, including without limitation the rights
8+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9+
copies of the Software, and to permit persons to whom the Software is
10+
furnished to do so, subject to the following conditions:
11+
12+
The above copyright notice and this permission notice shall be included in all
13+
copies or substantial portions of the Software.
14+
15+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21+
SOFTWARE.

README.md

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
# Git Tag Sequencer
2+
3+
Generate a scoped monotonically increasing sequence number as a git tag, scoped by a namespace and optional key. Useful for build numbering, release tagging, run counts within or not tied to full workflow runs, etc.
4+
5+
---
6+
7+
## Inputs
8+
9+
| Name | Description | Type | Required | Default |
10+
|----------------|--------------------------------------------------------------------------------------------------------| ------- | -------- | -------- |
11+
| `namespace` | Namespace / "category" for the sequence (e.g., `build`) | string | yes ||
12+
| `key` | Optional key / "sub-scope" within the namespace; if omitted, `namespace` is used as the sequence name | string | no ||
13+
| `root_tag` | Optional git tag to use as the commit reference for the new tag; defaults to using current branch HEAD | string | no ||
14+
| `seed` | Starting number if no existing tags found | number | no | 1 |
15+
| `with_cleanup` | Whether to delete previous tags matching the sequence | boolean | no | false |
16+
| `dry_run` | Logs actions without creating or deleting tags | boolean | no | false |
17+
18+
---
19+
20+
## Outputs
21+
22+
| Name | Description |
23+
|------------|---------------------------------|
24+
| `sequence` | The sequence number assigned |
25+
| `tag` | The tag pushed for the sequence |
26+
27+
---
28+
29+
## Usage
30+
31+
```yaml
32+
steps:
33+
- uses: actions/checkout@v4
34+
with:
35+
fetch-depth: 0
36+
fetch-tags: true
37+
38+
- name: Get Sequence
39+
uses: hwrok/git-tag-sequencer@v1
40+
id: get_sequence
41+
with:
42+
namespace: 'build'
43+
key: '1.2.3'
44+
with_cleanup: true
45+
46+
- run: echo "Next sequence: ${{ steps.get_sequence.outputs.sequence }}"
47+
```
48+
49+
## Requirements
50+
51+
- The repository must be checked out with tags fetched (`actions/checkout: with: fetch-depth: 0 and fetch-tags: true`, or `- run: git fetch --tags --prune --unshallow`)
52+
- Job requires `permissions: contents: write` to create and delete tags
53+
- Use `dry_run` to preview actions without making changes
54+
- Cleanup removes all previous tags matching the sequence, leaving only the newly created tag
55+
56+
## Examples
57+
58+
### Namespace + Key
59+
```yaml
60+
with:
61+
namespace: "build"
62+
key: "1.2.3"
63+
```
64+
- tag placed on: current branch `HEAD` (default)
65+
- example tag created: `build-1.2.3-42` (assuming the previous was `-41`)
66+
- meaning: build number `42` for version `1.2.3`
67+
68+
### Placing Tag on an Existing "Root Tag" for Easy Reference, Cleanliness, or Disambiguation
69+
```yaml
70+
with:
71+
namespace: "deploy"
72+
key: "staging"
73+
root_tag: "release-2025"
74+
```
75+
- tag placed on: commit tagged `release-2025`
76+
- example tag created: `deploy-staging-5`
77+
- meaning: 5th deployment to staging for the release tagged `release-2025`
78+
79+
### Simplest Use Case
80+
```yaml
81+
with:
82+
namespace: "build"
83+
```
84+
- tag placed on: current branch `HEAD`
85+
- example tag created: `build-17`
86+
- meaning: build number `17` (no additional scoping key)
87+
88+
### Re-seeding
89+
```yaml
90+
with:
91+
namespace: "build"
92+
key: "2.0.0"
93+
seed: 100
94+
```
95+
- potential use cases
96+
- migrating from a different sequencing system and want to start where it left off
97+
- repairing sequence after accidental tag deletions to avoid reusing old numbers
98+
- tag placed on: current branch `HEAD`
99+
- example tag created: `build-2.0.0-100` (if no existing tags ≥ 100 found)

action.yml

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
name: git-tag-sequencer
2+
description: 'Generate a scoped, monotonically increasing git tag sequence by namespace and optional key.'
3+
4+
branding:
5+
color: 'green'
6+
icon: 'tag'
7+
8+
inputs:
9+
namespace:
10+
description: 'namespace / "category" for the sequence'
11+
required: true
12+
key:
13+
description: 'key / "sub-scope" for the sequence; if not provided, namespace is used as the sequence name'
14+
required: false
15+
root_tag:
16+
description: 'root tag to place new tags; if not provided, HEAD of current branch is used as the ref for the new tag'
17+
required: false
18+
seed:
19+
description: 'seed to use if no previous matching tags are found'
20+
required: false
21+
with_cleanup:
22+
description: 'cleanup previous tags for this sequence'
23+
required: false
24+
default: 'false'
25+
dry_run:
26+
description: 'dry run - produces logs, does not write / delete external data'
27+
required: false
28+
default: 'false'
29+
30+
outputs:
31+
sequence:
32+
description: 'the sequence number assigned'
33+
value: ${{ steps.get_sequence.outputs.sequence }}
34+
tag:
35+
description: 'the tag for the sequence that will be pushed'
36+
value: ${{ steps.get_sequence.outputs.tag }}
37+
38+
runs:
39+
using: 'composite'
40+
steps:
41+
- name: Get Sequence
42+
id: get_sequence
43+
shell: bash
44+
env:
45+
HWS_NAMESPACE: "${{ inputs.namespace }}"
46+
HWS_KEY: "${{ inputs.key }}"
47+
HWS_ROOT_TAG: "${{ inputs.root_tag }}"
48+
HWS_SEED: "${{ inputs.seed }}"
49+
HWS_WITH_CLEANUP: "${{ inputs.with_cleanup }}"
50+
HWS_DRY_RUN: "${{ inputs.dry_run }}"
51+
run: |
52+
# get sequence
53+
bash $GITHUB_ACTION_PATH/lib/main.sh

lib/get_next_sequence.sh

Lines changed: 213 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,213 @@
1+
#!/bin/bash
2+
3+
set -euo pipefail
4+
5+
script_name="get_next_sequence"
6+
7+
namespace="${HWS_NAMESPACE:-}"
8+
key="${HWS_KEY:-}"
9+
root_tag="${HWS_ROOT_TAG:-}"
10+
seed="${HWS_SEED:-1}"
11+
with_cleanup="${HWS_WITH_CLEANUP:-false}"
12+
dry_run="${HWS_DRY_RUN:-false}"
13+
14+
log() {
15+
local timestamp level label value color reset
16+
timestamp=$(date '+%Y-%m-%d %H:%M:%S')
17+
18+
case "$1" in
19+
ERROR) color='\033[0;31m'; level=$1; label=$2; value="${3-}" ;;
20+
WARN) color='\033[0;33m'; level=$1; label=$2; value="${3-}" ;;
21+
INFO) color=''; level=$1; label=$2; value="${3-}" ;;
22+
*) color=''; level="INFO"; label=$1; value="${2-}" ;;
23+
esac
24+
25+
reset='\033[0m'
26+
27+
if [[ -n "$value" ]]; then
28+
printf "${color}[%s] %-19s %-7s %-30s %s${reset}\n" \
29+
"$script_name" "$timestamp" "$level" "$label" "[$value]" >&2
30+
else
31+
printf "${color}[%s] %-19s %-7s %s${reset}\n" \
32+
"$script_name" "$timestamp" "$level" "$label" >&2
33+
fi
34+
}
35+
36+
37+
while [[ "$#" -gt 0 ]]; do
38+
case "$1" in
39+
--namespace=*) namespace="${1#*=}"; shift ;;
40+
--namespace) namespace="$2"; shift 2 ;;
41+
--key=*) key="${1#*=}"; shift ;;
42+
--key) key="$2"; shift 2 ;;
43+
--root-tag=*) root_tag="${1#*=}"; shift ;;
44+
--root-tag) root_tag="$2"; shift 2 ;;
45+
--seed=*) seed="${1#*=}"; shift ;;
46+
--seed) seed="$2"; shift 2 ;;
47+
--with-cleanup) with_cleanup=true; shift ;;
48+
--dry-run) dry_run=true; shift ;;
49+
*) log "unknown argument" "$1"; exit 1 ;;
50+
esac
51+
done
52+
53+
if [[ -z "$namespace" ]]; then
54+
log ERROR "usage: $0 --namespace <namespace> [--key <key>] [--root-tag <tag>] [--seed <num>] [--with-cleanup] [--dry-run]"
55+
exit 1
56+
fi
57+
58+
if [[ -n "$seed" && ! "$seed" =~ ^[0-9]+$ ]]; then
59+
log ERROR "seed must be a non-negative integer" "$seed"
60+
exit 1
61+
fi
62+
63+
command -v GIT_TERMINAL_PROMPT=0 git >/dev/null || {
64+
log ERROR "git not installed"
65+
exit 1
66+
}
67+
68+
if ! GIT_TERMINAL_PROMPT=0 git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
69+
log ERROR "not inside a git repository"
70+
exit 1
71+
fi
72+
73+
find_max_sequence() {
74+
local sequence_base=$1
75+
shift
76+
local matches=("$@")
77+
78+
if [[ ${#matches[@]} -eq 0 || ( ${#matches[@]} -eq 1 && -z "${matches[0]}" ) ]]; then
79+
log "no matching tags found"
80+
return 0
81+
elif [[ "${#matches[@]}" -eq 1 ]]; then
82+
log "one matching tag found" "${matches[0]}"
83+
echo "${matches[0]##"$sequence_base"-}"
84+
return 0
85+
else
86+
log "multiple matching tags found"
87+
local max_num=-1
88+
for tag in "${matches[@]}"; do
89+
local num=${tag##"$sequence_base"-}
90+
log "checking" "$tag"
91+
log "comparing" "$num > $max_num"
92+
if [[ "$num" =~ ^[0-9]+$ ]]; then
93+
(( num > max_num )) && max_num=$num
94+
else
95+
log ERROR "num not numeric" "$num"
96+
exit 1
97+
fi
98+
done
99+
log "determined max sequence" "$max_num"
100+
echo "$max_num"
101+
return 0
102+
fi
103+
}
104+
105+
get_ref_commit() {
106+
local root_tag="$1"
107+
108+
if [[ -n "$root_tag" ]]; then
109+
if ref_commit=$(GIT_TERMINAL_PROMPT=0 git rev-parse "$root_tag" 2>/dev/null); then
110+
echo "$ref_commit"
111+
return 0
112+
else
113+
log ERROR "root tag not found" "$root_tag"
114+
return 1
115+
fi
116+
else
117+
# get current branch's latest commit hash
118+
if ref_commit=$(GIT_TERMINAL_PROMPT=0 git rev-parse HEAD 2>/dev/null); then
119+
echo "$ref_commit"
120+
return 0
121+
else
122+
log ERROR "could not get current HEAD commit"
123+
return 1
124+
fi
125+
fi
126+
}
127+
128+
129+
cleanup() {
130+
local matches=("${!1}")
131+
local next_tag=$2
132+
133+
for tag in "${matches[@]}"; do
134+
if [[ "$tag" != "$next_tag" ]]; then
135+
log "deleting tag" "$tag"
136+
if [[ "$dry_run" == true ]]; then
137+
log "[DRY RUN] push tag delete" "$tag"
138+
log "[DRY RUN] local tag delete" "$tag"
139+
else
140+
if ! GIT_TERMINAL_PROMPT=0 git push --delete origin "$tag" --quiet >&2; then
141+
log WARN "failed to push tag delete" "$tag"
142+
fi
143+
if ! GIT_TERMINAL_PROMPT=0 git tag -d "$tag" >/dev/null; then
144+
log WARN "failed to delete local tag" "$tag"
145+
fi
146+
fi
147+
fi
148+
done
149+
}
150+
151+
sequence_base="$namespace"
152+
log "setting sequence base" "$sequence_base"
153+
154+
if [[ -n "$key" ]]; then
155+
sequence_base+="-$key"
156+
log "updating sequence base" "$sequence_base"
157+
fi
158+
159+
ref_commit=$(get_ref_commit "$root_tag") || exit 1
160+
161+
log "finding tags that start with" "$sequence_base-"
162+
matches=()
163+
while IFS= read -r tag; do
164+
log "found tag" "$tag"
165+
matches+=("$tag")
166+
done < <(GIT_TERMINAL_PROMPT=0 git tag -l "$sequence_base-*" | grep -E "^${sequence_base}-[0-9]+$")
167+
168+
current_sequence=$(find_max_sequence "$sequence_base" "${matches[@]-}")
169+
170+
if [[ -n "$current_sequence" ]]; then
171+
log "existing sequence" "$current_sequence"
172+
next_sequence=$((current_sequence + 1))
173+
else
174+
log WARN "falling back to seed" "$seed"
175+
next_sequence=$seed
176+
fi
177+
178+
next_tag="$sequence_base-$next_sequence"
179+
180+
log "setting this sequence" "$next_sequence"
181+
log "setting this sequence tag" "$next_tag"
182+
183+
if [[ "$dry_run" == true ]]; then
184+
log "[DRY RUN] local tag" "$next_tag"
185+
log "[DRY RUN] push tag" "$next_tag"
186+
else
187+
if ! GIT_TERMINAL_PROMPT=0 git tag "$next_tag" "$ref_commit" >&2; then
188+
log ERROR "failed to tag [$ref_commit] with [$next_tag]"
189+
exit 1
190+
fi
191+
if ! GIT_TERMINAL_PROMPT=0 git push origin "$next_tag" --quiet >&2; then
192+
log ERROR "failed to push tag" "$next_tag"
193+
exit 1
194+
fi
195+
fi
196+
197+
cleanup_log_value="flag=$with_cleanup, matches ${#matches[@]}"
198+
if [[ "$with_cleanup" == true && ${#matches[@]} -gt 0 ]]; then
199+
log "starting cleanup" "$cleanup_log_value"
200+
cleanup "matches[@]" "$next_tag"
201+
else
202+
log "skipping cleanup" "$cleanup_log_value"
203+
fi
204+
205+
log "returning sequence" "$next_sequence"
206+
log "returning tag" "$next_tag"
207+
208+
cat <<EOF
209+
sequence=$next_sequence
210+
tag=$next_tag
211+
EOF
212+
213+
exit 0

0 commit comments

Comments
 (0)