Revert "fix(release): synchronize web changelog entries (#2088)" (#2113)

This reverts commit 7743cf280e.
This commit is contained in:
JATMN
2026-08-11 20:23:26 +08:00
committed by GitHub
parent 7b03ad19a4
commit 5f8e7d101b
8 changed files with 8 additions and 1365 deletions
+3 -690
View File
@@ -15,706 +15,19 @@ jobs:
name: Release Please
runs-on: ubuntu-latest
permissions:
contents: write # Create release commits/tags.
pull-requests: write # Keep the bot release PR draft before Release Please updates it.
contents: write
pull-requests: write
outputs:
release_created: ${{ steps.release.outputs.release_created }}
tag_name: ${{ steps.release.outputs.tag_name }}
version: ${{ steps.release.outputs.version }}
release_pr: ${{ steps.release.outputs.pr }}
steps:
# Release Please's draft option applies only on PR creation. Draft any
# existing pending bot PR before it updates the branch so an unvalidated
# head is not left ready for merge.
- name: Keep an existing Release Please PR draft before updating it
env:
GH_REPO: ${{ github.repository }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
RELEASE_BASE_BRANCH: ${{ github.ref_name }}
RELEASE_REPO_OWNER: ${{ github.repository_owner }}
run: |
set -euo pipefail
expected_prefix="release-please--branches--${RELEASE_BASE_BRANCH}--components--"
matches="$(gh api --paginate --method GET "repos/${GH_REPO}/pulls" \
-f state=open \
-f base="$RELEASE_BASE_BRANCH" \
-f per_page=100 \
| jq -s -c \
--arg prefix "$expected_prefix" \
--arg owner "$RELEASE_REPO_OWNER" \
--arg base "$RELEASE_BASE_BRANCH" '
[
add[]
| select(
.state == "open"
and (.user.login == "app/github-actions" or .user.login == "github-actions[bot]")
and .base.ref == $base
and (.head.ref | startswith($prefix))
and (.head.sha | length) > 0
and .head.repo.owner.login == $owner
and .head.repo.full_name == .base.repo.full_name
)
| {
number,
state: (.state | ascii_upcase),
isDraft: .draft,
author: { login: .user.login },
baseRefName: .base.ref,
headRefName: .head.ref,
headRefOid: .head.sha,
headRepositoryOwner: { login: .head.repo.owner.login },
isCrossRepository: false
}
]
')"
match_count="$(jq length <<<"$matches")"
if [ "$match_count" -eq 0 ]; then
exit 0
fi
if [ "$match_count" -ne 1 ]; then
echo "expected at most one pending Release Please bot PR for $RELEASE_BASE_BRANCH, found $match_count" >&2
jq . <<<"$matches" >&2
exit 1
fi
number="$(jq -r '.[0].number' <<<"$matches")"
if [ "$(jq -r '.[0].isDraft' <<<"$matches")" = "false" ]; then
gh pr ready "$number" --undo
fi
if [ "$(gh pr view "$number" --json isDraft --jq .isDraft)" != "true" ]; then
echo "failed to keep pending Release Please PR #$number in draft state" >&2
exit 1
fi
- name: Run release-please
id: release
uses: googleapis/release-please-action@45996ed1f6d02564a971a2fa1b5860e934307cf7 # v5.0.0
with:
token: ${{ secrets.GITHUB_TOKEN }}
# This must be separate from release-please: after a tag/release exists, a
# transient PR API failure must not suppress npm or Docker publishing.
prepare-web-release:
name: Prepare Release Please PR for web sync
needs: release-please
if: ${{ github.repository == 'Gitlawb/openclaude' && needs.release-please.result == 'success' }}
runs-on: ubuntu-latest
permissions:
pull-requests: write # Re-draft the bot release PR after Release Please updates it.
steps:
# release-please can leave an existing PR non-draft after updating it.
- name: Keep Release Please PR draft after updates
env:
GH_REPO: ${{ github.repository }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
RELEASE_BASE_BRANCH: ${{ github.ref_name }}
RELEASE_REPO_OWNER: ${{ github.repository_owner }}
RELEASE_PR_JSON: ${{ needs.release-please.outputs.release_pr }}
run: |
set -euo pipefail
expected_prefix="release-please--branches--${RELEASE_BASE_BRANCH}--components--"
release_pr=""
if [ -n "${RELEASE_PR_JSON:-}" ] && [ "$RELEASE_PR_JSON" != "null" ]; then
number="$(jq -r '.number // empty' <<<"$RELEASE_PR_JSON")"
if [ -n "$number" ]; then
candidate="$(gh pr view "$number" --json number,state,isDraft,author,baseRefName,headRefName,headRefOid,headRepositoryOwner,isCrossRepository)"
if [ "$(jq -r .state <<<"$candidate")" = "OPEN" ] \
&& { [ "$(jq -r .author.login <<<"$candidate")" = "app/github-actions" ] \
|| [ "$(jq -r .author.login <<<"$candidate")" = "github-actions[bot]" ]; } \
&& [ "$(jq -r .baseRefName <<<"$candidate")" = "$RELEASE_BASE_BRANCH" ] \
&& [[ "$(jq -r .headRefName <<<"$candidate")" == "$expected_prefix"* ]] \
&& [ "$(jq -r .headRepositoryOwner.login <<<"$candidate")" = "$RELEASE_REPO_OWNER" ] \
&& [ "$(jq -r .isCrossRepository <<<"$candidate")" = "false" ]; then
release_pr="$candidate"
fi
fi
fi
if [ -z "$release_pr" ]; then
matches="$(gh api --paginate --method GET "repos/${GH_REPO}/pulls" \
-f state=open \
-f base="$RELEASE_BASE_BRANCH" \
-f per_page=100 \
| jq -s -c \
--arg prefix "$expected_prefix" \
--arg owner "$RELEASE_REPO_OWNER" \
--arg base "$RELEASE_BASE_BRANCH" '
[
add[]
| select(
.state == "open"
and (.user.login == "app/github-actions" or .user.login == "github-actions[bot]")
and .base.ref == $base
and (.head.ref | startswith($prefix))
and (.head.sha | length) > 0
and .head.repo.owner.login == $owner
and .head.repo.full_name == .base.repo.full_name
)
| {
number,
state: (.state | ascii_upcase),
isDraft: .draft,
author: { login: .user.login },
baseRefName: .base.ref,
headRefName: .head.ref,
headRefOid: .head.sha,
headRepositoryOwner: { login: .head.repo.owner.login },
isCrossRepository: false
}
]
')"
if [ "$(jq length <<<"$matches")" -eq 0 ]; then
exit 0
fi
if [ "$(jq length <<<"$matches")" -ne 1 ]; then
echo "expected at most one pending Release Please bot PR after release-please" >&2
jq . <<<"$matches" >&2
exit 1
fi
release_pr="$(jq -c '.[0]' <<<"$matches")"
fi
number="$(jq -r .number <<<"$release_pr")"
if [ "$(jq -r .isDraft <<<"$release_pr")" = "false" ]; then
gh pr ready "$number" --undo
fi
if [ "$(gh pr view "$number" --json isDraft --jq .isDraft)" != "true" ]; then
echo "failed to keep pending Release Please PR #$number draft after release-please" >&2
exit 1
fi
# Validate the pending Release Please web entry with read-only credentials.
# A separate write job applies only the validated releases.ts artifact so
# PR-head scripts never run with contents/pull-requests write access.
# Sync failure must not block npm/docker after a release/tag is created.
validate-web-release:
name: Validate web release entry
needs: [release-please, prepare-web-release]
if: ${{ github.repository == 'Gitlawb/openclaude' && needs.release-please.result == 'success' && needs.prepare-web-release.result == 'success' }}
runs-on: ubuntu-latest
concurrency:
group: sync-web-release-${{ github.ref }}
cancel-in-progress: false
permissions:
contents: read # Check out trusted main and overlay pending release inputs only.
pull-requests: read # Resolve the pending bot release PR identity.
outputs:
found: ${{ steps.release-pr.outputs.found }}
number: ${{ steps.release-pr.outputs.number }}
branch: ${{ steps.release-pr.outputs.branch }}
head_sha: ${{ steps.release-pr.outputs.head_sha }}
steps:
- name: Resolve pending Release Please PR
id: release-pr
env:
GH_REPO: ${{ github.repository }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
RELEASE_BASE_BRANCH: ${{ github.ref_name }}
RELEASE_PR_JSON: ${{ needs.release-please.outputs.release_pr }}
RELEASE_REPO_OWNER: ${{ github.repository_owner }}
run: |
set -euo pipefail
expected_prefix="release-please--branches--${RELEASE_BASE_BRANCH}--components--"
matches_bot_identity() {
local release_pr="$1"
local state author base branch head_sha head_owner cross_repo
state="$(jq -r .state <<<"$release_pr")"
author="$(jq -r .author.login <<<"$release_pr")"
base="$(jq -r .baseRefName <<<"$release_pr")"
branch="$(jq -r .headRefName <<<"$release_pr")"
head_sha="$(jq -r .headRefOid <<<"$release_pr")"
head_owner="$(jq -r .headRepositoryOwner.login <<<"$release_pr")"
cross_repo="$(jq -r .isCrossRepository <<<"$release_pr")"
[ "$state" = "OPEN" ] \
&& { [ "$author" = "app/github-actions" ] || [ "$author" = "github-actions[bot]" ]; } \
&& [ "$base" = "$RELEASE_BASE_BRANCH" ] \
&& [[ "$branch" == "$expected_prefix"* ]] \
&& [ -n "$head_sha" ] \
&& [ "$head_owner" = "$RELEASE_REPO_OWNER" ] \
&& [ "$cross_repo" = "false" ]
}
discover_bot_prs() {
# Discover by bot branch prefix, not only the label, so a stripped
# label cannot leave a ready bot PR unvalidated. Ignore mislabeled
# non-bot PRs instead of failing the release pipeline.
gh api --paginate --method GET "repos/${GH_REPO}/pulls" \
-f state=open \
-f base="$RELEASE_BASE_BRANCH" \
-f per_page=100 \
| jq -s -c \
--arg prefix "$expected_prefix" \
--arg owner "$RELEASE_REPO_OWNER" \
--arg base "$RELEASE_BASE_BRANCH" '
[
add[]
| select(
.state == "open"
and (.user.login == "app/github-actions" or .user.login == "github-actions[bot]")
and .base.ref == $base
and (.head.ref | startswith($prefix))
and (.head.sha | length) > 0
and .head.repo.owner.login == $owner
and .head.repo.full_name == .base.repo.full_name
)
| {
number,
state: (.state | ascii_upcase),
isDraft: .draft,
author: { login: .user.login },
baseRefName: .base.ref,
headRefName: .head.ref,
headRefOid: .head.sha,
headRepositoryOwner: { login: .head.repo.owner.login },
isCrossRepository: false
}
]
'
}
release_pr=""
if [ -n "${RELEASE_PR_JSON:-}" ] && [ "$RELEASE_PR_JSON" != "null" ]; then
number="$(jq -r '.number // empty' <<<"$RELEASE_PR_JSON")"
if [ -n "$number" ]; then
candidate="$(gh pr view "$number" --json number,state,isDraft,author,baseRefName,headRefName,headRefOid,headRepositoryOwner,isCrossRepository)"
if matches_bot_identity "$candidate"; then
release_pr="$candidate"
else
echo "release-please pr output #$number is not an open same-repo bot release PR; falling back to discovery" >&2
jq . <<<"$candidate" >&2
fi
fi
fi
if [ -z "$release_pr" ]; then
matches="$(discover_bot_prs)"
match_count="$(jq length <<<"$matches")"
if [ "$match_count" -eq 0 ]; then
# If something carries the pending label but is not a bot release
# PR, ignore it and stay green only when no bot release PR exists.
labeled="$(gh pr list --state open --base "$RELEASE_BASE_BRANCH" --label 'autorelease: pending' --limit 10 --json number,headRefName,author)"
if [ "$(jq length <<<"$labeled")" -gt 0 ]; then
echo "ignoring non-bot autorelease: pending PR(s); no matching Release Please bot PR found" >&2
jq . <<<"$labeled" >&2
fi
echo "found=false" >> "$GITHUB_OUTPUT"
exit 0
fi
if [ "$match_count" -ne 1 ]; then
echo "expected exactly one pending Release Please bot PR for $RELEASE_BASE_BRANCH, found $match_count" >&2
jq . <<<"$matches" >&2
exit 1
fi
release_pr="$(jq -c '.[0]' <<<"$matches")"
fi
number="$(jq -r .number <<<"$release_pr")"
branch="$(jq -r .headRefName <<<"$release_pr")"
head_sha="$(jq -r .headRefOid <<<"$release_pr")"
echo "found=true" >> "$GITHUB_OUTPUT"
echo "number=$number" >> "$GITHUB_OUTPUT"
echo "branch=$branch" >> "$GITHUB_OUTPUT"
echo "head_sha=$head_sha" >> "$GITHUB_OUTPUT"
- name: Check out trusted main tip
if: ${{ steps.release-pr.outputs.found == 'true' }}
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: ${{ github.sha }}
fetch-depth: 0
persist-credentials: false
- name: Set up Bun for release PR sync
if: ${{ steps.release-pr.outputs.found == 'true' }}
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with:
bun-version-file: .bun-version
no-cache: true
- name: Sync and validate web release entry
id: sync-web
if: ${{ steps.release-pr.outputs.found == 'true' }}
env:
RELEASE_BASE_BRANCH: ${{ github.ref_name }}
RELEASE_EVENT_SHA: ${{ github.sha }}
RELEASE_PR_BRANCH: ${{ steps.release-pr.outputs.branch }}
RELEASE_PR_HEAD_SHA: ${{ steps.release-pr.outputs.head_sha }}
RELEASE_PR_NUMBER: ${{ steps.release-pr.outputs.number }}
run: |
set -euo pipefail
# Run trusted main scripts against the pending bot PR's release inputs.
# Do not execute package.json/scripts from the bot PR head.
git fetch --no-tags origin "$RELEASE_BASE_BRANCH" "$RELEASE_PR_HEAD_SHA"
release_base_sha="$(git rev-parse "origin/$RELEASE_BASE_BRANCH")"
if [ "$(git rev-parse HEAD)" != "$RELEASE_EVENT_SHA" ] \
|| [ "$release_base_sha" != "$RELEASE_EVENT_SHA" ]; then
echo "main advanced during release synchronization; a newer workflow run must validate the new base" >&2
echo "event=$RELEASE_EVENT_SHA current=$release_base_sha" >&2
exit 1
fi
if [ "$(git merge-base "$release_base_sha" "$RELEASE_PR_HEAD_SHA")" != "$release_base_sha" ]; then
echo "Release Please PR is not based on the validated main commit" >&2
exit 1
fi
unexpected_release_files="$(git diff --name-only "$release_base_sha...$RELEASE_PR_HEAD_SHA" \
| grep -Fvx \
-e .release-please-manifest.json \
-e CHANGELOG.md \
-e package.json \
-e web/src/data/releases.ts \
|| true)"
if [ -n "$unexpected_release_files" ]; then
echo "Release Please PR changed files outside its release-input contract" >&2
printf '%s\n' "$unexpected_release_files" >&2
exit 1
fi
for required_release_file in .release-please-manifest.json CHANGELOG.md package.json; do
if git diff --quiet "$release_base_sha...$RELEASE_PR_HEAD_SHA" -- "$required_release_file"; then
echo "Release Please PR is missing required release input $required_release_file" >&2
exit 1
fi
done
for release_file in .release-please-manifest.json CHANGELOG.md package.json web/src/data/releases.ts; do
tree_entry="$(git ls-tree "$RELEASE_PR_HEAD_SHA" -- "$release_file")"
if [[ "$tree_entry" != "100644 blob "*$'\t'"$release_file" ]]; then
echo "Release Please file must remain a regular non-executable blob: $release_file" >&2
exit 1
fi
done
mkdir -p .release-sync-input
git show "${RELEASE_PR_HEAD_SHA}:package.json" > .release-sync-input/package.json
git show "${RELEASE_PR_HEAD_SHA}:CHANGELOG.md" > CHANGELOG.md
git show "${RELEASE_PR_HEAD_SHA}:.release-please-manifest.json" > .release-please-manifest.json
git show "${RELEASE_PR_HEAD_SHA}:web/src/data/releases.ts" > .release-sync-input/releases.ts
if ! diff -u \
<(jq -S 'del(.version)' package.json) \
<(jq -S 'del(.version)' .release-sync-input/package.json); then
echo "Release Please PR changes package.json fields other than version" >&2
exit 1
fi
if ! diff -u \
<(jq -S 'del(.["."])' <(git show HEAD:.release-please-manifest.json)) \
<(jq -S 'del(.["."])' .release-please-manifest.json); then
echo "Release Please PR changes manifest fields other than the root version" >&2
exit 1
fi
package_version="$(jq -r .version .release-sync-input/package.json)"
manifest_version="$(jq -r '.["."]' .release-please-manifest.json)"
base_package_version="$(jq -r .version package.json)"
base_manifest_version="$(git show HEAD:.release-please-manifest.json | jq -r '.["."]')"
if [ "$package_version" != "$manifest_version" ] \
|| [ "$base_package_version" != "$base_manifest_version" ]; then
echo "package and Release Please manifest versions do not match" >&2
exit 1
fi
bun install --frozen-lockfile
BASE_VERSION="$base_package_version" NEXT_VERSION="$package_version" bun -e '
import { assertVersionAdvances } from "./scripts/sync-web-release-entry"
assertVersionAdvances(process.env.BASE_VERSION!, process.env.NEXT_VERSION!)
'
if ! git diff --quiet "$release_base_sha...$RELEASE_PR_HEAD_SHA" -- web/src/data/releases.ts; then
if ! bun -e '
import { hasGeneratedTopEntry } from "./scripts/sync-web-release-entry"
const releases = await Bun.file(".release-sync-input/releases.ts").text()
if (!hasGeneratedTopEntry(releases)) process.exit(1)
'; then
echo "Release Please PR has an unmarked releases.ts edit; refusing to overwrite it" >&2
exit 1
fi
fi
bun run sync:web-release -- --base-ref HEAD
# Restore overlaid inputs so the cleanliness gate only sees releases.ts.
git checkout -- CHANGELOG.md .release-please-manifest.json
bun test ./scripts/sync-web-release-entry.test.ts
if ! git diff --quiet -- web/src/data/releases.ts; then
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git add web/src/data/releases.ts
git commit -m "chore(web): sync releases.ts from release-please changelog"
fi
# GITHUB_TOKEN pushes do not start ordinary PR checks, so run the
# repo's standard gates against the synchronized commit before ready.
bun run typecheck
bun run typecheck:type-tests
bun run security:pr-scan -- --base "$release_base_sha" --head HEAD
git diff --check "$release_base_sha"...HEAD
bun install --cwd web --frozen-lockfile
bun run web:typecheck
bun run web:build
mkdir -p release-sync-artifact
cp web/src/data/releases.ts release-sync-artifact/releases.ts
releases_sha256="$(sha256sum web/src/data/releases.ts | awk '{print $1}')"
jq -n \
--arg number "$RELEASE_PR_NUMBER" \
--arg branch "$RELEASE_PR_BRANCH" \
--arg base_sha "$release_base_sha" \
--arg head_sha "$RELEASE_PR_HEAD_SHA" \
--arg releases_sha256 "$releases_sha256" \
'{ number: $number, branch: $branch, base_sha: $base_sha, head_sha: $head_sha, releases_sha256: $releases_sha256 }' \
> release-sync-artifact/metadata.json
if ! git diff --quiet || ! git diff --cached --quiet; then
echo "sync validation left uncommitted tracked changes" >&2
git status --short
exit 1
fi
- name: Upload validated releases.ts artifact
if: ${{ steps.release-pr.outputs.found == 'true' }}
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
# Keep the artifact addressable when only the failed push job is
# retried: run_attempt changes on a retry, while this validated head
# remains the push job's immutable input.
name: web-release-sync-${{ github.run_id }}-${{ steps.release-pr.outputs.head_sha }}
path: release-sync-artifact/
if-no-files-found: error
# A full validation rerun for the same head replaces its prior artifact.
overwrite: true
retention-days: 1
push-web-release:
name: Push web release entry
needs: validate-web-release
if: ${{ github.repository == 'Gitlawb/openclaude' && needs.validate-web-release.outputs.found == 'true' }}
runs-on: ubuntu-latest
concurrency:
group: sync-web-release-${{ github.ref }}
cancel-in-progress: false
permissions:
contents: write # Push only the validated releases.ts commit to the bot branch.
pull-requests: write # Keep the bot PR draft until push lands, then mark ready.
steps:
- name: Check out Release Please PR head
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: ${{ needs.validate-web-release.outputs.head_sha }}
fetch-depth: 0
persist-credentials: false
- name: Download validated releases.ts artifact
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
with:
name: web-release-sync-${{ github.run_id }}-${{ needs.validate-web-release.outputs.head_sha }}
path: release-sync-artifact
- name: Apply validated releases.ts and push
id: push-sync
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GH_REPO: ${{ github.repository }}
RELEASE_PR_BRANCH: ${{ needs.validate-web-release.outputs.branch }}
RELEASE_PR_NUMBER: ${{ needs.validate-web-release.outputs.number }}
RELEASE_PR_HEAD_SHA: ${{ needs.validate-web-release.outputs.head_sha }}
RELEASE_BASE_BRANCH: ${{ github.ref_name }}
run: |
set -euo pipefail
metadata_path=release-sync-artifact/metadata.json
if ! jq -e '
type == "object"
and (.number | type == "string" and length > 0)
and (.branch | type == "string" and length > 0)
and (.base_sha | type == "string" and test("^[0-9a-f]{40}$"))
and (.head_sha | type == "string" and test("^[0-9a-f]{40}$"))
and (.releases_sha256 | type == "string" and test("^[0-9a-f]{64}$"))
' "$metadata_path" >/dev/null; then
echo "validated artifact metadata is malformed" >&2
exit 1
fi
number="$(jq -r .number "$metadata_path")"
branch="$(jq -r .branch "$metadata_path")"
base_sha="$(jq -r .base_sha "$metadata_path")"
head_sha="$(jq -r .head_sha "$metadata_path")"
releases_sha256="$(jq -r .releases_sha256 "$metadata_path")"
if [ "${number}" != "$RELEASE_PR_NUMBER" ] \
|| [ "${branch}" != "$RELEASE_PR_BRANCH" ] \
|| [ "${head_sha}" != "$RELEASE_PR_HEAD_SHA" ]; then
echo "validated artifact metadata does not match the resolved release PR" >&2
jq . "$metadata_path" >&2
exit 1
fi
expected_sha256="$releases_sha256"
actual_sha256="$(sha256sum release-sync-artifact/releases.ts | awk '{print $1}')"
if [ "$actual_sha256" != "$expected_sha256" ]; then
echo "validated releases.ts digest mismatch" >&2
exit 1
fi
git fetch --no-tags origin "$RELEASE_BASE_BRANCH"
current_base_sha="$(git rev-parse "origin/$RELEASE_BASE_BRANCH")"
if [ "$current_base_sha" != "$base_sha" ]; then
echo "main advanced after web-release validation; refusing to push a stale artifact" >&2
echo "validated=$base_sha current=$current_base_sha" >&2
exit 1
fi
current="$(gh pr view "$RELEASE_PR_NUMBER" --json state,isDraft,baseRefName,headRefName,headRefOid,author,headRepositoryOwner,isCrossRepository)"
expected_prefix="release-please--branches--${RELEASE_BASE_BRANCH}--components--"
if [ "$(jq -r .state <<<"$current")" != "OPEN" ] \
|| { [ "$(jq -r .author.login <<<"$current")" != "app/github-actions" ] \
&& [ "$(jq -r .author.login <<<"$current")" != "github-actions[bot]" ]; } \
|| [ "$(jq -r .baseRefName <<<"$current")" != "$RELEASE_BASE_BRANCH" ] \
|| [ "$(jq -r .headRefName <<<"$current")" != "$RELEASE_PR_BRANCH" ] \
|| [[ "$(jq -r .headRefName <<<"$current")" != "$expected_prefix"* ]] \
|| [ "$(jq -r .headRepositoryOwner.login <<<"$current")" != "${GITHUB_REPOSITORY_OWNER}" ] \
|| [ "$(jq -r .isCrossRepository <<<"$current")" != "false" ]; then
echo "release PR changed or closed during sync" >&2
jq . <<<"$current" >&2
exit 1
fi
if [ "$(jq -r .isDraft <<<"$current")" != "true" ]; then
gh pr ready "$RELEASE_PR_NUMBER" --undo
if [ "$(gh pr view "$RELEASE_PR_NUMBER" --json isDraft --jq .isDraft)" != "true" ]; then
echo "failed to keep release PR #$RELEASE_PR_NUMBER draft before push" >&2
exit 1
fi
fi
current_sha="$(jq -r .headRefOid <<<"$current")"
validated_release_blob="$(git hash-object release-sync-artifact/releases.ts)"
already_synchronized=false
if [ "$current_sha" != "$RELEASE_PR_HEAD_SHA" ]; then
git fetch --no-tags origin "$current_sha"
if ! git diff --quiet "$RELEASE_PR_HEAD_SHA" "$current_sha" -- . ':(exclude)web/src/data/releases.ts'; then
echo "release PR head changed outside the validated release artifact" >&2
jq . <<<"$current" >&2
exit 1
fi
remote_release_entry="$(git ls-tree "$current_sha" -- web/src/data/releases.ts)"
expected_release_entry="100644 blob ${validated_release_blob}"$'\t''web/src/data/releases.ts'
if [ "$remote_release_entry" != "$expected_release_entry" ]; then
echo "release PR head changed to an unvalidated release entry or file mode" >&2
jq . <<<"$current" >&2
exit 1
fi
already_synchronized=true
fi
if [ "$already_synchronized" = "true" ]; then
# The captured validation head is stale after a concurrent push.
# Adopt the exact verified remote commit before checking cleanliness.
git switch --detach "$current_sha"
echo "release PR already has the validated releases.ts artifact"
else
# Apply only the validated file — do not execute PR-head scripts here.
cp release-sync-artifact/releases.ts web/src/data/releases.ts
applied_sha256="$(sha256sum web/src/data/releases.ts | awk '{print $1}')"
if [ "$applied_sha256" != "$expected_sha256" ]; then
echo "failed to apply validated releases.ts" >&2
exit 1
fi
if ! git diff --quiet -- web/src/data/releases.ts; then
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git add web/src/data/releases.ts
git commit -m "chore(web): sync releases.ts from release-please changelog"
else
echo "release PR already has the validated releases.ts artifact"
fi
fi
if ! git diff --quiet || ! git diff --cached --quiet; then
echo "push preparation left uncommitted tracked changes" >&2
git status --short
exit 1
fi
local_sha="$(git rev-parse HEAD)"
if [ "$already_synchronized" = "true" ]; then
local_sha="$current_sha"
elif [ "$current_sha" = "$RELEASE_PR_HEAD_SHA" ]; then
auth_header="$(printf 'x-access-token:%s' "$GITHUB_TOKEN" | base64 -w0)"
if ! GIT_CONFIG_COUNT=1 \
GIT_CONFIG_KEY_0=http.https://github.com/.extraheader \
GIT_CONFIG_VALUE_0="AUTHORIZATION: basic ${auth_header}" \
git push \
--force-with-lease="refs/heads/${RELEASE_PR_BRANCH}:${RELEASE_PR_HEAD_SHA}" \
"https://github.com/${GITHUB_REPOSITORY}.git" \
"HEAD:${RELEASE_PR_BRANCH}"; then
# A concurrent sync may have already pushed the same commit.
remote_sha="$(gh pr view "$RELEASE_PR_NUMBER" --json headRefOid --jq .headRefOid)"
if [ "$remote_sha" = "$local_sha" ]; then
echo "release PR already points at the synchronized commit after lease race"
else
echo "release PR head moved during push to an unexpected commit" >&2
echo "remote=$remote_sha local=$local_sha" >&2
exit 1
fi
fi
elif [ "$current_sha" = "$local_sha" ]; then
echo "release PR already points at the synchronized commit"
else
echo "release PR head changed to an unvalidated commit" >&2
jq . <<<"$current" >&2
exit 1
fi
echo "head_sha=$local_sha" >> "$GITHUB_OUTPUT"
echo "base_sha=$base_sha" >> "$GITHUB_OUTPUT"
- name: Mark synchronized release PR ready for review
if: ${{ steps.push-sync.outcome == 'success' }}
env:
GH_REPO: ${{ github.repository }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
RELEASE_PR_NUMBER: ${{ needs.validate-web-release.outputs.number }}
RELEASE_PR_BRANCH: ${{ needs.validate-web-release.outputs.branch }}
RELEASE_BASE_BRANCH: ${{ github.ref_name }}
VALIDATED_BASE_SHA: ${{ steps.push-sync.outputs.base_sha }}
VALIDATED_HEAD_SHA: ${{ steps.push-sync.outputs.head_sha }}
run: |
set -euo pipefail
current_base_sha="$(gh api "repos/${GITHUB_REPOSITORY}/git/ref/heads/${RELEASE_BASE_BRANCH}" --jq .object.sha)"
current="$(gh pr view "$RELEASE_PR_NUMBER" --json state,isDraft,baseRefName,headRefName,headRefOid)"
if [ "$current_base_sha" != "$VALIDATED_BASE_SHA" ] \
|| [ "$(jq -r .state <<<"$current")" != "OPEN" ] \
|| [ "$(jq -r .baseRefName <<<"$current")" != "$RELEASE_BASE_BRANCH" ] \
|| [ "$(jq -r .headRefName <<<"$current")" != "$RELEASE_PR_BRANCH" ] \
|| [ "$(jq -r .headRefOid <<<"$current")" != "$VALIDATED_HEAD_SHA" ]; then
echo "release PR no longer points at the synchronized commit" >&2
jq . <<<"$current" >&2
if [ "$(jq -r .state <<<"$current")" = "OPEN" ] && [ "$(jq -r .isDraft <<<"$current")" = "false" ]; then
gh pr ready "$RELEASE_PR_NUMBER" --undo
fi
exit 1
fi
if [ "$(jq -r .isDraft <<<"$current")" = "true" ]; then
gh pr ready "$RELEASE_PR_NUMBER"
fi
final_base_sha="$(gh api "repos/${GITHUB_REPOSITORY}/git/ref/heads/${RELEASE_BASE_BRANCH}" --jq .object.sha)"
final="$(gh pr view "$RELEASE_PR_NUMBER" --json state,isDraft,baseRefName,headRefName,headRefOid)"
if [ "$final_base_sha" != "$VALIDATED_BASE_SHA" ] \
|| [ "$(jq -r .state <<<"$final")" != "OPEN" ] \
|| [ "$(jq -r .isDraft <<<"$final")" != "false" ] \
|| [ "$(jq -r .baseRefName <<<"$final")" != "$RELEASE_BASE_BRANCH" ] \
|| [ "$(jq -r .headRefName <<<"$final")" != "$RELEASE_PR_BRANCH" ] \
|| [ "$(jq -r .headRefOid <<<"$final")" != "$VALIDATED_HEAD_SHA" ]; then
echo "release PR changed while it was being marked ready" >&2
jq . <<<"$final" >&2
if [ "$(jq -r .state <<<"$final")" = "OPEN" ] && [ "$(jq -r .isDraft <<<"$final")" = "false" ]; then
gh pr ready "$RELEASE_PR_NUMBER" --undo
fi
exit 1
fi
release-type: node
# Zero-warning install gate: pack the release tag and install it globally in
# a sandbox (cold + upgrade scenarios) on the supported Node floor (22 →
-21
View File
@@ -138,27 +138,6 @@ Dependency changes need a clear project benefit — fixing a bug, addressing a s
AI-assisted and vibe-coded contributions are welcome, but please review your own changes thoroughly before opening a PR. Even frontier models produce subtle bugs, incorrect assumptions, and code that looks right but isn't.
### Release web synchronization
Release automation regenerates `web/src/data/releases.ts` on the pending Release Please PR while that PR is kept draft, then pushes the synced file and marks the PR ready. Web sync validates with trusted `main` scripts (overlaying only the pending changelog/manifest) in a read-only job, then a separate write-only job applies the verified `releases.ts` artifact so PR-head scripts never run with write credentials. Sync failure cannot block npm or Docker publishing for an already-created release. Automation owns the top generated entry (marked in `releases.ts`); do not hand-edit that file on the bot release branch. To recover the entry on an explicit release/web PR, check out that PR, restore the trusted merge-base copy, then sync and validate:
```bash
base_ref="$(git merge-base origin/main HEAD)"
git show "${base_ref}:web/src/data/releases.ts" > web/src/data/releases.ts
bun install --frozen-lockfile
bun run sync:web-release -- --base-ref "$base_ref"
bun test ./scripts/sync-web-release-entry.test.ts
bun run typecheck
bun run typecheck:type-tests
bun run security:pr-scan -- --base origin/main --head HEAD
git diff --check origin/main...HEAD
bun install --cwd web --frozen-lockfile
bun run web:typecheck
bun run web:build
```
`bun run sync:web-release` writes `web/src/data/releases.ts` from the pending changelog section and manifest version. Commit the generated file and confirm those checks leave tracked files unchanged.
Before submitting, run multiple rounds of review on generated code:
- check for correctness, not just whether it compiles
-1
View File
@@ -54,7 +54,6 @@
"web:build": "bun run --cwd web build",
"web:preview": "bun run --cwd web preview",
"web:typecheck": "bun run --cwd web typecheck",
"sync:web-release": "bun scripts/sync-web-release-entry.ts --write",
"test": "bun test --feature=UNATTENDED_RETRY --max-concurrency=1",
"test:full": "bun test --feature=UNATTENDED_RETRY --max-concurrency=1",
"test:coverage": "bun test --feature=UNATTENDED_RETRY --coverage --coverage-reporter=lcov --coverage-dir=coverage --max-concurrency=1 && bun run scripts/render-coverage-heatmap.ts",
-1
View File
@@ -5,7 +5,6 @@
"release-type": "node",
"package-name": "@gitlawb/openclaude",
"bump-minor-pre-major": true,
"draft-pull-request": true,
"include-v-in-tag": true
}
}
-340
View File
@@ -1,340 +0,0 @@
import { describe, expect, test } from 'bun:test'
import {
assertVersionAdvances,
compareSemVer,
deriveTheme,
formatReleaseEntry,
GENERATED_ENTRY_MARKER,
insertReleaseEntry,
parseChangelogSection,
readCurrentTopVersion,
sanitizeChangelogBullet,
syncWebReleaseEntry,
} from './sync-web-release-entry'
const SAMPLE_CHANGELOG = `# Changelog
## [0.28.0](https://github.com/Gitlawb/openclaude/compare/v0.27.0...v0.28.0) (2026-08-10)
### Features
* **auth:** opt-in loopback proxy hosts ([#2050](https://github.com/Gitlawb/openclaude/issues/2050)) ([3925f27](https://github.com/Gitlawb/openclaude/commit/3925f27))
* **web:** replace favicon/logo with Ember Block O brand mark ([#2065](https://github.com/Gitlawb/openclaude/issues/2065)) ([56a9201](https://github.com/Gitlawb/openclaude/commit/56a9201))
* third highlight
* fourth highlight
* fifth highlight
* sixth highlight
`
const SAMPLE_RELEASES_TS = `export const releases: Release[] = [
{
version: '0.27.0',
date: '2026-07-30',
theme: 'existing',
highlights: ['one'],
},
]
`
describe('release version ordering', () => {
test('orders numeric prerelease identifiers numerically', () => {
expect(compareSemVer('1.0.0-rc.10', '1.0.0-rc.2')).toBeGreaterThan(0)
expect(compareSemVer('1.0.0-rc.2', '1.0.0-rc.10')).toBeLessThan(0)
})
test('implements SemVer prerelease precedence', () => {
expect(compareSemVer('1.0.0-alpha.1', '1.0.0-alpha.beta')).toBeLessThan(0)
expect(compareSemVer('1.0.0-alpha.beta', '1.0.0-beta')).toBeLessThan(0)
expect(compareSemVer('1.0.0-beta.11', '1.0.0-rc.1')).toBeLessThan(0)
expect(compareSemVer('1.0.0', '1.0.0-rc.1')).toBeGreaterThan(0)
expect(compareSemVer('1.0.0+build.2', '1.0.0+build.1')).toBe(0)
})
test('accepts only a valid advancing release version', () => {
expect(() => assertVersionAdvances('1.0.0-rc.2', '1.0.0-rc.10')).not.toThrow()
expect(() => assertVersionAdvances('1.0.0', '1.0.0')).toThrow(
'release version must advance: 1.0.0 -> 1.0.0',
)
expect(() => assertVersionAdvances('1.0.0', 'not-semver')).toThrow(
'invalid SemVer: not-semver',
)
})
})
describe('syncWebReleaseEntry', () => {
test('inserts a bounded, sanitized draft entry from the requested changelog section', () => {
const result = syncWebReleaseEntry({
changelog: SAMPLE_CHANGELOG,
releasesTs: SAMPLE_RELEASES_TS,
baseReleasesTs: SAMPLE_RELEASES_TS,
manifestVersion: '0.28.0',
})
expect(result.status).toBe('updated')
if (result.status !== 'updated') return
expect(readCurrentTopVersion(result.content)).toBe('0.28.0')
expect(result.content).toContain("version: '0.27.0'")
expect(result.content).toContain('auth: opt-in loopback proxy hosts')
expect(result.content).toContain('fifth highlight')
expect(result.content).not.toContain('sixth highlight')
expect(result.content).toContain(GENERATED_ENTRY_MARKER)
})
test('replaces an already-generated release PR entry when its version changes', () => {
const generated = insertReleaseEntry(SAMPLE_RELEASES_TS, {
version: '0.28.0',
date: '2026-08-09',
theme: 'old theme',
highlights: ['old highlight'],
})
const result = syncWebReleaseEntry({
changelog: SAMPLE_CHANGELOG.replaceAll('0.28.0', '0.29.0'),
releasesTs: generated,
baseReleasesTs: SAMPLE_RELEASES_TS,
manifestVersion: '0.29.0',
})
expect(result.status).toBe('updated')
if (result.status !== 'updated') return
expect(result.content).toContain('version: "0.29.0"')
expect(result.content).not.toContain('version: "0.28.0"')
expect(result.content.match(new RegExp(GENERATED_ENTRY_MARKER.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g'))).toHaveLength(1)
})
test('preserves a published entry when creating the next release', () => {
const published = insertReleaseEntry(SAMPLE_RELEASES_TS, {
version: '0.28.0',
date: '2026-08-10',
theme: 'ready',
highlights: ['ready'],
})
const result = syncWebReleaseEntry({
changelog: SAMPLE_CHANGELOG.replaceAll('0.28.0', '0.29.0'),
releasesTs: published,
baseReleasesTs: published,
manifestVersion: '0.29.0',
})
expect(result.status).toBe('updated')
if (result.status !== 'updated') return
expect(result.content).toContain('version: "0.29.0"')
expect(result.content).toContain('version: "0.28.0"')
expect(result.content.match(new RegExp(GENERATED_ENTRY_MARKER.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g'))).toHaveLength(1)
})
test('strips the leftover automation marker across consecutive restore-from-base syncs', () => {
const first = syncWebReleaseEntry({
changelog: SAMPLE_CHANGELOG,
releasesTs: SAMPLE_RELEASES_TS,
baseReleasesTs: SAMPLE_RELEASES_TS,
manifestVersion: '0.28.0',
})
expect(first.status).toBe('updated')
if (first.status !== 'updated') return
// CI restores releases.ts from merge-base after the previous release merged,
// so the working tree matches the marked published entry on main.
const second = syncWebReleaseEntry({
changelog: SAMPLE_CHANGELOG.replaceAll('0.28.0', '0.29.0'),
releasesTs: first.content,
baseReleasesTs: first.content,
manifestVersion: '0.29.0',
})
expect(second.status).toBe('updated')
if (second.status !== 'updated') return
expect(readCurrentTopVersion(second.content)).toBe('0.29.0')
expect(second.content).toContain('version: "0.28.0"')
expect(second.content).toContain("version: '0.27.0'")
expect(second.content.match(new RegExp(GENERATED_ENTRY_MARKER.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g'))).toHaveLength(1)
})
test('refreshes a generated same-version entry when changelog bullets change', () => {
const generated = insertReleaseEntry(SAMPLE_RELEASES_TS, {
version: '0.28.0',
date: '2026-08-09',
theme: 'stale',
highlights: ['stale highlight'],
})
const result = syncWebReleaseEntry({
changelog: SAMPLE_CHANGELOG,
releasesTs: generated,
baseReleasesTs: generated,
manifestVersion: '0.28.0',
})
expect(result.status).toBe('updated')
if (result.status !== 'updated') return
expect(result.content).toContain('auth: opt-in loopback proxy hosts')
expect(result.content).not.toContain('stale highlight')
expect(result.content.match(new RegExp(GENERATED_ENTRY_MARKER.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g'))).toHaveLength(1)
})
test('replaces an updated entry for the same release version', () => {
const generated = insertReleaseEntry(SAMPLE_RELEASES_TS, {
version: '0.29.0',
date: '2026-08-17',
theme: 'draft',
highlights: ['draft'],
})
const result = syncWebReleaseEntry({
changelog: SAMPLE_CHANGELOG.replaceAll('0.28.0', '0.29.0').replace('opt-in loopback proxy hosts', 'updated'),
releasesTs: generated,
baseReleasesTs: SAMPLE_RELEASES_TS,
manifestVersion: '0.29.0',
})
expect(result.status).toBe('updated')
if (result.status !== 'updated') return
expect(result.content).toContain('theme: "updated"')
expect(result.content.match(/version: "0.29.0"/g)).toHaveLength(1)
})
test('replaces a generated entry in a CRLF checkout and preserves its line endings', () => {
const generated = insertReleaseEntry(SAMPLE_RELEASES_TS, {
version: '0.28.0',
date: '2026-08-10',
theme: 'draft',
highlights: ['draft'],
}).replaceAll('\n', '\r\n')
const result = syncWebReleaseEntry({
changelog: SAMPLE_CHANGELOG.replaceAll('0.28.0', '0.29.0'),
releasesTs: generated,
baseReleasesTs: SAMPLE_RELEASES_TS,
manifestVersion: '0.29.0',
})
expect(result.status).toBe('updated')
if (result.status !== 'updated') return
expect(result.content).toContain('version: "0.29.0"')
expect(result.content.replaceAll('\r\n', '')).not.toContain('\n')
})
test('refuses unmarked divergence for the target version', () => {
const curated = insertReleaseEntry(SAMPLE_RELEASES_TS, {
version: '0.28.0',
date: '2026-08-10',
theme: 'curated theme',
highlights: ['curated highlight'],
}).replace(`${GENERATED_ENTRY_MARKER}\n`, '')
expect(() => syncWebReleaseEntry({
changelog: SAMPLE_CHANGELOG,
releasesTs: curated,
baseReleasesTs: SAMPLE_RELEASES_TS,
manifestVersion: '0.28.0',
})).toThrow('differs from the base without the generated-entry marker')
})
test('still refreshes entries that use the legacy generated-entry marker', () => {
const legacyMarker =
' // Generated by release automation; remove this comment before hand-curating.'
const existing = insertReleaseEntry(SAMPLE_RELEASES_TS, {
version: '0.28.0',
date: '2026-08-10',
theme: 'old theme',
highlights: ['old highlight'],
}).replace(GENERATED_ENTRY_MARKER, legacyMarker)
const result = syncWebReleaseEntry({
changelog: SAMPLE_CHANGELOG.replace('third highlight', 'updated third highlight'),
releasesTs: existing,
baseReleasesTs: SAMPLE_RELEASES_TS,
manifestVersion: '0.28.0',
})
expect(result.status).toBe('updated')
if (result.status !== 'updated') return
expect(result.content).toContain(GENERATED_ENTRY_MARKER)
expect(result.content).not.toContain(legacyMarker)
expect(result.content).toContain('updated third highlight')
})
test('refuses to overwrite an unmarked divergent pending entry for another version', () => {
const curated = insertReleaseEntry(SAMPLE_RELEASES_TS, {
version: '0.28.0',
date: '2026-08-10',
theme: 'curated theme',
highlights: ['curated highlight'],
}).replace(`${GENERATED_ENTRY_MARKER}\n`, '')
expect(() => syncWebReleaseEntry({
changelog: SAMPLE_CHANGELOG.replaceAll('0.28.0', '0.29.0'),
releasesTs: curated,
baseReleasesTs: SAMPLE_RELEASES_TS,
manifestVersion: '0.29.0',
})).toThrow('differs from the base without the generated-entry marker')
})
test('is a no-op when the top entry already matches without a draft marker', () => {
const releasesTs = SAMPLE_RELEASES_TS.replace("version: '0.27.0'", "version: '0.28.0'")
expect(
syncWebReleaseEntry({
changelog: SAMPLE_CHANGELOG,
releasesTs,
baseReleasesTs: releasesTs,
manifestVersion: '0.28.0',
}),
).toEqual({
status: 'unchanged',
version: '0.28.0',
reason: 'releases.ts already lists this version first',
})
})
test('rejects a missing version section and an empty release section', () => {
expect(() =>
syncWebReleaseEntry({
changelog: SAMPLE_CHANGELOG,
releasesTs: SAMPLE_RELEASES_TS,
baseReleasesTs: SAMPLE_RELEASES_TS,
manifestVersion: '9.9.9',
}),
).toThrow('no CHANGELOG.md section found for version 9.9.9')
expect(() =>
syncWebReleaseEntry({
changelog: '## [0.28.0](url) (2026-08-10)\n',
releasesTs: SAMPLE_RELEASES_TS,
baseReleasesTs: SAMPLE_RELEASES_TS,
manifestVersion: '0.28.0',
}),
).toThrow('CHANGELOG.md section for 0.28.0 has no bullet highlights')
})
test('requires an explicit trusted base', () => {
expect(() =>
syncWebReleaseEntry({
changelog: SAMPLE_CHANGELOG,
releasesTs: SAMPLE_RELEASES_TS,
manifestVersion: '0.28.0',
}),
).toThrow('missing base release ref')
})
})
describe('formatting helpers', () => {
test('strips markdown and trailing changelog references', () => {
expect(
sanitizeChangelogBullet('* **auth:** ready ([#12](https://example.test/12)) ([abcdef1](https://example.test))'),
).toBe('auth: ready')
expect(sanitizeChangelogBullet('* accept &lt;tag&gt;, arrows -&gt; and &#x1F525;')).toBe(
'accept <tag>, arrows -> and 🔥',
)
expect(sanitizeChangelogBullet('* invalid &#x110000; and &#55296;')).toBe(
'invalid &#x110000; and &#55296;',
)
})
test('derives safe compact themes and escapes generated strings', () => {
expect(deriveTheme([])).toBe('release highlights')
expect(deriveTheme(['plain highlight'])).toBe('plain highlight')
expect(deriveTheme([`scope: ${'x'.repeat(80)}`])).toBe(`${'x'.repeat(69)}`)
expect(deriveTheme([`scope: ${'x'.repeat(68)}🔥more`])).toBe(`${'x'.repeat(68)}🔥…`)
expect(formatReleaseEntry({ version: '0.28.0', date: '2026-08-10', theme: "it's\rready", highlights: ["don't"] })).toContain(
'theme: "it\'s\\rready"',
)
})
test('parses only the requested release section', () => {
expect(parseChangelogSection(`${SAMPLE_CHANGELOG}\n## [0.27.0](url) (2026-07-30)\n* old`, '0.28.0')?.highlights).toHaveLength(5)
})
})
-307
View File
@@ -1,307 +0,0 @@
import { execFileSync } from 'node:child_process'
import { readFileSync, writeFileSync } from 'node:fs'
export const RELEASES_TS_PATH = 'web/src/data/releases.ts'
export const CHANGELOG_PATH = 'CHANGELOG.md'
export const MANIFEST_PATH = '.release-please-manifest.json'
export const GENERATED_ENTRY_MARKER =
' // Generated by release automation; do not edit this entry by hand.'
/** Prior wording kept only so pending bot PRs with the old marker still refresh. */
const LEGACY_GENERATED_ENTRY_MARKER =
' // Generated by release automation; remove this comment before hand-curating.'
const GENERATED_ENTRY_MARKERS = [GENERATED_ENTRY_MARKER, LEGACY_GENERATED_ENTRY_MARKER] as const
const SEMVER_IDENTIFIER = '(?:0|[1-9]\\d*|[0-9A-Za-z-]*[A-Za-z-][0-9A-Za-z-]*)'
const SEMVER_PATTERN = new RegExp(
`^(0|[1-9]\\d*)\\.(0|[1-9]\\d*)\\.(0|[1-9]\\d*)`
+ `(?:-(${SEMVER_IDENTIFIER}(?:\\.${SEMVER_IDENTIFIER})*))?`
+ `(?:\\+[0-9A-Za-z-]+(?:\\.[0-9A-Za-z-]+)*)?$`,
)
export type ReleaseEntry = {
version: string
date: string
theme: string
highlights: string[]
}
type ParsedSemVer = {
core: [bigint, bigint, bigint]
prerelease: string[] | null
}
function parseSemVer(version: string): ParsedSemVer {
const match = version.match(SEMVER_PATTERN)
if (!match) throw new Error(`invalid SemVer: ${version}`)
return {
core: [BigInt(match[1]!), BigInt(match[2]!), BigInt(match[3]!)],
prerelease: match[4]?.split('.') ?? null,
}
}
export function compareSemVer(leftVersion: string, rightVersion: string): number {
const left = parseSemVer(leftVersion)
const right = parseSemVer(rightVersion)
for (let index = 0; index < left.core.length; index++) {
if (left.core[index] !== right.core[index])
return left.core[index]! < right.core[index]! ? -1 : 1
}
if (left.prerelease === null || right.prerelease === null)
return left.prerelease === right.prerelease ? 0 : left.prerelease === null ? 1 : -1
const length = Math.max(left.prerelease.length, right.prerelease.length)
for (let index = 0; index < length; index++) {
const a = left.prerelease[index]
const b = right.prerelease[index]
if (a === undefined || b === undefined)
return a === b ? 0 : a === undefined ? -1 : 1
if (a === b) continue
const aNumeric = /^\d+$/.test(a)
const bNumeric = /^\d+$/.test(b)
if (aNumeric && bNumeric) return BigInt(a) < BigInt(b) ? -1 : 1
if (aNumeric !== bNumeric) return aNumeric ? -1 : 1
return a < b ? -1 : 1
}
return 0
}
export function assertVersionAdvances(baseVersion: string, nextVersion: string): void {
if (compareSemVer(nextVersion, baseVersion) <= 0)
throw new Error(`release version must advance: ${baseVersion} -> ${nextVersion}`)
}
export function sanitizeChangelogBullet(line: string): string {
let sanitized = line
.replace(/^\*\s+/, '')
.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1')
.replace(/`([^`]+)`/g, '$1')
.replace(/\*\*([^*]+)\*\*/g, '$1')
.replace(/\*([^*]+)\*/g, '$1')
.trim()
while (true) {
const next = sanitized.replace(/\s*\((?:#\d+|[0-9a-f]{7,40})\)\s*$/i, '').trim()
if (next === sanitized) break
sanitized = next
}
return decodeHtmlEntities(sanitized.replace(/,\s*closes\s+#\d+$/i, '').trim())
}
function decodeHtmlEntities(value: string): string {
const named: Record<string, string> = {
amp: '&',
apos: "'",
gt: '>',
lt: '<',
quot: '"',
}
return value.replace(/&(?:#(\d+)|#x([0-9a-f]+)|([a-z]+));/gi, (entity, decimal, hexadecimal, name) => {
if (name) return named[name.toLowerCase()] ?? entity
const codePoint = Number.parseInt(decimal ?? hexadecimal, decimal ? 10 : 16)
if (!Number.isSafeInteger(codePoint) || codePoint < 0 || codePoint > 0x10ffff)
return entity
if (codePoint >= 0xd800 && codePoint <= 0xdfff) return entity
return String.fromCodePoint(codePoint)
})
}
export function parseChangelogSection(
changelog: string,
version?: string,
): { version: string; date: string; highlights: string[] } | null {
const target = version ?? readManifestVersion()
const sectionPattern = new RegExp(
`^## \\[${escapeRegExp(target)}\\][^\\n]*\\((\\d{4}-\\d{2}-\\d{2})\\)\\s*$`,
'm',
)
const headerMatch = changelog.match(sectionPattern)
if (!headerMatch) return null
const start = headerMatch.index ?? changelog.indexOf(headerMatch[0])
const afterHeader = changelog.slice(start + headerMatch[0].length)
const nextSection = afterHeader.search(/^## \[/m)
const sectionBody = nextSection === -1 ? afterHeader : afterHeader.slice(0, nextSection)
const highlights = sectionBody
.split('\n')
.map(line => line.trim())
.filter(line => line.startsWith('* '))
.map(sanitizeChangelogBullet)
.filter(Boolean)
.slice(0, 5)
return { version: target, date: headerMatch[1]!, highlights }
}
export function deriveTheme(highlights: string[]): string {
if (highlights.length === 0) return 'release highlights'
const first = highlights[0]!
const scopeMatch = first.match(/^([a-z0-9-]+):\s*/i)
const theme = scopeMatch ? first.slice(scopeMatch[0].length) : first
const characters = [...theme]
return characters.length > 72 ? `${characters.slice(0, 69).join('')}` : theme
}
export function readManifestVersion(manifest = readFileSync(MANIFEST_PATH, 'utf8')): string {
const parsed = JSON.parse(manifest) as Record<string, string>
const version = parsed['.']
if (!version) throw new Error(`missing root version in ${MANIFEST_PATH}`)
return version
}
export function readCurrentTopVersion(releasesTs: string): string | null {
const match = releasesTs.match(
/export const releases: Release\[\] = \[\s*(?:\/\/[^\n]*\s*)?\{\s*version: ["']([^"']+)["']/s,
)
return match?.[1] ?? null
}
export function formatReleaseEntry(entry: ReleaseEntry, indent = ' '): string {
const quote = (value: string) => JSON.stringify(value)
const highlightLines = entry.highlights.map(highlight => `${indent} ${quote(highlight)},`).join('\n')
return `${indent}{
${indent} version: ${quote(entry.version)},
${indent} date: ${quote(entry.date)},
${indent} theme: ${quote(entry.theme)},
${indent} highlights: [
${highlightLines}
${indent} ],
${indent}},
`
}
export function insertReleaseEntry(releasesTs: string, entry: ReleaseEntry): string {
const marker = 'export const releases: Release[] = ['
const index = releasesTs.indexOf(marker)
if (index === -1) throw new Error(`could not find releases array in ${RELEASES_TS_PATH}`)
const insertAt = index + marker.length
const eol = detectLineEnding(releasesTs)
const formattedEntry = formatReleaseEntry(entry).replaceAll('\n', eol)
// A merged release can leave the automation marker on the published top
// entry. Strip that leftover marker so only the new draft owns it.
const rest = stripLeadingGeneratedMarker(releasesTs.slice(insertAt), eol)
return `${releasesTs.slice(0, insertAt)}${eol}${GENERATED_ENTRY_MARKER}${eol}${formattedEntry}${rest}`
}
export function replaceTopReleaseEntry(releasesTs: string, entry: ReleaseEntry): string {
const marker = 'export const releases: Release[] = ['
const index = releasesTs.indexOf(marker)
if (index === -1) throw new Error(`could not find releases array in ${RELEASES_TS_PATH}`)
const insertAt = index + marker.length
const existing = stripLeadingGeneratedMarker(releasesTs.slice(insertAt), detectLineEnding(releasesTs))
const endMatch = /^ \},\r?\n/m.exec(existing)
if (!endMatch) throw new Error(`could not find top release entry in ${RELEASES_TS_PATH}`)
const eol = detectLineEnding(releasesTs)
const formattedEntry = formatReleaseEntry(entry).replaceAll('\n', eol)
const afterEntry = endMatch.index + endMatch[0].length
return `${releasesTs.slice(0, insertAt)}${eol}${GENERATED_ENTRY_MARKER}${eol}${formattedEntry}${existing.slice(afterEntry)}`
}
export function hasGeneratedTopEntry(releasesTs: string): boolean {
const marker = 'export const releases: Release[] = ['
const index = releasesTs.indexOf(marker)
if (index === -1) return false
const existing = releasesTs.slice(index + marker.length)
return GENERATED_ENTRY_MARKERS.some(
generated =>
existing.startsWith(`\n${generated}\n`)
|| existing.startsWith(`\r\n${generated}\r\n`),
)
}
function detectLineEnding(value: string): '\n' | '\r\n' {
return value.includes('\r\n') ? '\r\n' : '\n'
}
function stripLeadingGeneratedMarker(value: string, eol: '\n' | '\r\n'): string {
for (const generated of GENERATED_ENTRY_MARKERS) {
const prefix = `${eol}${generated}${eol}`
if (value.startsWith(prefix)) return value.slice(prefix.length)
}
return value
}
export function readBaseReleasesTs(baseRef: string): string {
return execFileSync('git', ['show', `${baseRef}:${RELEASES_TS_PATH}`], { encoding: 'utf8' })
}
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
}
export type SyncResult =
| { status: 'unchanged'; version: string; reason: string }
| { status: 'updated'; version: string; content: string }
export function syncWebReleaseEntry(options: {
changelog?: string
releasesTs?: string
baseReleasesTs?: string
baseRef?: string
manifestVersion?: string
} = {}): SyncResult {
const version = options.manifestVersion ?? readManifestVersion()
const releasesTs = options.releasesTs ?? readFileSync(RELEASES_TS_PATH, 'utf8')
const currentTop = readCurrentTopVersion(releasesTs)
const baseReleasesTs = options.baseReleasesTs
?? (options.baseRef ? readBaseReleasesTs(options.baseRef) : null)
if (baseReleasesTs === null)
throw new Error('missing base release ref; pass --base-ref <ref> or provide baseReleasesTs')
const baseTop = readCurrentTopVersion(baseReleasesTs)
const generatedTop = hasGeneratedTopEntry(releasesTs)
const divergedFromBase = currentTop !== baseTop
// Unmarked divergence is not automation-owned. Fail closed — never preserve
// or overwrite hand edits on the bot sync path.
if (divergedFromBase && !generatedTop) {
throw new Error(
`${RELEASES_TS_PATH} differs from the base without the generated-entry marker; refusing to overwrite it`,
)
}
// Same version without a generated marker is already published on the trusted base.
if (currentTop === version && !generatedTop)
return { status: 'unchanged', version, reason: 'releases.ts already lists this version first' }
const changelog = options.changelog ?? readFileSync(CHANGELOG_PATH, 'utf8')
const section = parseChangelogSection(changelog, version)
if (!section) throw new Error(`no CHANGELOG.md section found for version ${version}`)
if (section.highlights.length === 0)
throw new Error(`CHANGELOG.md section for ${version} has no bullet highlights`)
const entry: ReleaseEntry = {
version: section.version,
date: section.date,
theme: deriveTheme(section.highlights),
highlights: section.highlights,
}
// Generated tops are automation-owned: refresh in place for the same version,
// or replace when the pending Release Please PR bumps the draft version.
if (generatedTop && (currentTop === version || divergedFromBase))
return { status: 'updated', version, content: replaceTopReleaseEntry(releasesTs, entry) }
// New version on top of the trusted base. insertReleaseEntry strips any
// leftover marker that remained on a previously generated published entry.
return { status: 'updated', version, content: insertReleaseEntry(releasesTs, entry) }
}
if (import.meta.main) {
const baseRefIndex = process.argv.indexOf('--base-ref')
const baseRef = baseRefIndex === -1 ? undefined : process.argv[baseRefIndex + 1]
if (baseRefIndex !== -1 && (!baseRef || baseRef.startsWith('--')))
throw new Error('--base-ref requires a git ref argument')
const result = syncWebReleaseEntry({ baseRef })
if (result.status === 'unchanged') console.log(`sync-web-release-entry: ${result.reason}`)
else if (process.argv.includes('--write')) {
writeFileSync(RELEASES_TS_PATH, result.content)
console.log(`sync-web-release-entry: inserted ${result.version} into ${RELEASES_TS_PATH}`)
} else {
console.error(`sync-web-release-entry: ${RELEASES_TS_PATH} is missing ${result.version}; run with --write`)
process.exit(1)
}
}
+3 -3
View File
@@ -1,6 +1,6 @@
// Release highlights for the /changelog page. Automation selects the first
// five changelog bullets for new entries; older published entries may predate
// that automation. Full notes live on GitHub releases. Newest first.
// Curated release highlights for the /changelog page. Full notes live on
// GitHub releases; this list is the hand-picked "what actually matters"
// per minor version. Newest first.
//
// Ownership: release/web process only (release automation and dedicated web
// release PRs). Do not edit this file from ordinary feature or bugfix PRs,
+2 -2
View File
@@ -4,7 +4,7 @@ import { SITE } from '../data/site'
import { releases, releaseUrl, RELEASES_URL } from '../data/releases'
const title = `what's new in openclaude — release highlights | openclaude`
const description = `Highlights from every openclaude release: buddy companions, GPT-5.6 support, new providers, background sessions, and more. Currently at v${SITE.version}.`
const description = `Curated highlights from every openclaude release: buddy companions, GPT-5.6 support, new providers, background sessions, and more. Currently at v${SITE.version}.`
const jsonLd = [
{
@@ -32,7 +32,7 @@ const jsonLd = [
<p class="pill"><span class="dot" aria-hidden="true"></span>currently v{SITE.version}</p>
<h1 id="changelog-heading" class="text-hero">what's new.</h1>
<p class="hero-sub">
a quick summary of each release.
the highlights that matter from each release — hand-picked, not a commit dump.
full notes live on <a href={RELEASES_URL} rel="noopener">github releases</a>.
</p>
</div>