mirror of
https://github.com/Gitlawb/openclaude.git
synced 2026-08-24 10:14:19 -05:00
This reverts commit 7743cf280e.
This commit is contained in:
@@ -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 →
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 <tag>, arrows -> and 🔥')).toBe(
|
||||
'accept <tag>, arrows -> and 🔥',
|
||||
)
|
||||
expect(sanitizeChangelogBullet('* invalid � and �')).toBe(
|
||||
'invalid � and �',
|
||||
)
|
||||
})
|
||||
|
||||
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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user