first commit

This commit is contained in:
2026-07-22 13:48:46 +08:00
commit c87751c3dc
2820 changed files with 726976 additions and 0 deletions
+367
View File
@@ -0,0 +1,367 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
VERSION_PATH="plugins/cad/VERSION"
VERSION_FILE="$REPO_ROOT/$VERSION_PATH"
PART=""
SET_VERSION=""
FROM_VERSION=""
DRY_RUN=0
CHECK=0
CHECK_INCREMENTED_FROM=""
COMMIT=0
AMEND=0
NO_EDIT=0
NO_VERIFY=0
SIGNOFF=0
TAG=0
FORCE_TAG=0
MESSAGES=()
usage() {
cat <<'EOF'
Usage:
scripts/release/bump-version.sh major|minor|patch [options]
scripts/release/bump-version.sh --set-version X.Y.Z [options]
scripts/release/bump-version.sh --check
scripts/release/bump-version.sh --check-incremented-from REF
Bumps or checks the canonical repo release version in plugins/cad/VERSION.
Duplicate package and plugin metadata is synchronized from that file by
scripts/release/sync-version.mjs during release preparation and production
bundling.
Common options:
--dry-run Show planned edits and git actions without changing files.
--no-commit Write files but do not commit. This is the default.
--commit Commit the bump.
--amend Amend the current commit instead of creating a new commit.
-m, --message MSG Pass a commit message to git commit. May be repeated.
--no-edit Reuse the current commit message with --amend.
--no-verify Pass --no-verify to git commit.
--signoff Pass --signoff to git commit.
--no-tag Do not create a local tag. This is the default.
--tag Create a local tag after committing. Manual fallback only.
--force-tag Move an existing local release tag to HEAD.
--from-version X Require the current canonical version to be X.Y.Z.
EOF
}
die() {
echo "error: $*" >&2
exit 1
}
validate_semver() {
local version="$1"
if [[ ! "$version" =~ ^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]]; then
die "expected a plain semver version like 1.2.3, got '$version'"
fi
}
read_version() {
local version
[ -f "$VERSION_FILE" ] || die "missing canonical version file: $VERSION_PATH"
version="$(tr -d '[:space:]' < "$VERSION_FILE")"
validate_semver "$version"
printf '%s\n' "$version"
}
bump_version() {
local current="$1"
local part="$2"
local major minor patch
validate_semver "$current"
IFS=. read -r major minor patch <<< "$current"
case "$part" in
major)
printf '%s.0.0\n' "$((10#$major + 1))"
;;
minor)
printf '%s.%s.0\n' "$major" "$((10#$minor + 1))"
;;
patch)
printf '%s.%s.%s\n' "$major" "$minor" "$((10#$patch + 1))"
;;
*)
die "unknown bump part: $part"
;;
esac
}
semver_greater() {
local left="$1"
local right="$2"
local left_major left_minor left_patch right_major right_minor right_patch
validate_semver "$left"
validate_semver "$right"
IFS=. read -r left_major left_minor left_patch <<< "$left"
IFS=. read -r right_major right_minor right_patch <<< "$right"
if [ "$((10#$left_major))" -ne "$((10#$right_major))" ]; then
[ "$((10#$left_major))" -gt "$((10#$right_major))" ]
return
fi
if [ "$((10#$left_minor))" -ne "$((10#$right_minor))" ]; then
[ "$((10#$left_minor))" -gt "$((10#$right_minor))" ]
return
fi
[ "$((10#$left_patch))" -gt "$((10#$right_patch))" ]
}
git_text_at_ref() {
local ref="$1"
[ -n "$ref" ] || die "base ref must not be empty"
if [[ "$ref" =~ ^0+$ ]]; then
die "base ref must be a real commit, not an empty all-zero ref"
fi
git -C "$REPO_ROOT" show "$ref:$VERSION_PATH"
}
stage_paths() {
git -C "$REPO_ROOT" add -- "$VERSION_PATH"
}
commit_version_bump() {
local next_version="$1"
local command=(commit)
stage_paths
if [ "$AMEND" -eq 1 ]; then
command+=(--amend)
fi
if [ "$NO_VERIFY" -eq 1 ]; then
command+=(--no-verify)
fi
if [ "$SIGNOFF" -eq 1 ]; then
command+=(--signoff)
fi
if [ "$NO_EDIT" -eq 1 ]; then
command+=(--no-edit)
elif [ "${#MESSAGES[@]}" -gt 0 ]; then
local message
for message in "${MESSAGES[@]}"; do
command+=(-m "$message")
done
elif [ "$AMEND" -eq 1 ]; then
command+=(--no-edit)
else
command+=(-m "Bump version to $next_version")
fi
git -C "$REPO_ROOT" "${command[@]}"
}
tag_exists() {
git -C "$REPO_ROOT" rev-parse --verify --quiet "refs/tags/$1" >/dev/null
}
create_release_tag() {
local tag_name="$1"
local tag_commit head_commit
if tag_exists "$tag_name"; then
tag_commit="$(git -C "$REPO_ROOT" rev-list -n 1 "$tag_name")"
head_commit="$(git -C "$REPO_ROOT" rev-parse HEAD)"
if [ "$tag_commit" = "$head_commit" ]; then
echo "Release tag already exists on HEAD: $tag_name"
return
fi
[ "$FORCE_TAG" -eq 1 ] || die "release tag already exists on a different commit: $tag_name"
git -C "$REPO_ROOT" tag -f "$tag_name" HEAD
echo "Moved release tag: $tag_name"
return
fi
git -C "$REPO_ROOT" tag "$tag_name" HEAD
echo "Created release tag: $tag_name"
}
while [ "$#" -gt 0 ]; do
case "$1" in
major|minor|patch)
[ -z "$PART" ] || die "provide only one semver bump part"
PART="$1"
;;
--set-version)
[ "$#" -ge 2 ] || die "--set-version requires a value"
SET_VERSION="$2"
shift
;;
--set-version=*)
SET_VERSION="${1#--set-version=}"
;;
--from-version)
[ "$#" -ge 2 ] || die "--from-version requires a value"
FROM_VERSION="$2"
shift
;;
--from-version=*)
FROM_VERSION="${1#--from-version=}"
;;
--dry-run)
DRY_RUN=1
;;
--check)
CHECK=1
;;
--check-incremented-from)
[ "$#" -ge 2 ] || die "--check-incremented-from requires a ref"
CHECK=1
CHECK_INCREMENTED_FROM="$2"
shift
;;
--check-incremented-from=*)
CHECK=1
CHECK_INCREMENTED_FROM="${1#--check-incremented-from=}"
;;
--no-commit)
COMMIT=0
TAG=0
;;
--commit)
COMMIT=1
;;
--amend)
AMEND=1
;;
-m|--message)
[ "$#" -ge 2 ] || die "$1 requires a value"
MESSAGES+=("$2")
shift
;;
--message=*)
MESSAGES+=("${1#--message=}")
;;
--no-edit)
NO_EDIT=1
;;
--no-verify)
NO_VERIFY=1
;;
--signoff)
SIGNOFF=1
;;
--no-tag)
TAG=0
;;
--tag)
TAG=1
;;
--force-tag)
FORCE_TAG=1
;;
-h|--help)
usage
exit 0
;;
*)
die "unknown argument: $1"
;;
esac
shift
done
cd "$REPO_ROOT"
if [ "$CHECK" -eq 1 ]; then
if [ -n "$PART" ] || [ -n "$SET_VERSION" ] || [ -n "$FROM_VERSION" ] || [ "$DRY_RUN" -eq 1 ] ||
[ "$COMMIT" -eq 1 ] || [ "$AMEND" -eq 1 ] || [ "${#MESSAGES[@]}" -gt 0 ] ||
[ "$NO_EDIT" -eq 1 ] || [ "$NO_VERIFY" -eq 1 ] || [ "$SIGNOFF" -eq 1 ] ||
[ "$TAG" -eq 1 ] || [ "$FORCE_TAG" -eq 1 ]; then
die "version bump, git commit, and tag arguments cannot be combined with check modes"
fi
current_version="$(read_version)"
if [ -n "$CHECK_INCREMENTED_FROM" ]; then
base_version="$(git_text_at_ref "$CHECK_INCREMENTED_FROM" | tr -d '[:space:]')"
validate_semver "$base_version"
if ! semver_greater "$current_version" "$base_version"; then
die "current version $current_version must be greater than $base_version from $CHECK_INCREMENTED_FROM"
fi
echo "Canonical release version is incremented from $CHECK_INCREMENTED_FROM: $base_version -> $current_version"
else
echo "Canonical release version is valid: $current_version"
fi
exit 0
fi
if [ -n "$PART" ] && [ -n "$SET_VERSION" ]; then
die "provide exactly one of major/minor/patch or --set-version"
fi
if [ -z "$PART" ] && [ -z "$SET_VERSION" ]; then
die "provide exactly one of major/minor/patch or --set-version"
fi
if [ -n "$SET_VERSION" ]; then
validate_semver "$SET_VERSION"
fi
if [ -n "$FROM_VERSION" ]; then
validate_semver "$FROM_VERSION"
fi
if [ "$COMMIT" -eq 1 ] && [ "$AMEND" -eq 1 ]; then
die "--commit and --amend are mutually exclusive"
fi
if [ "$NO_EDIT" -eq 1 ] && [ "$AMEND" -eq 0 ]; then
die "--no-edit only applies with --amend"
fi
if [ "$NO_EDIT" -eq 1 ] && [ "${#MESSAGES[@]}" -gt 0 ]; then
die "--no-edit cannot be combined with --message"
fi
if [ "$TAG" -eq 1 ] && [ "$COMMIT" -eq 0 ] && [ "$AMEND" -eq 0 ] && [ "$DRY_RUN" -eq 0 ]; then
die "--tag requires --commit or --amend"
fi
if [ "$FORCE_TAG" -eq 1 ] && [ "$TAG" -eq 0 ]; then
die "--force-tag requires --tag"
fi
current_version="$(read_version)"
if [ -n "$FROM_VERSION" ] && [ "$current_version" != "$FROM_VERSION" ]; then
die "$VERSION_PATH is $current_version, expected $FROM_VERSION"
fi
if [ -n "$SET_VERSION" ]; then
next_version="$SET_VERSION"
else
next_version="$(bump_version "$current_version" "$PART")"
fi
if [ "$next_version" = "$current_version" ]; then
die "next version matches current version: $current_version"
fi
echo "Version bump: $current_version -> $next_version"
echo "- $VERSION_PATH (canonical plugin version)"
if [ "$DRY_RUN" -eq 1 ]; then
echo "Dry run only; no files changed."
if [ "$COMMIT" -eq 1 ] || [ "$AMEND" -eq 1 ]; then
if [ "$AMEND" -eq 1 ]; then
echo "Would amend the current commit."
else
echo "Would create a commit."
fi
fi
if [ "$TAG" -eq 1 ]; then
echo "Would create release tag: $next_version"
fi
echo "Release workflow: gh workflow run release.yml"
echo "Local fallback: scripts/release/bump-version.sh --set-version $next_version --no-commit"
exit 0
fi
printf '%s\n' "$next_version" > "$VERSION_FILE"
echo "Updated 1 file."
if [ "$COMMIT" -eq 1 ] || [ "$AMEND" -eq 1 ]; then
commit_version_bump "$next_version"
echo "Committed version bump."
fi
if [ "$TAG" -eq 1 ]; then
create_release_tag "$next_version"
elif [ "$COMMIT" -eq 1 ] || [ "$AMEND" -eq 1 ]; then
echo "Release tag to create separately: $next_version"
else
echo "Release workflow: gh workflow run release.yml"
echo "Local fallback: scripts/release/bump-version.sh --set-version $next_version --no-commit"
fi
+128
View File
@@ -0,0 +1,128 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="${RELEASE_REPO_ROOT:-$(cd "$SCRIPT_DIR/../.." && pwd)}"
SOURCE_REF="HEAD"
TARGET_REF="origin/main"
PRINT_PREVIOUS_SOURCE=0
usage() {
cat <<'EOF'
Usage:
scripts/release/check-publish-source.sh [--source-ref REF] [--target-ref REF]
scripts/release/check-publish-source.sh --print-previous-source [--target-ref REF]
Checks that a publish source contains the source commit used by the previous
publish target. This keeps main as a generated-output branch without requiring
develop to merge generated publish commits back into source history.
Options:
--source-ref REF Source branch, tag, or SHA to publish. Defaults to HEAD.
--target-ref REF Previous publish target ref. Defaults to origin/main.
--print-previous-source Print only the resolved previous source commit.
-h, --help Show this help.
EOF
}
die() {
echo "error: $*" >&2
exit 1
}
resolve_commit() {
local ref="$1"
local label="$2"
local commit
commit="$(git rev-parse --verify "$ref^{commit}" 2>/dev/null)" ||
die "could not resolve $label ref as a commit: $ref"
printf '%s\n' "$commit"
}
source_commit_from_message() {
local target_commit="$1"
local recorded
recorded="$(
git log -1 --format=%B "$target_commit" |
sed -nE 's/^Source commit:[[:space:]]*([0-9a-fA-F]{7,64})[[:space:]]*$/\1/p' |
head -n 1
)"
[ -n "$recorded" ] || return 1
resolve_commit "$recorded" "recorded source"
}
previous_source_for_target() {
local target_commit="$1"
local previous_source
if previous_source="$(source_commit_from_message "$target_commit")"; then
printf '%s\n' "$previous_source"
return 0
fi
if git rev-parse --verify --quiet "$target_commit^" >/dev/null; then
git rev-parse "$target_commit^"
return 0
fi
die "could not determine previous publish source for $TARGET_REF; expected a Source commit line or a parent commit"
}
while [ "$#" -gt 0 ]; do
case "$1" in
--source-ref)
[ "$#" -ge 2 ] || die "--source-ref requires a value"
SOURCE_REF="$2"
shift
;;
--source-ref=*)
SOURCE_REF="${1#--source-ref=}"
;;
--target-ref)
[ "$#" -ge 2 ] || die "--target-ref requires a value"
TARGET_REF="$2"
shift
;;
--target-ref=*)
TARGET_REF="${1#--target-ref=}"
;;
--print-previous-source)
PRINT_PREVIOUS_SOURCE=1
;;
-h|--help)
usage
exit 0
;;
*)
die "unknown argument: $1"
;;
esac
shift
done
cd "$REPO_ROOT"
source_sha="$(resolve_commit "$SOURCE_REF" "source")"
target_sha="$(resolve_commit "$TARGET_REF" "target")"
previous_source="$(previous_source_for_target "$target_sha")"
if [ "$PRINT_PREVIOUS_SOURCE" -eq 1 ]; then
printf '%s\n' "$previous_source"
exit 0
fi
if ! git merge-base --is-ancestor "$previous_source" "$source_sha"; then
{
echo "previous publish source is not an ancestor of the requested source"
echo "target ref: $TARGET_REF"
echo "target commit: $target_sha"
echo "previous source: $previous_source"
echo "requested source: $source_sha"
echo
echo "Use source_ref=develop after the release PR has merged, or rebase/merge the source branch so it contains the previous release source."
} >&2
exit 1
fi
echo "Publish source is valid: $source_sha contains previous publish source $previous_source from $TARGET_REF."
+56
View File
@@ -0,0 +1,56 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
usage() {
cat <<'EOF'
Usage:
scripts/release/check-version.sh [--incremented-from REF]
Checks that plugins/cad/VERSION contains a valid canonical release version.
With --incremented-from, also checks that the current version is greater than
the version at REF.
Options:
--incremented-from REF Compare current release version against REF.
-h, --help Show this help.
EOF
}
BASE_REF=""
while [ "$#" -gt 0 ]; do
case "$1" in
--incremented-from)
[ "$#" -ge 2 ] || {
echo "--incremented-from requires a ref" >&2
exit 2
}
BASE_REF="$2"
shift
;;
--incremented-from=*)
BASE_REF="${1#--incremented-from=}"
;;
-h|--help)
usage
exit 0
;;
*)
echo "Unknown argument: $1" >&2
usage >&2
exit 2
;;
esac
shift
done
cd "$REPO_ROOT"
if [ -n "$BASE_REF" ]; then
"$SCRIPT_DIR/bump-version.sh" --check-incremented-from "$BASE_REF"
else
"$SCRIPT_DIR/bump-version.sh" --check
fi
+214
View File
@@ -0,0 +1,214 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd -P)"
BUMP_VERSION="$SCRIPT_DIR/bump-version.sh"
REMOTE="${RELEASE_REMOTE:-origin}"
REPO=""
DRY_RUN=0
SKIP_CHECKS=0
RUN_TESTS=0
SKIP_PUSH=0
SKIP_RELEASE=0
PUBLISH=0
BUMP_ARGS=()
usage() {
cat <<'EOF'
Usage:
scripts/release/create-github-release.sh <major|minor|patch> [options]
scripts/release/create-github-release.sh --set-version X.Y.Z [options]
Runs the repo release-prep flow:
1. calls scripts/release/bump-version.sh
2. bundles generated skill/plugin outputs
3. runs generated-output and plugin checks
4. commits the release version bump
5. creates and pushes the git tag
6. creates a draft GitHub Release for that tag
Options:
--from-version X.Y.Z Pass through to bump-version.sh.
--set-version X.Y.Z Pass through to bump-version.sh instead of bumping a part.
--dry-run Show the planned bump and release steps without writing.
--skip-checks Skip bundle/check scripts after bumping.
--run-tests Run scripts/test/test.sh before committing.
--skip-push Do not push the branch or tag.
--skip-release Do not create the GitHub Release.
--publish Publish the GitHub Release immediately instead of creating a draft.
--remote NAME Git remote to push to. Defaults to origin.
--repo OWNER/REPO GitHub repository for gh release commands.
-h, --help Show this help.
The worktree must be clean before a non-dry-run release prep starts.
This is a manual all-in-one fallback. Prefer the Release GitHub Actions
workflow for normal releases.
EOF
}
die() {
echo "error: $*" >&2
exit 1
}
require_command() {
command -v "$1" >/dev/null 2>&1 || die "$1 is required"
}
while [ "$#" -gt 0 ]; do
case "$1" in
major|minor|patch)
BUMP_ARGS+=("$1")
;;
--from-version|--set-version|--remote|--repo)
[ "$#" -ge 2 ] || die "$1 requires a value"
case "$1" in
--remote)
REMOTE="$2"
;;
--repo)
REPO="$2"
;;
*)
BUMP_ARGS+=("$1" "$2")
;;
esac
shift
;;
--dry-run)
DRY_RUN=1
;;
--skip-checks)
SKIP_CHECKS=1
;;
--run-tests)
RUN_TESTS=1
;;
--skip-push)
SKIP_PUSH=1
;;
--skip-release)
SKIP_RELEASE=1
;;
--publish)
PUBLISH=1
;;
-h|--help)
usage
exit 0
;;
*)
die "unknown argument: $1"
;;
esac
shift
done
cd "$REPO_ROOT"
require_command git
[ -x "$BUMP_VERSION" ] || die "missing executable bump-version script: $BUMP_VERSION"
if [ "$SKIP_PUSH" -eq 1 ] && [ "$SKIP_RELEASE" -eq 0 ]; then
die "--skip-push requires --skip-release because gh release create --verify-tag needs the remote tag"
fi
bump_output="$("$BUMP_VERSION" "${BUMP_ARGS[@]}" --dry-run)"
version_pair="$(printf '%s\n' "$bump_output" | sed -nE 's/^Version bump: ([0-9]+\.[0-9]+\.[0-9]+) -> ([0-9]+\.[0-9]+\.[0-9]+)$/\1 \2/p')"
[ -n "$version_pair" ] || die "could not determine target version from bump-version.sh"
read -r current_version next_version <<<"$version_pair"
if [ "$DRY_RUN" -eq 1 ]; then
printf '%s\n' "$bump_output"
echo ""
echo "Release prep dry run for $current_version -> $next_version:"
echo "- Bundle generated outputs with scripts/bundle/bundle.sh"
if [ "$SKIP_CHECKS" -eq 0 ]; then
echo "- Check generated outputs and plugin metadata with scripts/bundle/bundle.sh --check"
fi
if [ "$RUN_TESTS" -eq 1 ]; then
echo "- Run scripts/test/test.sh"
fi
echo "- Commit canonical release version and bundled outputs as: Release $next_version"
echo "- Create git tag: $next_version"
if [ "$SKIP_PUSH" -eq 0 ]; then
echo "- Push current branch and tag to $REMOTE"
fi
if [ "$SKIP_RELEASE" -eq 0 ]; then
if [ "$PUBLISH" -eq 1 ]; then
echo "- Create published GitHub Release: $next_version"
else
echo "- Create draft GitHub Release: $next_version"
fi
fi
exit 0
fi
branch="$(git symbolic-ref -q --short HEAD || true)"
[ -n "$branch" ] || die "release prep must run on a named branch, not detached HEAD"
if ! git diff --quiet || ! git diff --cached --quiet || [ -n "$(git ls-files --others --exclude-standard)" ]; then
die "working tree must be clean before release prep starts"
fi
if git rev-parse -q --verify "refs/tags/$next_version" >/dev/null; then
die "local tag already exists: $next_version"
fi
if git ls-remote --exit-code --tags "$REMOTE" "refs/tags/$next_version" >/dev/null 2>&1; then
die "remote tag already exists on $REMOTE: $next_version"
else
ls_remote_status=$?
if [ "$ls_remote_status" -ne 2 ]; then
die "failed to check remote tags on $REMOTE"
fi
fi
if [ "$SKIP_RELEASE" -eq 0 ]; then
require_command gh
gh_repo_args=()
if [ -n "$REPO" ]; then
gh_repo_args=(-R "$REPO")
fi
if gh "${gh_repo_args[@]}" release view "$next_version" >/dev/null 2>&1; then
die "GitHub Release already exists: $next_version"
fi
fi
"$BUMP_VERSION" "${BUMP_ARGS[@]}"
"$REPO_ROOT/scripts/bundle/bundle.sh"
if [ "$SKIP_CHECKS" -eq 0 ]; then
"$REPO_ROOT/scripts/bundle/bundle.sh" --check
fi
if [ "$RUN_TESTS" -eq 1 ]; then
"$REPO_ROOT/scripts/test/test.sh"
fi
git add -A
if git diff --cached --quiet; then
die "release prep did not produce any committed changes"
fi
git commit -m "Release $next_version"
git tag "$next_version"
if [ "$SKIP_PUSH" -eq 0 ]; then
git push "$REMOTE" "$branch"
git push "$REMOTE" "$next_version"
fi
if [ "$SKIP_RELEASE" -eq 0 ]; then
release_args=(release create "$next_version" --verify-tag --generate-notes --title "$next_version")
if [ "$PUBLISH" -eq 0 ]; then
release_args+=(--draft)
fi
gh "${gh_repo_args[@]}" "${release_args[@]}"
fi
echo "Prepared release $next_version."
+167
View File
@@ -0,0 +1,167 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd -P)"
REMOTE="${RELEASE_REMOTE:-origin}"
REPO=""
DRY_RUN=0
CREATE_RELEASE=1
DRAFT=1
TARGET_REF="HEAD"
SKIP_EXISTING_VERSION=0
usage() {
cat <<'EOF'
Usage:
scripts/release/publish-github-release.sh [options]
Creates the immutable release identity for the current repo version:
1. verifies plugins/cad/VERSION contains a valid canonical version
2. verifies a new version is greater than the latest local semver tag
3. creates and pushes the semver git tag for plugins/cad/VERSION
4. creates a GitHub Release for that tag with generated notes
Options:
--target REF Commit/ref to tag. Defaults to HEAD.
--remote NAME Git remote to push tags to. Defaults to origin.
--repo OWNER/REPO GitHub repository for gh release commands.
--dry-run Print planned tag/release actions without writing.
--skip-existing-version
Exit successfully when the version tag already exists.
--skip-release Create/push the tag but do not create a GitHub Release.
--draft Create a draft GitHub Release. This is the default.
--publish Publish the GitHub Release immediately.
-h, --help Show this help.
The workflow path should run this from the production branch after generated
outputs have been validated. Local use is a manual fallback.
EOF
}
die() {
echo "error: $*" >&2
exit 1
}
require_command() {
command -v "$1" >/dev/null 2>&1 || die "$1 is required"
}
while [ "$#" -gt 0 ]; do
case "$1" in
--target)
[ "$#" -ge 2 ] || die "--target requires a value"
TARGET_REF="$2"
shift
;;
--remote)
[ "$#" -ge 2 ] || die "--remote requires a value"
REMOTE="$2"
shift
;;
--repo)
[ "$#" -ge 2 ] || die "--repo requires a value"
REPO="$2"
shift
;;
--dry-run)
DRY_RUN=1
;;
--skip-existing-version)
SKIP_EXISTING_VERSION=1
;;
--skip-release)
CREATE_RELEASE=0
;;
--draft)
DRAFT=1
;;
--publish)
DRAFT=0
;;
-h|--help)
usage
exit 0
;;
*)
die "unknown argument: $1"
;;
esac
shift
done
cd "$REPO_ROOT"
require_command git
"$SCRIPT_DIR/check-version.sh"
version="$(tr -d '[:space:]' < "$REPO_ROOT/plugins/cad/VERSION")"
[ -n "$version" ] || die "plugins/cad/VERSION is empty"
tag_name="$version"
target_commit="$(git rev-parse "$TARGET_REF^{commit}")"
latest_tag="$(git tag --list '[0-9]*.[0-9]*.[0-9]*' --sort=-version:refname | head -n 1 || true)"
tag_commit=""
if git rev-parse --verify --quiet "refs/tags/$tag_name" >/dev/null; then
tag_commit="$(git rev-list -n 1 "$tag_name")"
fi
if [ -n "$tag_commit" ]; then
if [ "$tag_commit" != "$target_commit" ]; then
if [ "$SKIP_EXISTING_VERSION" -eq 1 ]; then
echo "Release tag already exists for $tag_name at $tag_commit; skipping $target_commit."
exit 0
fi
die "release tag $tag_name already points at $tag_commit, not $target_commit"
fi
echo "Release tag already points at target commit: $tag_name"
else
if [ -n "$latest_tag" ]; then
"$SCRIPT_DIR/check-version.sh" --incremented-from "refs/tags/$latest_tag"
fi
if [ "$DRY_RUN" -eq 1 ]; then
echo "Would create release tag: $tag_name -> $target_commit"
echo "Would push release tag to $REMOTE"
else
git tag "$tag_name" "$target_commit"
git push "$REMOTE" "refs/tags/$tag_name"
echo "Created and pushed release tag: $tag_name"
fi
fi
if [ "$CREATE_RELEASE" -eq 0 ]; then
echo "Skipping GitHub Release creation."
exit 0
fi
gh_repo_args=()
if [ -n "$REPO" ]; then
gh_repo_args=(-R "$REPO")
fi
if [ "$DRY_RUN" -eq 1 ]; then
if [ "$DRAFT" -eq 1 ]; then
echo "Would create draft GitHub Release: $tag_name"
else
echo "Would create published GitHub Release: $tag_name"
fi
exit 0
fi
require_command gh
if gh "${gh_repo_args[@]}" release view "$tag_name" >/dev/null 2>&1; then
echo "GitHub Release already exists: $tag_name"
exit 0
fi
release_args=(release create "$tag_name" --verify-tag --generate-notes --title "$tag_name")
if [ "$DRAFT" -eq 1 ]; then
release_args+=(--draft)
fi
gh "${gh_repo_args[@]}" "${release_args[@]}"
echo "Created GitHub Release: $tag_name"
+262
View File
@@ -0,0 +1,262 @@
#!/usr/bin/env node
import { readFileSync, writeFileSync } from "node:fs";
import path from "node:path";
import process from "node:process";
import { fileURLToPath } from "node:url";
const scriptDir = path.dirname(fileURLToPath(import.meta.url));
const repoRoot = path.resolve(scriptDir, "../..");
const canonicalVersionPath = "plugins/cad/VERSION";
const semverPattern = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/;
const jsonTargets = [
{ path: "docs/package.json", fields: [["version"]] },
{ path: "docs/package-lock.json", fields: [["version"], ["packages", "", "version"]] },
{ path: "packages/cadjs/package.json", fields: [["version"]] },
{ path: "packages/cadjs/package-lock.json", fields: [["version"], ["packages", "", "version"]] },
{ path: "viewer/package.json", fields: [["version"]] },
{
path: "viewer/package-lock.json",
fields: [
["version"],
["packages", "", "version"],
["packages", "packages/cadjs", "version"],
],
},
{ path: "skills/cad-viewer/scripts/viewer/package.json", fields: [["version"]] },
{ path: "plugins/cad/skills/cad-viewer/scripts/viewer/package.json", fields: [["version"]] },
{ path: "plugins/cad/.claude-plugin/plugin.json", fields: [["version"]] },
{ path: "plugins/cad/.codex-plugin/plugin.json", fields: [["version"]] },
{ path: ".claude-plugin/marketplace.json", fields: [["version"]], pluginEntries: ["cad"] },
{ path: "viewer/packages/cadjs/package.json", fields: [["version"]], required: false },
{ path: "viewer/packages/cadjs/package-lock.json", fields: [["version"], ["packages", "", "version"]], required: false },
{ path: "skills/cad-viewer/scripts/viewer/packages/cadjs/package.json", fields: [["version"]], required: false },
{
path: "skills/cad-viewer/scripts/viewer/packages/cadjs/package-lock.json",
fields: [["version"], ["packages", "", "version"]],
required: false,
},
{ path: "plugins/cad/skills/cad-viewer/scripts/viewer/packages/cadjs/package.json", fields: [["version"]], required: false },
{
path: "plugins/cad/skills/cad-viewer/scripts/viewer/packages/cadjs/package-lock.json",
fields: [["version"], ["packages", "", "version"]],
required: false,
},
];
const tomlTargets = [
"packages/cadpy/pyproject.toml",
"packages/cadpy_metadata/pyproject.toml",
"viewer/moveit2_server/pyproject.toml",
"viewer/packages/cadpy/pyproject.toml",
"skills/cad-viewer/scripts/viewer/moveit2_server/pyproject.toml",
"skills/cad-viewer/scripts/viewer/packages/cadpy/pyproject.toml",
"skills/cad/scripts/packages/cadpy/pyproject.toml",
"skills/sdf/scripts/packages/cadpy_metadata/pyproject.toml",
"skills/srdf/scripts/packages/cadpy_metadata/pyproject.toml",
"skills/urdf/scripts/packages/cadpy_metadata/pyproject.toml",
"plugins/cad/skills/cad/scripts/packages/cadpy/pyproject.toml",
"plugins/cad/skills/cad-viewer/scripts/viewer/moveit2_server/pyproject.toml",
"plugins/cad/skills/cad-viewer/scripts/viewer/packages/cadpy/pyproject.toml",
"plugins/cad/skills/sdf/scripts/packages/cadpy_metadata/pyproject.toml",
"plugins/cad/skills/srdf/scripts/packages/cadpy_metadata/pyproject.toml",
"plugins/cad/skills/urdf/scripts/packages/cadpy_metadata/pyproject.toml",
];
function usage() {
console.log(`Usage:
scripts/release/sync-version.mjs [--check]
Synchronizes duplicate package and plugin metadata versions from
plugins/cad/VERSION. Release preparation edits the canonical VERSION file, then
stamps derived metadata from it; the bundle script and the Release workflow
re-check the same metadata before production output is written.
Options:
--check Fail if derived version metadata is stale.
-h, --help`);
}
function parseArgs(argv) {
const options = { check: false };
for (const arg of argv) {
if (arg === "--check") {
options.check = true;
} else if (arg === "-h" || arg === "--help") {
usage();
process.exit(0);
} else {
throw new Error(`unknown argument: ${arg}`);
}
}
return options;
}
function repoPath(relativePath) {
return path.join(repoRoot, relativePath);
}
function readRequiredText(relativePath) {
return readFileSync(repoPath(relativePath), "utf8");
}
function readOptionalText(relativePath, required = true) {
try {
return readRequiredText(relativePath);
} catch (error) {
if (!required && error && error.code === "ENOENT") {
return null;
}
throw error;
}
}
function canonicalVersion() {
const version = readRequiredText(canonicalVersionPath).trim();
if (!semverPattern.test(version)) {
throw new Error(`${canonicalVersionPath} must contain a plain X.Y.Z semver version`);
}
return version;
}
function formatJsonPath(parts) {
const labels = [];
for (const part of parts) {
if (part === "") {
labels[labels.length - 1] = `${labels[labels.length - 1]}[""]`;
} else {
labels.push(part);
}
}
return labels.join(".");
}
function syncJsonField(data, fieldPath, version, staleLabels) {
let cursor = data;
for (const key of fieldPath.slice(0, -1)) {
if (!cursor || typeof cursor !== "object" || Array.isArray(cursor) || !(key in cursor)) {
throw new Error(`missing JSON path: ${formatJsonPath(fieldPath)}`);
}
cursor = cursor[key];
}
const key = fieldPath[fieldPath.length - 1];
if (!cursor || typeof cursor !== "object" || Array.isArray(cursor) || !(key in cursor)) {
throw new Error(`missing JSON path: ${formatJsonPath(fieldPath)}`);
}
if (cursor[key] !== version) {
cursor[key] = version;
staleLabels.push(formatJsonPath(fieldPath));
}
}
function syncPluginEntry(data, pluginName, version, staleLabels) {
if (!Array.isArray(data.plugins)) {
throw new Error("plugins must be an array");
}
const matches = data.plugins.filter((entry) => entry && typeof entry === "object" && entry.name === pluginName);
if (matches.length !== 1) {
throw new Error(`expected exactly one plugin entry named ${JSON.stringify(pluginName)}`);
}
if (matches[0].version !== version) {
matches[0].version = version;
staleLabels.push(`plugins[${pluginName}].version`);
}
}
function syncJsonTarget(target, version) {
const text = readOptionalText(target.path, target.required !== false);
if (text === null) {
return null;
}
const data = JSON.parse(text);
if (!data || typeof data !== "object" || Array.isArray(data)) {
throw new Error(`${target.path} must contain a JSON object`);
}
const staleLabels = [];
for (const field of target.fields) {
syncJsonField(data, field, version, staleLabels);
}
for (const pluginName of target.pluginEntries ?? []) {
syncPluginEntry(data, pluginName, version, staleLabels);
}
if (staleLabels.length === 0) {
return null;
}
return {
path: target.path,
labels: staleLabels,
text: `${JSON.stringify(data, null, 2)}\n`,
};
}
function syncTomlTarget(relativePath, version) {
const text = readOptionalText(relativePath);
if (text === null) {
return null;
}
const matches = [...text.matchAll(/^(version\s*=\s*)"([^"]+)"/gm)];
if (matches.length !== 1) {
throw new Error(`${relativePath} must contain exactly one double-quoted version field`);
}
const match = matches[0];
if (match[2] === version) {
return null;
}
const start = match.index + match[1].length + 1;
const end = start + match[2].length;
return {
path: relativePath,
labels: ["version"],
text: `${text.slice(0, start)}${version}${text.slice(end)}`,
};
}
function main() {
const options = parseArgs(process.argv.slice(2));
const version = canonicalVersion();
const changes = [];
for (const target of jsonTargets) {
const change = syncJsonTarget(target, version);
if (change) {
changes.push(change);
}
}
for (const target of tomlTargets) {
const change = syncTomlTarget(target, version);
if (change) {
changes.push(change);
}
}
if (options.check) {
if (changes.length > 0) {
console.error(`Derived version metadata is stale for ${version}:`);
for (const change of changes) {
console.error(`- ${change.path} (${change.labels.join(", ")})`);
}
process.exit(1);
}
console.log(`Derived version metadata is synced from ${canonicalVersionPath}: ${version}`);
return;
}
for (const change of changes) {
writeFileSync(repoPath(change.path), change.text, "utf8");
}
if (changes.length === 0) {
console.log(`Derived version metadata already synced from ${canonicalVersionPath}: ${version}`);
return;
}
console.log(`Synced derived version metadata from ${canonicalVersionPath}: ${version}`);
for (const change of changes) {
console.log(`- ${change.path} (${change.labels.join(", ")})`);
}
}
try {
main();
} catch (error) {
console.error(`error: ${error.message}`);
process.exit(1);
}