Skip to content

Commit b2b3bf7

Browse files
authored
automate versioned releases (#15)
1 parent 4041b35 commit b2b3bf7

10 files changed

Lines changed: 309 additions & 7 deletions

File tree

.claude-plugin/marketplace.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,13 @@
55
},
66
"metadata": {
77
"description": "Portable TLA+ agent skills for Claude Code and other Agent Skills clients.",
8-
"version": "0.1.2"
8+
"version": "0.1.6"
99
},
1010
"plugins": [
1111
{
1212
"name": "tla-workbenches",
1313
"description": "TLA+ skills for checking specs and writing proofs.",
14-
"version": "0.1.2",
14+
"version": "0.1.6",
1515
"author": {
1616
"name": "younes-io"
1717
},
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
name: Release On Version Change
2+
3+
on:
4+
push:
5+
branches:
6+
- main
7+
8+
permissions:
9+
contents: write
10+
11+
jobs:
12+
release:
13+
runs-on: ubuntu-latest
14+
15+
steps:
16+
- name: Checkout repository
17+
uses: actions/checkout@v4
18+
with:
19+
fetch-depth: 0
20+
21+
- name: Set up Node
22+
uses: actions/setup-node@v4
23+
with:
24+
node-version: 20
25+
26+
- name: Detect VERSION changes in this push
27+
id: version_change
28+
env:
29+
BEFORE_SHA: ${{ github.event.before }}
30+
run: |
31+
set -euo pipefail
32+
33+
if [[ "$BEFORE_SHA" == "0000000000000000000000000000000000000000" ]]; then
34+
changed="true"
35+
elif git diff --quiet "$BEFORE_SHA" "$GITHUB_SHA" -- VERSION; then
36+
changed="false"
37+
else
38+
changed="true"
39+
fi
40+
41+
version="$(./scripts/read_version.sh)"
42+
43+
{
44+
echo "changed=$changed"
45+
echo "version=$version"
46+
} >> "$GITHUB_OUTPUT"
47+
48+
- name: Stop when VERSION was not changed
49+
if: steps.version_change.outputs.changed != 'true'
50+
run: |
51+
echo "VERSION did not change in this push; skipping release."
52+
53+
- name: Configure git identity
54+
if: steps.version_change.outputs.changed == 'true'
55+
run: |
56+
git config user.name "github-actions[bot]"
57+
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
58+
59+
- name: Sync manifests and generated files
60+
if: steps.version_change.outputs.changed == 'true'
61+
run: |
62+
set -euo pipefail
63+
./scripts/sync_version.sh
64+
./scripts/sync_claude_plugin_skills.sh
65+
66+
- name: Validate release state
67+
if: steps.version_change.outputs.changed == 'true'
68+
run: |
69+
set -euo pipefail
70+
npx -y skills add . --list
71+
./scripts/tests/version_sync_test.sh
72+
git diff --exit-code -- plugins/tla-workbenches/skills
73+
./scripts/validate_claude_plugin.sh
74+
75+
- name: Commit synced metadata if needed
76+
if: steps.version_change.outputs.changed == 'true'
77+
run: |
78+
set -euo pipefail
79+
80+
if git diff --quiet -- .claude-plugin/marketplace.json plugins/tla-workbenches/.claude-plugin/plugin.json; then
81+
echo "No manifest sync commit needed."
82+
exit 0
83+
fi
84+
85+
git add .claude-plugin/marketplace.json plugins/tla-workbenches/.claude-plugin/plugin.json
86+
git commit -m "chore(release): sync metadata for v${{ steps.version_change.outputs.version }}"
87+
git push origin HEAD:main
88+
89+
- name: Create and push tag
90+
if: steps.version_change.outputs.changed == 'true'
91+
env:
92+
TAG_NAME: v${{ steps.version_change.outputs.version }}
93+
run: |
94+
set -euo pipefail
95+
96+
git fetch --tags --force origin
97+
98+
if git ls-remote --tags origin "refs/tags/${TAG_NAME}" | grep -q .; then
99+
echo "Tag already exists on origin: ${TAG_NAME}" >&2
100+
exit 1
101+
fi
102+
103+
git tag -a "${TAG_NAME}" -m "Release ${TAG_NAME}"
104+
git push origin "refs/tags/${TAG_NAME}"
105+
106+
- name: Create GitHub release
107+
if: steps.version_change.outputs.changed == 'true'
108+
env:
109+
GH_TOKEN: ${{ github.token }}
110+
TAG_NAME: v${{ steps.version_change.outputs.version }}
111+
run: |
112+
set -euo pipefail
113+
gh release create "${TAG_NAME}" \
114+
--title "${TAG_NAME}" \
115+
--generate-notes

.github/workflows/validate.yml

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,24 @@ jobs:
1515
with:
1616
node-version: 20
1717

18+
- name: Shell syntax checks
19+
run: |
20+
set -euo pipefail
21+
bash -n scripts/read_version.sh
22+
bash -n scripts/sync_version.sh
23+
bash -n scripts/sync_claude_plugin_skills.sh
24+
bash -n scripts/validate_claude_plugin.sh
25+
bash -n scripts/tests/version_sync_test.sh
26+
1827
- name: Validate portable skill pack
1928
run: npx -y skills add . --list
2029

30+
- name: Run version sync regression test
31+
run: ./scripts/tests/version_sync_test.sh
32+
33+
- name: Sync release version into manifests
34+
run: ./scripts/sync_version.sh
35+
2136
- name: Sync generated Claude plugin skills
2237
run: ./scripts/sync_claude_plugin_skills.sh
2338

README.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,19 @@ Claude-specific wrapper files live under:
7575

7676
The root `skills/` directory is the only editable source of skill content. The Claude plugin `skills/` tree is generated from it and committed for GitHub-based Claude Code installs.
7777

78+
## Releases
79+
80+
`VERSION` is the single release source of truth.
81+
82+
When a change to `VERSION` reaches `main`, GitHub Actions will:
83+
84+
- sync the release version into the Claude plugin manifests
85+
- validate the repo state
86+
- create and push the matching Git tag (`vX.Y.Z`)
87+
- create the matching GitHub Release
88+
89+
Normal content changes do not create a release unless `VERSION` changed in that push.
90+
7891
## tla-check prerequisites
7992

8093
See `skills/tla-check/SKILL.md` for full usage.

VERSION

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
0.1.6

plugins/tla-workbenches/.claude-plugin/plugin.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "tla-workbenches",
3-
"version": "0.1.2",
3+
"version": "0.1.6",
44
"description": "TLA+ skills for checking specs and writing proofs.",
55
"author": {
66
"name": "younes-io"

scripts/read_version.sh

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
#!/usr/bin/env bash
2+
set -euo pipefail
3+
4+
repo_root="${REPO_ROOT:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}"
5+
version_file="$repo_root/VERSION"
6+
7+
if [[ ! -f "$version_file" ]]; then
8+
echo "Missing VERSION file: $version_file" >&2
9+
exit 1
10+
fi
11+
12+
version="$(tr -d '[:space:]' < "$version_file")"
13+
if [[ -z "$version" ]]; then
14+
echo "VERSION file is empty" >&2
15+
exit 1
16+
fi
17+
18+
if [[ ! "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
19+
echo "VERSION must be strict semver in the form X.Y.Z" >&2
20+
exit 1
21+
fi
22+
23+
printf '%s\n' "$version"

scripts/sync_version.sh

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
#!/usr/bin/env bash
2+
set -euo pipefail
3+
4+
repo_root="${REPO_ROOT:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}"
5+
version="$("$repo_root/scripts/read_version.sh")"
6+
marketplace_json="$repo_root/.claude-plugin/marketplace.json"
7+
plugin_json="$repo_root/plugins/tla-workbenches/.claude-plugin/plugin.json"
8+
9+
python3 - "$marketplace_json" "$plugin_json" "$version" <<'PY'
10+
import json
11+
import sys
12+
13+
marketplace_path, plugin_path, version = sys.argv[1:4]
14+
15+
with open(marketplace_path, "r", encoding="utf-8") as f:
16+
marketplace = json.load(f)
17+
18+
with open(plugin_path, "r", encoding="utf-8") as f:
19+
plugin = json.load(f)
20+
21+
metadata = marketplace.setdefault("metadata", {})
22+
metadata["version"] = version
23+
24+
plugins = marketplace.get("plugins")
25+
if not isinstance(plugins, list) or len(plugins) != 1:
26+
raise SystemExit("Marketplace must contain exactly one plugin entry")
27+
28+
plugins[0]["version"] = version
29+
plugin["version"] = version
30+
31+
for path, payload in ((marketplace_path, marketplace), (plugin_path, plugin)):
32+
with open(path, "w", encoding="utf-8") as f:
33+
json.dump(payload, f, indent=2)
34+
f.write("\n")
35+
PY

scripts/tests/version_sync_test.sh

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
#!/usr/bin/env bash
2+
set -euo pipefail
3+
4+
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
5+
tmp_root="$(mktemp -d)"
6+
trap 'rm -rf "$tmp_root"' EXIT
7+
8+
fixture_root="$tmp_root/repo"
9+
mkdir -p "$fixture_root"
10+
11+
rsync \
12+
--archive \
13+
--exclude '.git' \
14+
"$repo_root/" \
15+
"$fixture_root/"
16+
17+
assert_eq() {
18+
local expected="$1"
19+
local actual="$2"
20+
local message="$3"
21+
if [[ "$expected" != "$actual" ]]; then
22+
echo "$message: expected '$expected', got '$actual'" >&2
23+
exit 1
24+
fi
25+
}
26+
27+
initial_version="$(
28+
REPO_ROOT="$fixture_root" \
29+
"$fixture_root/scripts/read_version.sh"
30+
)"
31+
32+
python3 - "$fixture_root" <<'PY'
33+
import json
34+
import pathlib
35+
import sys
36+
37+
root = pathlib.Path(sys.argv[1])
38+
marketplace_path = root / ".claude-plugin" / "marketplace.json"
39+
plugin_path = root / "plugins" / "tla-workbenches" / ".claude-plugin" / "plugin.json"
40+
41+
with open(marketplace_path, "r", encoding="utf-8") as f:
42+
marketplace = json.load(f)
43+
with open(plugin_path, "r", encoding="utf-8") as f:
44+
plugin = json.load(f)
45+
46+
with open(root / "VERSION", "r", encoding="utf-8") as f:
47+
expected = f.read().strip()
48+
49+
assert marketplace["metadata"]["version"] == expected
50+
assert marketplace["plugins"][0]["version"] == expected
51+
assert plugin["version"] == expected
52+
PY
53+
54+
next_version="$(
55+
python3 - "$initial_version" <<'PY'
56+
import sys
57+
58+
major, minor, patch = map(int, sys.argv[1].split("."))
59+
print(f"{major}.{minor}.{patch + 1}")
60+
PY
61+
)"
62+
63+
printf '%s\n' "$next_version" > "$fixture_root/VERSION"
64+
65+
if REPO_ROOT="$fixture_root" "$fixture_root/scripts/validate_claude_plugin.sh" >/dev/null 2>&1; then
66+
echo "validate_claude_plugin.sh should fail when VERSION and manifests diverge" >&2
67+
exit 1
68+
fi
69+
70+
REPO_ROOT="$fixture_root" "$fixture_root/scripts/sync_version.sh"
71+
REPO_ROOT="$fixture_root" "$fixture_root/scripts/validate_claude_plugin.sh"
72+
73+
synced_version="$(
74+
REPO_ROOT="$fixture_root" \
75+
"$fixture_root/scripts/read_version.sh"
76+
)"
77+
assert_eq "$next_version" "$synced_version" "synced VERSION"
78+
79+
python3 - "$fixture_root" <<'PY'
80+
import json
81+
import pathlib
82+
import sys
83+
84+
root = pathlib.Path(sys.argv[1])
85+
marketplace_path = root / ".claude-plugin" / "marketplace.json"
86+
plugin_path = root / "plugins" / "tla-workbenches" / ".claude-plugin" / "plugin.json"
87+
88+
with open(marketplace_path, "r", encoding="utf-8") as f:
89+
marketplace = json.load(f)
90+
with open(plugin_path, "r", encoding="utf-8") as f:
91+
plugin = json.load(f)
92+
93+
with open(root / "VERSION", "r", encoding="utf-8") as f:
94+
expected = f.read().strip()
95+
96+
assert marketplace["metadata"]["version"] == expected
97+
assert marketplace["plugins"][0]["version"] == expected
98+
assert plugin["version"] == expected
99+
PY

scripts/validate_claude_plugin.sh

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
11
#!/usr/bin/env bash
22
set -euo pipefail
33

4-
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
4+
repo_root="${REPO_ROOT:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}"
55
marketplace_json="$repo_root/.claude-plugin/marketplace.json"
66
plugin_root="$repo_root/plugins/tla-workbenches"
77
plugin_json="$plugin_root/.claude-plugin/plugin.json"
88
source_skills="$repo_root/skills"
99
generated_skills="$plugin_root/skills"
10+
version="$("$repo_root/scripts/read_version.sh")"
1011

1112
for required_path in "$marketplace_json" "$plugin_json" "$source_skills" "$generated_skills"; do
1213
if [[ ! -e "$required_path" ]]; then
@@ -15,11 +16,11 @@ for required_path in "$marketplace_json" "$plugin_json" "$source_skills" "$gener
1516
fi
1617
done
1718

18-
python3 - "$marketplace_json" "$plugin_json" <<'PY'
19+
python3 - "$marketplace_json" "$plugin_json" "$version" <<'PY'
1920
import json
2021
import sys
2122
22-
marketplace_path, plugin_path = sys.argv[1:3]
23+
marketplace_path, plugin_path, expected_version = sys.argv[1:4]
2324
2425
with open(marketplace_path, "r", encoding="utf-8") as f:
2526
marketplace = json.load(f)
@@ -32,7 +33,7 @@ expected_marketplace = {
3233
"owner_name": "younes-io",
3334
"plugin_name": "tla-workbenches",
3435
"plugin_source": "./plugins/tla-workbenches",
35-
"plugin_version": "0.1.2",
36+
"plugin_version": expected_version,
3637
"skills": "./skills/",
3738
}
3839

0 commit comments

Comments
 (0)