Skip to content

Commit 43dec9e

Browse files
committed
feat: add CI-driven release pipeline
- release-prepare.yml: two-job workflow_dispatch; Job 1 validates semver and checks tag collision, Job 2 mints App token, bumps gradle.properties, commits, tags, atomic pushes - release-tag.yml: add validate-tag job (format + gradle.properties consistency), concurrency lock, workflow_dispatch with dry_run, fix prerelease flag to include -rc - gradle.properties: add version=1.0.0 as single source of truth - build.gradle.kts: remove stale version = '1.0-SNAPSHOT'
1 parent 3691da6 commit 43dec9e

6 files changed

Lines changed: 374 additions & 3 deletions

File tree

.claude/CLAUDE.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,3 +16,4 @@ Sync server for the [Octi](https://github.com/d4rken-org/octi) Android app. Devi
1616
- [Build Commands](rules/build-commands.md) — Gradle commands, running locally, Docker, CI
1717
- [Testing](rules/testing.md) — TestRunner, integration tests, helpers
1818
- [Commit Guidelines](rules/commit-guidelines.md) — Commit message format and examples
19+
- [Releasing](rules/release.md) — Release flow, inputs, gotchas, recovery

.claude/rules/release.md

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
# Releasing
2+
3+
## Flow at a glance
4+
5+
```
6+
release-prepare.yml (workflow_dispatch)
7+
└─ Job 1: compute-and-validate (always)
8+
reads gradle.properties version=, computes next, checks tag collision, writes summary
9+
└─ Job 2: push-and-dispatch (only when dry_run=false)
10+
mints App token, bumps gradle.properties, commits "Release: vX.Y.Z", tags, atomic push
11+
12+
└─► release-tag.yml fires automatically from the App-token push
13+
└─ Job 1: validate-tag (always)
14+
regex check on tag name + gradle.properties consistency
15+
└─ Job 2: release-github (needs validate-tag, gated by foss-production)
16+
multi-arch Docker push to ghcr.io + installDist zip + GitHub Release
17+
```
18+
19+
## Inputs for `release-prepare.yml`
20+
21+
| Input | Type | Default | Description |
22+
|-------|------|---------|-------------|
23+
| `bump_type` | choice (patch/minor/major) | `patch` | Auto-increment strategy. Ignored when `version` is set. |
24+
| `version` | string | `''` | Explicit version override (e.g. `1.1.0`, `2.0.0-rc1`). Ignores `bump_type`. |
25+
| `expected_current` | string | `''` | Safety check: fail if `gradle.properties` version doesn't match. Defends against concurrent queued runs. |
26+
| `dry_run` | boolean | `true` | When true, only Job 1 runs (validate + print plan). Set to `false` to actually push. |
27+
28+
Examples:
29+
- Normal patch release: `bump_type=patch`, `dry_run=false`
30+
- First RC: `version=1.1.0-rc1`, `dry_run=false`
31+
- Promote RC to stable: `version=1.1.0`, `dry_run=false`
32+
33+
## Version source of truth
34+
35+
`gradle.properties` — the single `version=X.Y.Z` line. Gradle reads it automatically; `build.gradle.kts` does not set `version`.
36+
37+
## Cancel window
38+
39+
When `dry_run=false`, Job 1 ends (summary appears on screen) and Job 2 starts runner spin-up (~5–15 seconds). That window is the manual-cancel affordance — watch the run page after dispatching. Job 1's validation catches most issues before Job 2 starts.
40+
41+
## Five gotchas worth memorizing
42+
43+
1. **App-token pushes fire `on: push: tags`** — do NOT add `gh workflow run release-tag.yml`. App tokens aren't `GITHUB_TOKEN`, so they fire workflow triggers. Adding an explicit dispatch produces duplicate runs (capod #568).
44+
2. **Use `app-slug` output, not `gh api /app`**`/app` requires a signed App JWT, not the installation token; it 401s (capod #567).
45+
3. **Use `client-id`, not `app-id`**`app-id` is deprecated upstream and prints warnings. Secret name is `RELEASE_APP_CLIENT_ID` (capod #569).
46+
4. **Atomic push**`git push --atomic origin HEAD:refs/heads/main "refs/tags/vX.Y.Z"`. Both the bump commit and the tag land together or neither does.
47+
5. **`workflow_dispatch` only sees workflows on the default branch** — merging to `main` is required before you can dispatch `release-prepare.yml`.
48+
49+
## Recovery procedures
50+
51+
**Build failure after tag push** (Docker/GH Release step failed, tag exists, gradle.properties already bumped):
52+
1. Go to `release-tag.yml` → Run workflow → target the tag ref (e.g. `v1.0.1`), set `dry_run=false`.
53+
2. `validate-tag` re-checks format + consistency. `release-github` re-runs with `foss-production` approval.
54+
55+
**Needs rollback** (tag pushed but you want to undo before the release is published):
56+
1. Delete the remote tag: `git push origin --delete vX.Y.Z`
57+
2. Revert the bump commit on `main` (or cherry-pick a revert): `git revert <sha>` + push.
58+
3. Re-cut from the correct state.
59+
60+
## Prerequisites (one-time setup)
61+
62+
- `d4rken-org-releaser` GitHub App installed on this repo (already used by capod).
63+
- Org secrets `RELEASE_APP_CLIENT_ID` and `RELEASE_APP_PRIVATE_KEY` accessible to this repo.
64+
- App added as bypass actor in any branch/tag protection rulesets covering `main` and `v*` tags (`GITHUB_TOKEN` cannot be a bypass actor — only installed Apps can).
Lines changed: 258 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,258 @@
1+
name: Release prepare
2+
3+
on:
4+
workflow_dispatch:
5+
inputs:
6+
bump_type:
7+
description: 'Version bump type (ignored when version is set)'
8+
required: false
9+
default: patch
10+
type: choice
11+
options:
12+
- patch
13+
- minor
14+
- major
15+
version:
16+
description: 'Explicit version override, e.g. 1.2.3 or 1.2.3-rc1 (ignores bump_type)'
17+
required: false
18+
default: ''
19+
type: string
20+
expected_current:
21+
description: 'Safety check: fail if gradle.properties version does not match this value'
22+
required: false
23+
default: ''
24+
type: string
25+
dry_run:
26+
description: 'Dry run: validate and print plan only, skip commit/push'
27+
required: false
28+
default: true
29+
type: boolean
30+
31+
permissions:
32+
contents: read
33+
34+
concurrency:
35+
group: release-prepare-main
36+
cancel-in-progress: false
37+
38+
jobs:
39+
compute-and-validate:
40+
name: Compute and validate
41+
permissions:
42+
contents: read
43+
runs-on: ubuntu-24.04
44+
outputs:
45+
current_name: ${{ steps.read-version.outputs.current }}
46+
new_name: ${{ steps.compute-version.outputs.new }}
47+
48+
steps:
49+
- name: Guard — must run on main
50+
env:
51+
REF: ${{ github.ref }}
52+
run: |
53+
set -euo pipefail
54+
if [[ "$REF" != "refs/heads/main" ]]; then
55+
echo "::error::This workflow must be dispatched from main (got $REF)"
56+
exit 1
57+
fi
58+
59+
- name: Checkout main
60+
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
61+
with:
62+
ref: main
63+
fetch-depth: 0
64+
persist-credentials: false
65+
66+
- name: Read current version
67+
id: read-version
68+
env:
69+
EXPECTED_CURRENT: ${{ inputs.expected_current }}
70+
run: |
71+
set -euo pipefail
72+
count=$(grep -cE '^version=' gradle.properties || true)
73+
[[ "$count" == "1" ]] || { echo "::error::expected exactly one 'version=' line in gradle.properties, found $count"; exit 1; }
74+
current=$(awk -F= '$1=="version"{print $2}' gradle.properties)
75+
echo "$current" | grep -qE '^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-(rc|beta)(0|[1-9][0-9]*))?$' || {
76+
echo "::error::current version '$current' does not match semver format"
77+
exit 1
78+
}
79+
if [[ -n "$EXPECTED_CURRENT" && "$current" != "$EXPECTED_CURRENT" ]]; then
80+
echo "::error::current version '$current' does not match expected_current '$EXPECTED_CURRENT'"
81+
exit 1
82+
fi
83+
echo "current=$current" >> "$GITHUB_OUTPUT"
84+
85+
- name: Compute next version
86+
id: compute-version
87+
env:
88+
CURRENT: ${{ steps.read-version.outputs.current }}
89+
BUMP_TYPE: ${{ inputs.bump_type }}
90+
OVERRIDE: ${{ inputs.version }}
91+
run: |
92+
set -euo pipefail
93+
new=$(python3 - <<'PYEOF'
94+
import re, sys, os
95+
semver = re.compile(r'^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(?:-(rc|beta)(0|[1-9][0-9]*))?$')
96+
def parse(v):
97+
m = semver.match(v)
98+
if not m: sys.exit(f"invalid version: {v}")
99+
return (int(m[1]), int(m[2]), int(m[3]), m[4], int(m[5]) if m[5] else None)
100+
def precedence(p):
101+
return (p[0], p[1], p[2], 1 if p[3] is None else 0, p[3] or '', p[4] or 0)
102+
cur = parse(os.environ['CURRENT'])
103+
override = os.environ.get('OVERRIDE') or None
104+
if override:
105+
nxt = parse(override)
106+
else:
107+
if cur[3] is not None:
108+
sys.exit("bump_type rejected on prerelease current; use explicit version override")
109+
x, y, z = cur[:3]
110+
b = os.environ['BUMP_TYPE']
111+
if b == 'patch': z += 1
112+
elif b == 'minor': y, z = y + 1, 0
113+
elif b == 'major': x, y, z = x + 1, 0, 0
114+
else: sys.exit(f"invalid bump_type: {b}")
115+
nxt = (x, y, z, None, None)
116+
if precedence(nxt) <= precedence(cur):
117+
sys.exit(f"refusing downgrade: {nxt} not > {cur}")
118+
pre = f"-{nxt[3]}{nxt[4]}" if nxt[3] else ''
119+
print(f"{nxt[0]}.{nxt[1]}.{nxt[2]}{pre}")
120+
PYEOF
121+
)
122+
echo "new=$new" >> "$GITHUB_OUTPUT"
123+
124+
- name: Check tag collision
125+
env:
126+
NEW: ${{ steps.compute-version.outputs.new }}
127+
run: |
128+
set -euo pipefail
129+
if git rev-parse --verify "refs/tags/v${NEW}" >/dev/null 2>&1; then
130+
echo "::error::Local tag v${NEW} already exists"
131+
exit 1
132+
fi
133+
if git ls-remote --exit-code --tags origin "refs/tags/v${NEW}" >/dev/null; then
134+
echo "::error::Remote tag v${NEW} already exists"
135+
exit 1
136+
fi
137+
138+
- name: Write job summary
139+
env:
140+
CURRENT: ${{ steps.read-version.outputs.current }}
141+
NEW: ${{ steps.compute-version.outputs.new }}
142+
DRY_RUN: ${{ inputs.dry_run }}
143+
run: |
144+
set -euo pipefail
145+
echo "| Field | Value |" >> "$GITHUB_STEP_SUMMARY"
146+
echo "|----------|-------|" >> "$GITHUB_STEP_SUMMARY"
147+
echo "| Current | \`$CURRENT\` |" >> "$GITHUB_STEP_SUMMARY"
148+
echo "| New | \`$NEW\` |" >> "$GITHUB_STEP_SUMMARY"
149+
echo "| Tag | \`v$NEW\` |" >> "$GITHUB_STEP_SUMMARY"
150+
echo "| Dry run | \`$DRY_RUN\` |" >> "$GITHUB_STEP_SUMMARY"
151+
152+
push-and-dispatch:
153+
name: Push bump and tag
154+
needs: compute-and-validate
155+
if: ${{ !inputs.dry_run }}
156+
permissions:
157+
contents: read
158+
runs-on: ubuntu-24.04
159+
160+
steps:
161+
- name: Mint GitHub App token
162+
id: app-token
163+
uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 #v3.1.1
164+
with:
165+
client-id: ${{ secrets.RELEASE_APP_CLIENT_ID }}
166+
private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }}
167+
168+
- name: Resolve bot identity
169+
id: bot
170+
env:
171+
GH_TOKEN: ${{ steps.app-token.outputs.token }}
172+
APP_SLUG: ${{ steps.app-token.outputs.app-slug }}
173+
run: |
174+
set -euo pipefail
175+
user_id=$(gh api "/users/${APP_SLUG}%5Bbot%5D" --jq .id)
176+
echo "user_name=${APP_SLUG}[bot]" >> "$GITHUB_OUTPUT"
177+
echo "user_email=${user_id}+${APP_SLUG}[bot]@users.noreply.github.com" >> "$GITHUB_OUTPUT"
178+
179+
- name: Checkout main with App token
180+
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
181+
with:
182+
ref: main
183+
fetch-depth: 0
184+
persist-credentials: true
185+
token: ${{ steps.app-token.outputs.token }}
186+
187+
- name: Re-validate current version
188+
env:
189+
EXPECTED: ${{ needs.compute-and-validate.outputs.current_name }}
190+
run: |
191+
set -euo pipefail
192+
count=$(grep -cE '^version=' gradle.properties || true)
193+
[[ "$count" == "1" ]] || { echo "::error::expected exactly one 'version=' line, found $count"; exit 1; }
194+
current=$(awk -F= '$1=="version"{print $2}' gradle.properties)
195+
[[ "$current" == "$EXPECTED" ]] || {
196+
echo "::error::current version '$current' does not match expected '$EXPECTED' (main moved during job gap?)"
197+
exit 1
198+
}
199+
200+
- name: Re-check tag collision
201+
env:
202+
NEW: ${{ needs.compute-and-validate.outputs.new_name }}
203+
run: |
204+
set -euo pipefail
205+
if git rev-parse --verify "refs/tags/v${NEW}" >/dev/null 2>&1; then
206+
echo "::error::Local tag v${NEW} already exists"
207+
exit 1
208+
fi
209+
if git ls-remote --exit-code --tags origin "refs/tags/v${NEW}" >/dev/null; then
210+
echo "::error::Remote tag v${NEW} already exists"
211+
exit 1
212+
fi
213+
214+
- name: Apply version bump
215+
env:
216+
NEW: ${{ needs.compute-and-validate.outputs.new_name }}
217+
run: |
218+
set -euo pipefail
219+
sed -i "s/^version=.*/version=${NEW}/" gradle.properties
220+
count=$(grep -cE '^version=' gradle.properties || true)
221+
[[ "$count" == "1" ]] || { echo "::error::expected exactly one version= line after bump, found $count"; exit 1; }
222+
got=$(awk -F= '$1=="version"{print $2}' gradle.properties)
223+
[[ "$got" == "$NEW" ]] || { echo "::error::bump verification failed: expected '$NEW' got '$got'"; exit 1; }
224+
225+
- name: Configure git identity
226+
env:
227+
GIT_USER_NAME: ${{ steps.bot.outputs.user_name }}
228+
GIT_USER_EMAIL: ${{ steps.bot.outputs.user_email }}
229+
run: |
230+
set -euo pipefail
231+
git config user.name "$GIT_USER_NAME"
232+
git config user.email "$GIT_USER_EMAIL"
233+
234+
- name: Commit and tag
235+
env:
236+
NEW: ${{ needs.compute-and-validate.outputs.new_name }}
237+
run: |
238+
set -euo pipefail
239+
git add gradle.properties
240+
git commit -m "Release: v${NEW}"
241+
git tag -a "v${NEW}" -m "Release v${NEW}"
242+
243+
- name: Atomic push
244+
env:
245+
NEW: ${{ needs.compute-and-validate.outputs.new_name }}
246+
run: |
247+
set -euo pipefail
248+
git push --atomic origin HEAD:refs/heads/main "refs/tags/v${NEW}"
249+
250+
- name: Write final summary
251+
env:
252+
NEW: ${{ needs.compute-and-validate.outputs.new_name }}
253+
REPO: ${{ github.repository }}
254+
run: |
255+
set -euo pipefail
256+
echo "Pushed commit and tag \`v${NEW}\` to main." >> "$GITHUB_STEP_SUMMARY"
257+
echo "The \`release-tag.yml\` workflow fired automatically — track it at:" >> "$GITHUB_STEP_SUMMARY"
258+
echo "https://github.com/${REPO}/actions/workflows/release-tag.yml" >> "$GITHUB_STEP_SUMMARY"

.github/workflows/release-tag.yml

Lines changed: 50 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,60 @@ on:
44
push:
55
tags:
66
- 'v*'
7+
workflow_dispatch:
8+
inputs:
9+
dry_run:
10+
description: 'Dry run: validate tag only, skip publish (default true)'
11+
required: false
12+
default: true
13+
type: boolean
714

8-
permissions: {}
15+
permissions:
16+
contents: read
17+
18+
concurrency:
19+
group: release-${{ github.ref_name }}
20+
cancel-in-progress: false
921

1022
jobs:
23+
validate-tag:
24+
name: Validate tag
25+
runs-on: ubuntu-24.04
26+
steps:
27+
- name: Check tag format
28+
env:
29+
TAG: ${{ github.ref_name }}
30+
run: |
31+
set -euo pipefail
32+
echo "$TAG" | grep -qE '^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-(rc|beta)(0|[1-9][0-9]*))?$' || {
33+
echo "::error::Tag '$TAG' does not match expected format vX.Y.Z[-rcN|-betaN]. Releases must be cut via the 'Release prepare' workflow."
34+
exit 1
35+
}
36+
37+
- name: Checkout
38+
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
39+
with:
40+
fetch-depth: 1
41+
persist-credentials: false
42+
43+
- name: Check gradle.properties version matches tag
44+
env:
45+
TAG: ${{ github.ref_name }}
46+
run: |
47+
set -euo pipefail
48+
count=$(grep -cE '^version=' gradle.properties || true)
49+
[[ "$count" == "1" ]] || { echo "::error::expected exactly one 'version=' line in gradle.properties, found $count"; exit 1; }
50+
file_version=$(awk -F= '$1=="version"{print $2}' gradle.properties)
51+
tag_version="${TAG#v}"
52+
[[ "$file_version" == "$tag_version" ]] || {
53+
echo "::error::gradle.properties version '$file_version' does not match tag '$TAG'. Releases must be cut via the 'Release prepare' workflow."
54+
exit 1
55+
}
56+
1157
release-github:
1258
name: Create GitHub release
59+
needs: [validate-tag]
60+
if: "github.event_name != 'workflow_dispatch' || !inputs.dry_run"
1361
permissions:
1462
contents: write
1563
packages: write
@@ -71,7 +119,7 @@ jobs:
71119
- name: Create GitHub release
72120
uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda #v3.0.0
73121
with:
74-
prerelease: ${{ contains(github.ref_name, '-beta') }}
122+
prerelease: ${{ contains(github.ref_name, '-beta') || contains(github.ref_name, '-rc') }}
75123
tag_name: ${{ github.ref_name }}
76124
name: ${{ github.ref_name }}
77125
generate_release_notes: true

0 commit comments

Comments
 (0)