From 5f8e7d101be3052f970dc073f8f37f1c3dc0c5da Mon Sep 17 00:00:00 2001 From: JATMN Date: Tue, 11 Aug 2026 05:23:26 -0700 Subject: [PATCH] Revert "fix(release): synchronize web changelog entries (#2088)" (#2113) This reverts commit 7743cf280ed13b97b4bb2da1b0afde8d0757651a. --- .github/workflows/release.yml | 693 +------------------------ CONTRIBUTING.md | 21 - package.json | 1 - release-please-config.json | 1 - scripts/sync-web-release-entry.test.ts | 340 ------------ scripts/sync-web-release-entry.ts | 307 ----------- web/src/data/releases.ts | 6 +- web/src/pages/changelog.astro | 4 +- 8 files changed, 8 insertions(+), 1365 deletions(-) delete mode 100644 scripts/sync-web-release-entry.test.ts delete mode 100644 scripts/sync-web-release-entry.ts diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d5a9fadab..906012118 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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 β†’ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c290735f9..97918ba06 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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 diff --git a/package.json b/package.json index 8dbb1799d..5edabe7c1 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/release-please-config.json b/release-please-config.json index 9c4e5b150..41a3040f8 100644 --- a/release-please-config.json +++ b/release-please-config.json @@ -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 } } diff --git a/scripts/sync-web-release-entry.test.ts b/scripts/sync-web-release-entry.test.ts deleted file mode 100644 index d5a08e637..000000000 --- a/scripts/sync-web-release-entry.test.ts +++ /dev/null @@ -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 , 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) - }) -}) diff --git a/scripts/sync-web-release-entry.ts b/scripts/sync-web-release-entry.ts deleted file mode 100644 index f5ee13bd9..000000000 --- a/scripts/sync-web-release-entry.ts +++ /dev/null @@ -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 = { - 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 - 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 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) - } -} diff --git a/web/src/data/releases.ts b/web/src/data/releases.ts index 7387be6dd..37bd5e0a2 100644 --- a/web/src/data/releases.ts +++ b/web/src/data/releases.ts @@ -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, diff --git a/web/src/pages/changelog.astro b/web/src/pages/changelog.astro index 833e54e06..675d770d1 100644 --- a/web/src/pages/changelog.astro +++ b/web/src/pages/changelog.astro @@ -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 = [

currently v{SITE.version}

what's new.

- a quick summary of each release. + the highlights that matter from each release β€” hand-picked, not a commit dump. full notes live on github releases.