From 5052fdd3caa398ec41d23e150a0c8e387d3e05a4 Mon Sep 17 00:00:00 2001 From: Wikid82 Date: Tue, 11 Aug 2026 18:35:24 -0400 Subject: [PATCH 1/9] ci: set up development as the integration branch Adds CI (build+lint on PRs/pushes) and an automated main->development sync so hotfixes/direct pushes to main don't leave development stale. Renovate now targets development instead of main so dependency bumps batch into a single release instead of triggering one each. --- .github/renovate.json | 2 +- .github/workflows/ci.yml | 31 ++++++++++++ .../workflows/sync-main-to-development.yml | 49 +++++++++++++++++++ 3 files changed, 81 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/sync-main-to-development.yml diff --git a/.github/renovate.json b/.github/renovate.json index d37c046..6f6032e 100644 --- a/.github/renovate.json +++ b/.github/renovate.json @@ -6,7 +6,7 @@ ":separateMultipleMajorReleases", "helpers:pinGitHubActionDigests" ], - "baseBranchPatterns": ["main"], + "baseBranchPatterns": ["development"], "postUpdateOptions": ["npmDedupe"], "skipInstalls": false, "timezone": "America/New_York", diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..990de52 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,31 @@ +name: CI + +on: + pull_request: + branches: [main, development] + push: + branches: [main, development] + +permissions: + contents: read + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + + - name: Setup Node.js + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 + with: + node-version: 24.19.0 + cache: 'npm' + + - name: Install dependencies + run: npm ci + + - name: Lint + run: npm run lint + + - name: Build + run: npm run build diff --git a/.github/workflows/sync-main-to-development.yml b/.github/workflows/sync-main-to-development.yml new file mode 100644 index 0000000..30ed8ea --- /dev/null +++ b/.github/workflows/sync-main-to-development.yml @@ -0,0 +1,49 @@ +name: Sync main to development + +# Direct pushes/hotfixes (and release-please's own version-bump commits) land on main +# without ever touching development. This keeps development from drifting by opening +# a PR that merges main back in whenever main moves ahead. + +on: + push: + branches: [main] + +permissions: + contents: write + pull-requests: write + +jobs: + sync: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + fetch-depth: 0 + + - name: Sync main into development + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + + AHEAD=$(git rev-list --count origin/development..origin/main) + if [ "$AHEAD" -eq 0 ]; then + echo "development is already up to date with main; nothing to sync." + exit 0 + fi + + BRANCH="sync/main-to-development" + git checkout -B "$BRANCH" origin/development + git merge origin/main --no-edit + + git push origin "$BRANCH" --force + + if gh pr list --base development --head "$BRANCH" --state open --json number -q '.[0].number' | grep -q .; then + echo "Sync PR already open; branch push above updated it." + else + gh pr create --base development --head "$BRANCH" \ + --title "chore: sync main into development" \ + --body "Automated sync after a push to main (hotfix, direct push, or release-please version bump)." + fi From aeafa78c1ee831315c6c4b9302bc63c0169590a3 Mon Sep 17 00:00:00 2001 From: Wikid82 Date: Tue, 11 Aug 2026 18:41:22 -0400 Subject: [PATCH 2/9] ci: add GitHub workflows for CodeQL analysis, PR title linting, nightly promotion, and syncing development --- .github/workflows/codeql.yml | 41 ++++ .github/workflows/pr-title-lint.yml | 20 ++ .github/workflows/promote-nightly-to-main.yml | 212 ++++++++++++++++++ .../propagate-main-to-development.yml | 94 ++++++++ .github/workflows/sync-nightly.yml | 64 ++++++ 5 files changed, 431 insertions(+) create mode 100644 .github/workflows/codeql.yml create mode 100644 .github/workflows/pr-title-lint.yml create mode 100644 .github/workflows/promote-nightly-to-main.yml create mode 100644 .github/workflows/propagate-main-to-development.yml create mode 100644 .github/workflows/sync-nightly.yml diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 0000000..c43808c --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,41 @@ +name: CodeQL + +on: + push: + branches: [main, development, nightly, 'feature/**'] + pull_request: + branches: [main, development, nightly] + schedule: + - cron: '30 3 * * 1' # Mondays ~03:30 UTC, off the top of the hour + workflow_dispatch: + +concurrency: + # PRs: cancel the scan for superseded commits on the same PR (the old + # code no longer matters). Pushes: key on the commit SHA, not the branch + # name — otherwise a second push to the same branch before CodeQL + # finishes cancels the first commit's scan and leaves it with a + # permanently cancelled/red check instead of a result. + group: ${{ github.workflow }}-${{ github.event_name == 'pull_request' && github.head_ref || github.sha }} + cancel-in-progress: true + +permissions: + contents: read + security-events: write + +jobs: + analyze: + runs-on: ubuntu-latest + permissions: + contents: read + security-events: write + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + + - uses: github/codeql-action/init@5595ccaf912efad79be6eef63a5619ff05969be3 # v4 + with: + languages: javascript-typescript + queries: security-and-quality + + - uses: github/codeql-action/analyze@5595ccaf912efad79be6eef63a5619ff05969be3 # v4 + with: + category: /language:javascript-typescript diff --git a/.github/workflows/pr-title-lint.yml b/.github/workflows/pr-title-lint.yml new file mode 100644 index 0000000..ce3cc20 --- /dev/null +++ b/.github/workflows/pr-title-lint.yml @@ -0,0 +1,20 @@ +name: PR title lint + +# release-please derives versions and changelog entries from conventional +# commit prefixes (feat:, fix:, chore:, etc). When a PR is squash-merged, +# the PR title becomes the commit message, so the title has to follow the +# same convention or the release automation silently misses it. +on: + pull_request: + types: [opened, edited, synchronize, reopened] + +permissions: + pull-requests: read + +jobs: + check-title: + runs-on: ubuntu-latest + steps: + - uses: amannn/action-semantic-pull-request@48f256284bd46cdaab1048c3721360e808335d50 # v6 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/promote-nightly-to-main.yml b/.github/workflows/promote-nightly-to-main.yml new file mode 100644 index 0000000..4e455c3 --- /dev/null +++ b/.github/workflows/promote-nightly-to-main.yml @@ -0,0 +1,212 @@ +name: Promote nightly to main + +# Weekly PR nightly -> main, human-reviewed (unlike sync-nightly.yml's +# direct development -> nightly push). Gated on CI having actually passed +# for nightly's current HEAD, so a broken nightly doesn't get promoted just +# because a week went by. + +on: + schedule: + - cron: '23 12 * * 1' # Mondays ~12:23 UTC, off the top of the hour + workflow_dispatch: + inputs: + reason: + description: 'Why are you running this manually?' + required: true + default: 'Ad-hoc promotion request' + skip_health_check: + description: 'Skip the CI health check on nightly?' + required: false + type: boolean + default: false + +concurrency: + group: ${{ github.workflow }} + cancel-in-progress: false + +env: + SOURCE_BRANCH: nightly + TARGET_BRANCH: main + +permissions: + contents: read + pull-requests: write + +jobs: + check-nightly-health: + runs-on: ubuntu-latest + outputs: + is_healthy: ${{ steps.check.outputs.is_healthy }} + run_url: ${{ steps.check.outputs.run_url }} + steps: + - name: Check CI status on nightly HEAD + id: check + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 + env: + SKIP: ${{ inputs.skip_health_check }} + with: + script: | + if (process.env.SKIP === 'true') { + core.info('Health check skipped by workflow_dispatch input'); + core.setOutput('is_healthy', 'true'); + core.setOutput('run_url', 'N/A - check skipped'); + return; + } + + const { data: branch } = await github.rest.repos.getBranch({ + owner: context.repo.owner, + repo: context.repo.repo, + branch: 'nightly', + }); + const headSha = branch.commit.sha; + core.info(`nightly HEAD: ${headSha}`); + + // sync-nightly.yml never creates a new commit — nightly is always + // either untouched or fast-forwarded/reset to development's exact + // SHA. So a completed CI run on 'development' at this same SHA is + // equally valid proof of health, and covers two gaps in checking + // 'nightly' alone: (1) if nightly was synced with the GITHUB_TOKEN + // fallback (doesn't trigger downstream workflows), CI never ran on + // nightly at all; (2) if nightly already matched development when + // the sync ran, no push happened, so no nightly-branch run exists + // for this SHA even with a trigger token configured. + let run = null; + for (let attempt = 1; attempt <= 4; attempt += 1) { + const { data } = await github.rest.actions.listWorkflowRuns({ + owner: context.repo.owner, + repo: context.repo.repo, + workflow_id: 'ci.yml', + status: 'completed', + per_page: 20, + }); + run = data.workflow_runs.find( + (r) => r.head_sha === headSha && (r.head_branch === 'nightly' || r.head_branch === 'development'), + ); + if (run) break; + core.info(`No completed CI run for nightly HEAD yet (attempt ${attempt}/4), waiting...`); + await new Promise((resolve) => setTimeout(resolve, 15000)); + } + + if (!run) { + core.setOutput('is_healthy', 'false'); + core.setOutput('run_url', `https://github.com/${context.repo.owner}/${context.repo.repo}/actions/workflows/ci.yml`); + core.warning('No completed CI run found for nightly HEAD — blocking promotion'); + return; + } + + core.setOutput('is_healthy', run.conclusion === 'success' ? 'true' : 'false'); + core.setOutput('run_url', run.html_url); + if (run.conclusion !== 'success') { + core.warning(`CI on nightly HEAD concluded '${run.conclusion}' — blocking promotion`); + } + + create-promotion-pr: + needs: check-nightly-health + if: needs.check-nightly-health.outputs.is_healthy == 'true' + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + ref: ${{ env.TARGET_BRANCH }} + fetch-depth: 0 + + - name: Check for differences + id: diff + run: | + git fetch origin "${{ env.SOURCE_BRANCH }}" + AHEAD=$(git rev-list --count "origin/${{ env.TARGET_BRANCH }}..origin/${{ env.SOURCE_BRANCH }}") + echo "nightly is $AHEAD commits ahead of main" + echo "ahead=$AHEAD" >> "$GITHUB_OUTPUT" + + - name: Generate commit summary + if: steps.diff.outputs.ahead != '0' + id: commits + run: | + { + echo "log<> "$GITHUB_OUTPUT" + + - name: Create or update promotion PR + if: steps.diff.outputs.ahead != '0' + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 + env: + COMMIT_LOG: ${{ steps.commits.outputs.log }} + TRIGGER_REASON: ${{ inputs.reason || 'Scheduled weekly promotion' }} + RUN_URL: ${{ needs.check-nightly-health.outputs.run_url }} + with: + script: | + const source = process.env.SOURCE_BRANCH || 'nightly'; + const target = process.env.TARGET_BRANCH || 'main'; + + const { data: existing } = await github.rest.pulls.list({ + owner: context.repo.owner, + repo: context.repo.repo, + state: 'open', + head: `${context.repo.owner}:nightly`, + base: 'main', + }); + + const date = new Date().toISOString().slice(0, 10); + const body = `## Promote nightly to main + + **Date:** ${date} + **Trigger:** ${process.env.TRIGGER_REASON} + **CI on nightly HEAD:** ${process.env.RUN_URL} + + ### Commits being promoted + \`\`\` + ${process.env.COMMIT_LOG} + \`\`\` + + ## Merge instructions — important + + **Use "Create a merge commit", not squash or rebase.** Squashing collapses every + \`feat:\`/\`fix:\` commit into one bullet-list body, which release-please can't parse — + version bumps and changelog entries silently stop working. + + --- + _Opened automatically by [Promote nightly to main](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})._ + `; + + if (existing.length > 0) { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: existing[0].number, + body: `Nightly has moved on since this PR opened. New commits may be included.\n\n_Triggered by [this run](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})._`, + }); + core.info(`Updated existing PR #${existing[0].number}`); + return; + } + + const pr = await github.rest.pulls.create({ + owner: context.repo.owner, + repo: context.repo.repo, + title: `chore: promote nightly to main (${date})`, + head: 'nightly', + base: 'main', + body, + }); + core.info(`Created PR #${pr.data.number}: ${pr.data.html_url}`); + + for (const label of ['automated', 'promotion']) { + try { + await github.rest.issues.getLabel({ owner: context.repo.owner, repo: context.repo.repo, name: label }); + } catch { + await github.rest.issues.createLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + name: label, + color: label === 'automated' ? '0e8a16' : '5319e7', + }); + } + } + await github.rest.issues.addLabels({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: pr.data.number, + labels: ['automated', 'promotion'], + }); diff --git a/.github/workflows/propagate-main-to-development.yml b/.github/workflows/propagate-main-to-development.yml new file mode 100644 index 0000000..ebc3a18 --- /dev/null +++ b/.github/workflows/propagate-main-to-development.yml @@ -0,0 +1,94 @@ +name: Propagate main to development + +# For hotfixes/CI-generated commits pushed straight to main — opens a PR +# carrying those changes down to development so they don't only exist on +# main. Deliberately does NOT also target nightly: nightly picks up +# anything development gets via sync-nightly.yml's daily sync, so a second +# PR there would just be redundant. +# +# Also fires after a routine nightly -> main promotion merge, but that's a +# no-op in practice: main's content after that merge already matches +# development (it came from development via nightly), so the ahead-count +# check below finds nothing to propagate and skips creating a PR. + +on: + push: + branches: [main] + +concurrency: + group: ${{ github.workflow }} + cancel-in-progress: false + +permissions: + contents: read + pull-requests: write + +jobs: + propagate: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + ref: development + fetch-depth: 0 + + - name: Check for differences + id: diff + run: | + git fetch origin main development + AHEAD=$(git rev-list --count origin/development..origin/main) + echo "main is $AHEAD commits ahead of development" + echo "ahead=$AHEAD" >> "$GITHUB_OUTPUT" + + - name: Create or update propagation PR + if: steps.diff.outputs.ahead != '0' + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 + with: + script: | + const { data: existing } = await github.rest.pulls.list({ + owner: context.repo.owner, + repo: context.repo.repo, + state: 'open', + head: `${context.repo.owner}:main`, + base: 'development', + }); + + if (existing.length > 0) { + core.info(`Existing PR #${existing[0].number} already open for main -> development; leaving it as-is`); + return; + } + + const pr = await github.rest.pulls.create({ + owner: context.repo.owner, + repo: context.repo.repo, + title: 'chore: propagate main into development', + head: 'main', + base: 'development', + body: [ + 'Automated PR carrying commits pushed directly to `main` (hotfixes, CI-generated', + 'commits) down into `development`. nightly is intentionally skipped — it picks', + 'these up via the daily development sync instead.', + '', + `Triggered by push ${context.sha} to main.`, + ].join('\n'), + draft: true, + }); + core.info(`Created PR #${pr.data.number}: ${pr.data.html_url}`); + + try { + await github.rest.issues.getLabel({ owner: context.repo.owner, repo: context.repo.repo, name: 'auto-propagate' }); + } catch { + await github.rest.issues.createLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + name: 'auto-propagate', + color: '7dd3fc', + }); + } + await github.rest.issues.addLabels({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: pr.data.number, + labels: ['auto-propagate'], + }); diff --git a/.github/workflows/sync-nightly.yml b/.github/workflows/sync-nightly.yml new file mode 100644 index 0000000..d66520c --- /dev/null +++ b/.github/workflows/sync-nightly.yml @@ -0,0 +1,64 @@ +name: Sync development to nightly + +# Direct fast-forward sync, not a PR — nightly is meant to track development +# continuously without manual review. (nightly -> main is the reviewed step, +# in promote-nightly-to-main.yml.) +# +# Uses CI_TRIGGER_TOKEN if set, falling back to GITHUB_TOKEN. The fallback +# works, but a push made with the default GITHUB_TOKEN doesn't trigger other +# workflows — so without CI_TRIGGER_TOKEN, CI won't run on nightly until +# something else touches it (e.g. the promotion PR). Set a PAT with repo +# write access as CI_TRIGGER_TOKEN to get CI running on nightly right away. + +on: + schedule: + - cron: '17 9 * * *' # daily ~09:17 UTC, off the top of the hour + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }} + cancel-in-progress: false + +permissions: + contents: write + +jobs: + sync: + runs-on: ubuntu-latest + steps: + - name: Checkout nightly + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + ref: nightly + fetch-depth: 0 + token: ${{ secrets.CI_TRIGGER_TOKEN || secrets.GITHUB_TOKEN }} + + - name: Configure git + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + + - name: Fast-forward nightly to development + env: + HAS_TRIGGER_TOKEN: ${{ secrets.CI_TRIGGER_TOKEN != '' }} + run: | + git fetch origin development nightly + git reset --hard origin/nightly + + if git diff --quiet origin/nightly origin/development; then + echo "nightly already matches development — nothing to sync" + exit 0 + fi + + if [[ "$HAS_TRIGGER_TOKEN" != "true" ]]; then + echo "::warning title=Using GITHUB_TOKEN fallback::Set CI_TRIGGER_TOKEN so this push triggers CI on nightly." + fi + + # Neither branch of this ever creates a new commit — nightly just + # becomes a ref pointing at development's exact SHA, so CI (now + # triggered on push to nightly too) runs against it normally. + git merge origin/development --ff-only || { + echo "Fast-forward not possible — resetting nightly to development" + git reset --hard origin/development + } + git push --force origin nightly From b2812edbb9859b9995a16c8d7c3b22933bde42fa Mon Sep 17 00:00:00 2001 From: Wikid82 Date: Tue, 11 Aug 2026 18:41:27 -0400 Subject: [PATCH 3/9] fix: update repository URLs in package.json to match the correct project name --- package.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/package.json b/package.json index e0eece0..12eda4a 100644 --- a/package.json +++ b/package.json @@ -25,12 +25,12 @@ }, "repository": { "type": "git", - "url": "https://github.com/Wikid82/file_bookmarks" + "url": "https://github.com/Wikid82/Workspace-File-Bookmarks" }, "bugs": { - "url": "https://github.com/Wikid82/file_bookmarks/issues" + "url": "https://github.com/Wikid82/Workspace-File-Bookmarks/issues" }, - "homepage": "https://github.com/Wikid82/file_bookmarks#readme", + "homepage": "https://github.com/Wikid82/Workspace-File-Bookmarks#readme", "engines": { "vscode": "^1.132.0" }, From 83841ab23f113bc5f2eccdba457dfd7f190b5510 Mon Sep 17 00:00:00 2001 From: Wikid82 Date: Tue, 11 Aug 2026 18:46:10 -0400 Subject: [PATCH 4/9] ci: remove obsolete workflows for nightly promotion and sync --- .github/workflows/promote-nightly-to-main.yml | 212 ------------------ .github/workflows/release-please.yml | 110 --------- .github/workflows/sync-nightly.yml | 64 ------ 3 files changed, 386 deletions(-) delete mode 100644 .github/workflows/promote-nightly-to-main.yml delete mode 100644 .github/workflows/release-please.yml delete mode 100644 .github/workflows/sync-nightly.yml diff --git a/.github/workflows/promote-nightly-to-main.yml b/.github/workflows/promote-nightly-to-main.yml deleted file mode 100644 index 4e455c3..0000000 --- a/.github/workflows/promote-nightly-to-main.yml +++ /dev/null @@ -1,212 +0,0 @@ -name: Promote nightly to main - -# Weekly PR nightly -> main, human-reviewed (unlike sync-nightly.yml's -# direct development -> nightly push). Gated on CI having actually passed -# for nightly's current HEAD, so a broken nightly doesn't get promoted just -# because a week went by. - -on: - schedule: - - cron: '23 12 * * 1' # Mondays ~12:23 UTC, off the top of the hour - workflow_dispatch: - inputs: - reason: - description: 'Why are you running this manually?' - required: true - default: 'Ad-hoc promotion request' - skip_health_check: - description: 'Skip the CI health check on nightly?' - required: false - type: boolean - default: false - -concurrency: - group: ${{ github.workflow }} - cancel-in-progress: false - -env: - SOURCE_BRANCH: nightly - TARGET_BRANCH: main - -permissions: - contents: read - pull-requests: write - -jobs: - check-nightly-health: - runs-on: ubuntu-latest - outputs: - is_healthy: ${{ steps.check.outputs.is_healthy }} - run_url: ${{ steps.check.outputs.run_url }} - steps: - - name: Check CI status on nightly HEAD - id: check - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 - env: - SKIP: ${{ inputs.skip_health_check }} - with: - script: | - if (process.env.SKIP === 'true') { - core.info('Health check skipped by workflow_dispatch input'); - core.setOutput('is_healthy', 'true'); - core.setOutput('run_url', 'N/A - check skipped'); - return; - } - - const { data: branch } = await github.rest.repos.getBranch({ - owner: context.repo.owner, - repo: context.repo.repo, - branch: 'nightly', - }); - const headSha = branch.commit.sha; - core.info(`nightly HEAD: ${headSha}`); - - // sync-nightly.yml never creates a new commit — nightly is always - // either untouched or fast-forwarded/reset to development's exact - // SHA. So a completed CI run on 'development' at this same SHA is - // equally valid proof of health, and covers two gaps in checking - // 'nightly' alone: (1) if nightly was synced with the GITHUB_TOKEN - // fallback (doesn't trigger downstream workflows), CI never ran on - // nightly at all; (2) if nightly already matched development when - // the sync ran, no push happened, so no nightly-branch run exists - // for this SHA even with a trigger token configured. - let run = null; - for (let attempt = 1; attempt <= 4; attempt += 1) { - const { data } = await github.rest.actions.listWorkflowRuns({ - owner: context.repo.owner, - repo: context.repo.repo, - workflow_id: 'ci.yml', - status: 'completed', - per_page: 20, - }); - run = data.workflow_runs.find( - (r) => r.head_sha === headSha && (r.head_branch === 'nightly' || r.head_branch === 'development'), - ); - if (run) break; - core.info(`No completed CI run for nightly HEAD yet (attempt ${attempt}/4), waiting...`); - await new Promise((resolve) => setTimeout(resolve, 15000)); - } - - if (!run) { - core.setOutput('is_healthy', 'false'); - core.setOutput('run_url', `https://github.com/${context.repo.owner}/${context.repo.repo}/actions/workflows/ci.yml`); - core.warning('No completed CI run found for nightly HEAD — blocking promotion'); - return; - } - - core.setOutput('is_healthy', run.conclusion === 'success' ? 'true' : 'false'); - core.setOutput('run_url', run.html_url); - if (run.conclusion !== 'success') { - core.warning(`CI on nightly HEAD concluded '${run.conclusion}' — blocking promotion`); - } - - create-promotion-pr: - needs: check-nightly-health - if: needs.check-nightly-health.outputs.is_healthy == 'true' - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - with: - ref: ${{ env.TARGET_BRANCH }} - fetch-depth: 0 - - - name: Check for differences - id: diff - run: | - git fetch origin "${{ env.SOURCE_BRANCH }}" - AHEAD=$(git rev-list --count "origin/${{ env.TARGET_BRANCH }}..origin/${{ env.SOURCE_BRANCH }}") - echo "nightly is $AHEAD commits ahead of main" - echo "ahead=$AHEAD" >> "$GITHUB_OUTPUT" - - - name: Generate commit summary - if: steps.diff.outputs.ahead != '0' - id: commits - run: | - { - echo "log<> "$GITHUB_OUTPUT" - - - name: Create or update promotion PR - if: steps.diff.outputs.ahead != '0' - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 - env: - COMMIT_LOG: ${{ steps.commits.outputs.log }} - TRIGGER_REASON: ${{ inputs.reason || 'Scheduled weekly promotion' }} - RUN_URL: ${{ needs.check-nightly-health.outputs.run_url }} - with: - script: | - const source = process.env.SOURCE_BRANCH || 'nightly'; - const target = process.env.TARGET_BRANCH || 'main'; - - const { data: existing } = await github.rest.pulls.list({ - owner: context.repo.owner, - repo: context.repo.repo, - state: 'open', - head: `${context.repo.owner}:nightly`, - base: 'main', - }); - - const date = new Date().toISOString().slice(0, 10); - const body = `## Promote nightly to main - - **Date:** ${date} - **Trigger:** ${process.env.TRIGGER_REASON} - **CI on nightly HEAD:** ${process.env.RUN_URL} - - ### Commits being promoted - \`\`\` - ${process.env.COMMIT_LOG} - \`\`\` - - ## Merge instructions — important - - **Use "Create a merge commit", not squash or rebase.** Squashing collapses every - \`feat:\`/\`fix:\` commit into one bullet-list body, which release-please can't parse — - version bumps and changelog entries silently stop working. - - --- - _Opened automatically by [Promote nightly to main](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})._ - `; - - if (existing.length > 0) { - await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: existing[0].number, - body: `Nightly has moved on since this PR opened. New commits may be included.\n\n_Triggered by [this run](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})._`, - }); - core.info(`Updated existing PR #${existing[0].number}`); - return; - } - - const pr = await github.rest.pulls.create({ - owner: context.repo.owner, - repo: context.repo.repo, - title: `chore: promote nightly to main (${date})`, - head: 'nightly', - base: 'main', - body, - }); - core.info(`Created PR #${pr.data.number}: ${pr.data.html_url}`); - - for (const label of ['automated', 'promotion']) { - try { - await github.rest.issues.getLabel({ owner: context.repo.owner, repo: context.repo.repo, name: label }); - } catch { - await github.rest.issues.createLabel({ - owner: context.repo.owner, - repo: context.repo.repo, - name: label, - color: label === 'automated' ? '0e8a16' : '5319e7', - }); - } - } - await github.rest.issues.addLabels({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: pr.data.number, - labels: ['automated', 'promotion'], - }); diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml deleted file mode 100644 index 1ef3843..0000000 --- a/.github/workflows/release-please.yml +++ /dev/null @@ -1,110 +0,0 @@ -name: release-please - -on: - push: - branches: [main] - workflow_dispatch: - -permissions: - contents: write - pull-requests: write - -jobs: - release-please: - runs-on: ubuntu-latest - outputs: - release_created: ${{ steps.release.outputs.release_created }} - tag_name: ${{ steps.release.outputs.tag_name }} - upload_url: ${{ steps.release.outputs.upload_url }} - steps: - - uses: googleapis/release-please-action@45996ed1f6d02564a971a2fa1b5860e934307cf7 # v5 - id: release - with: - config-file: release-please-config.json - manifest-file: .release-please-manifest.json - - # Runs only when release-please actually cut a new release (i.e. a release PR was merged). - # Chained onto this workflow (rather than triggered by the tag it pushes) because tags/releases - # created via the default GITHUB_TOKEN do not trigger other workflows — a separate tag-triggered - # workflow would silently never run. - publish: - needs: release-please - if: ${{ needs.release-please.outputs.release_created == 'true' }} - runs-on: ubuntu-latest - permissions: - contents: write - env: - VS_MARKETPLACE_TOKEN: ${{ secrets.VS_MARKETPLACE_TOKEN }} - OPEN_VSX_TOKEN: ${{ secrets.OPEN_VSX_TOKEN }} - steps: - - name: Checkout release tag - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - with: - ref: ${{ needs.release-please.outputs.tag_name }} - - - name: Setup Node.js - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 - with: - node-version: 24.19.0 - cache: 'npm' - - - name: Install dependencies - run: npm ci - - - name: Build - run: npm run build - - - name: Package extension - run: npx @vscode/vsce package - - # release-please-config.json sets draft:true for this package specifically so this step can - # run at all: GitHub's immutable-releases feature locks a release the instant it's published, - # so assets can only be attached while it's still a draft. We un-draft it in the next step, - # once the vsix is safely attached. - # - # GitHub's "get release by tag" API doesn't resolve drafts, so `gh release upload ` - # can't find this release yet. Using upload_url (which release-please-action already gives - # us straight from the create-release response) sidesteps that lookup entirely. - # - # Lets users grab the .vsix straight from the GitHub Release and "Install from VSIX...", - # no Marketplace/Open VSX account or token needed for this path. - - name: Attach VSIX to GitHub Release - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - UPLOAD_URL: ${{ needs.release-please.outputs.upload_url }} - run: | - VSIX_FILE=$(ls *.vsix) - BASE_URL="${UPLOAD_URL%%\{*}" - curl -sSf -X POST \ - -H "Authorization: token ${GH_TOKEN}" \ - -H "Content-Type: application/octet-stream" \ - --data-binary @"${VSIX_FILE}" \ - "${BASE_URL}?name=${VSIX_FILE}" - - - name: Publish GitHub Release - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - UPLOAD_URL: ${{ needs.release-please.outputs.upload_url }} - run: | - RELEASE_ID=$(echo "${UPLOAD_URL}" | sed -E 's#.*/releases/([0-9]+)/.*#\1#') - gh api -X PATCH "repos/${{ github.repository }}/releases/${RELEASE_ID}" -f draft=false - - # Publishes to the official VS Code Marketplace (skipped until VS_MARKETPLACE_TOKEN is set). - # continue-on-error so a failure here (e.g. re-running on an already-published version) - # doesn't prevent the independent Open VSX step below from running. - - name: Publish to Visual Studio Marketplace - if: ${{ env.VS_MARKETPLACE_TOKEN != '' }} - continue-on-error: true - uses: HaaLeo/publish-vscode-extension@ca5561daa085dee804bf9f37fe0165785a9b14db # v2 - with: - pat: ${{ env.VS_MARKETPLACE_TOKEN }} - registryUrl: https://marketplace.visualstudio.com - - # Publishes to Open VSX Registry (skipped until OPEN_VSX_TOKEN is set) - - name: Publish to Open VSX Registry - if: ${{ env.OPEN_VSX_TOKEN != '' }} - continue-on-error: true - uses: HaaLeo/publish-vscode-extension@ca5561daa085dee804bf9f37fe0165785a9b14db # v2 - with: - pat: ${{ env.OPEN_VSX_TOKEN }} - registryUrl: https://open-vsx.org diff --git a/.github/workflows/sync-nightly.yml b/.github/workflows/sync-nightly.yml deleted file mode 100644 index d66520c..0000000 --- a/.github/workflows/sync-nightly.yml +++ /dev/null @@ -1,64 +0,0 @@ -name: Sync development to nightly - -# Direct fast-forward sync, not a PR — nightly is meant to track development -# continuously without manual review. (nightly -> main is the reviewed step, -# in promote-nightly-to-main.yml.) -# -# Uses CI_TRIGGER_TOKEN if set, falling back to GITHUB_TOKEN. The fallback -# works, but a push made with the default GITHUB_TOKEN doesn't trigger other -# workflows — so without CI_TRIGGER_TOKEN, CI won't run on nightly until -# something else touches it (e.g. the promotion PR). Set a PAT with repo -# write access as CI_TRIGGER_TOKEN to get CI running on nightly right away. - -on: - schedule: - - cron: '17 9 * * *' # daily ~09:17 UTC, off the top of the hour - workflow_dispatch: - -concurrency: - group: ${{ github.workflow }} - cancel-in-progress: false - -permissions: - contents: write - -jobs: - sync: - runs-on: ubuntu-latest - steps: - - name: Checkout nightly - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - with: - ref: nightly - fetch-depth: 0 - token: ${{ secrets.CI_TRIGGER_TOKEN || secrets.GITHUB_TOKEN }} - - - name: Configure git - run: | - git config user.name "github-actions[bot]" - git config user.email "github-actions[bot]@users.noreply.github.com" - - - name: Fast-forward nightly to development - env: - HAS_TRIGGER_TOKEN: ${{ secrets.CI_TRIGGER_TOKEN != '' }} - run: | - git fetch origin development nightly - git reset --hard origin/nightly - - if git diff --quiet origin/nightly origin/development; then - echo "nightly already matches development — nothing to sync" - exit 0 - fi - - if [[ "$HAS_TRIGGER_TOKEN" != "true" ]]; then - echo "::warning title=Using GITHUB_TOKEN fallback::Set CI_TRIGGER_TOKEN so this push triggers CI on nightly." - fi - - # Neither branch of this ever creates a new commit — nightly just - # becomes a ref pointing at development's exact SHA, so CI (now - # triggered on push to nightly too) runs against it normally. - git merge origin/development --ff-only || { - echo "Fast-forward not possible — resetting nightly to development" - git reset --hard origin/development - } - git push --force origin nightly From 012ad5d221b53b439d9c81068114b266eb202a92 Mon Sep 17 00:00:00 2001 From: Wikid82 Date: Tue, 11 Aug 2026 18:58:43 -0400 Subject: [PATCH 5/9] chore: schedule Renovate PRs for Monday morning Eastern Keeps dependency-update PRs from landing mid-week or over the weekend; they'll be waiting, green-or-red, when the week starts. --- .github/renovate.json | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/renovate.json b/.github/renovate.json index 6f6032e..2f1977d 100644 --- a/.github/renovate.json +++ b/.github/renovate.json @@ -10,6 +10,7 @@ "postUpdateOptions": ["npmDedupe"], "skipInstalls": false, "timezone": "America/New_York", + "schedule": ["before 7am on monday"], "dependencyDashboard": true, "dependencyDashboardApproval": true, "prConcurrentLimit": 10, From 1b713271c092980e487e3e96bd6d93ee68bcfd62 Mon Sep 17 00:00:00 2001 From: Wikid82 Date: Tue, 11 Aug 2026 18:58:59 -0400 Subject: [PATCH 6/9] chore: let vulnerability alerts bypass the Monday-only schedule Security patches shouldn't wait up to 6 days for the weekly window. --- .github/renovate.json | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/renovate.json b/.github/renovate.json index 2f1977d..3217d55 100644 --- a/.github/renovate.json +++ b/.github/renovate.json @@ -24,6 +24,7 @@ "platformAutomerge": true, "vulnerabilityAlerts": { "enabled": true, + "schedule": ["at any time"], "dependencyDashboardApproval": false, "automerge": false, "labels": ["security", "vulnerability"] From aab7414ca9d8b21bb2344eb63aec9766306f746b Mon Sep 17 00:00:00 2001 From: Wikid82 Date: Tue, 11 Aug 2026 19:05:11 -0400 Subject: [PATCH 7/9] chore: revert Monday-only Renovate schedule MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit dependencyDashboardApproval already gates PR creation on manual checkbox approval, which is the actual behavior wanted — approve and test when there's time, not wait for a cron window. The schedule was redundant and would have delayed PR creation past approval. --- .github/renovate.json | 2 -- 1 file changed, 2 deletions(-) diff --git a/.github/renovate.json b/.github/renovate.json index 3217d55..6f6032e 100644 --- a/.github/renovate.json +++ b/.github/renovate.json @@ -10,7 +10,6 @@ "postUpdateOptions": ["npmDedupe"], "skipInstalls": false, "timezone": "America/New_York", - "schedule": ["before 7am on monday"], "dependencyDashboard": true, "dependencyDashboardApproval": true, "prConcurrentLimit": 10, @@ -24,7 +23,6 @@ "platformAutomerge": true, "vulnerabilityAlerts": { "enabled": true, - "schedule": ["at any time"], "dependencyDashboardApproval": false, "automerge": false, "labels": ["security", "vulnerability"] From c0e82dff7033ba527cb584385c6139027a2a0796 Mon Sep 17 00:00:00 2001 From: Wikid82 Date: Tue, 11 Aug 2026 19:11:10 -0400 Subject: [PATCH 8/9] feat: publish pre-release builds from development development is now the beta channel: every push publishes a pre-release build (odd-minor versioning per VS Code's convention) that users can opt into via 'Switch to Pre-Release Version', while main stays the verified/stable track. --- .github/workflows/prerelease.yml | 72 ++++++++++++++++++++++++++++++++ 1 file changed, 72 insertions(+) create mode 100644 .github/workflows/prerelease.yml diff --git a/.github/workflows/prerelease.yml b/.github/workflows/prerelease.yml new file mode 100644 index 0000000..91348ef --- /dev/null +++ b/.github/workflows/prerelease.yml @@ -0,0 +1,72 @@ +name: Publish Pre-Release + +# development is the beta/nightly channel: every push here (an approved dependency +# bump, a feature merge, a main->development sync) publishes a pre-release build +# that users can opt into via "Switch to Pre-Release Version" in the Extensions view. +# +# Version follows VS Code Marketplace's odd/even convention (stable package.json +# version is even-minor, e.g. 1.4.x) so pre-release users are always offered a +# higher version than the current stable one: minor is bumped to the next odd +# number, and patch is the workflow run number so it's always increasing. This is +# computed at publish time only and never committed back to package.json/git. + +on: + push: + branches: [development] + workflow_dispatch: + +permissions: + contents: read + +jobs: + prerelease: + runs-on: ubuntu-latest + env: + VS_MARKETPLACE_TOKEN: ${{ secrets.VS_MARKETPLACE_TOKEN }} + OPEN_VSX_TOKEN: ${{ secrets.OPEN_VSX_TOKEN }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + + - name: Setup Node.js + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 + with: + node-version: 24.19.0 + cache: 'npm' + + - name: Install dependencies + run: npm ci + + - name: Compute pre-release version + id: version + run: | + CURRENT=$(node -p "require('./package.json').version") + IFS='.' read -r MAJOR MINOR _PATCH <<< "$CURRENT" + if [ $((MINOR % 2)) -eq 0 ]; then + MINOR=$((MINOR + 1)) + fi + VERSION="${MAJOR}.${MINOR}.${{ github.run_number }}" + echo "Stable version is ${CURRENT}; publishing pre-release ${VERSION}" + echo "version=${VERSION}" >> "$GITHUB_OUTPUT" + + - name: Set package version (not committed) + run: npm version ${{ steps.version.outputs.version }} --no-git-tag-version --allow-same-version + + - name: Publish to Visual Studio Marketplace + if: ${{ env.VS_MARKETPLACE_TOKEN != '' }} + continue-on-error: true + uses: HaaLeo/publish-vscode-extension@ca5561daa085dee804bf9f37fe0165785a9b14db # v2 + with: + pat: ${{ env.VS_MARKETPLACE_TOKEN }} + registryUrl: https://marketplace.visualstudio.com + preRelease: true + skipDuplicate: true + + - name: Publish to Open VSX Registry + if: ${{ env.OPEN_VSX_TOKEN != '' }} + continue-on-error: true + uses: HaaLeo/publish-vscode-extension@ca5561daa085dee804bf9f37fe0165785a9b14db # v2 + with: + pat: ${{ env.OPEN_VSX_TOKEN }} + registryUrl: https://open-vsx.org + preRelease: true + skipDuplicate: true From aa0de282ae6e6689ebf20cabe99cca449e729fdd Mon Sep 17 00:00:00 2001 From: Wikid82 Date: Tue, 11 Aug 2026 19:15:57 -0400 Subject: [PATCH 9/9] fix: restore release-please.yml, drop redundant propagation workflow release-please.yml was accidentally removed in 83841ab alongside the obsolete nightly workflows -- it's what cuts stable releases and publishes to the Marketplace/Open VSX, so it needs to stay on main. sync-main-to-development.yml duplicated propagate-main-to-development.yml (same trigger, same job); keeping the latter since it reuses an existing open PR instead of force-pushing a branch, and labels it. --- .github/workflows/release-please.yml | 110 ++++++++++++++++++ .../workflows/sync-main-to-development.yml | 49 -------- 2 files changed, 110 insertions(+), 49 deletions(-) create mode 100644 .github/workflows/release-please.yml delete mode 100644 .github/workflows/sync-main-to-development.yml diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml new file mode 100644 index 0000000..1ef3843 --- /dev/null +++ b/.github/workflows/release-please.yml @@ -0,0 +1,110 @@ +name: release-please + +on: + push: + branches: [main] + workflow_dispatch: + +permissions: + contents: write + pull-requests: write + +jobs: + release-please: + runs-on: ubuntu-latest + outputs: + release_created: ${{ steps.release.outputs.release_created }} + tag_name: ${{ steps.release.outputs.tag_name }} + upload_url: ${{ steps.release.outputs.upload_url }} + steps: + - uses: googleapis/release-please-action@45996ed1f6d02564a971a2fa1b5860e934307cf7 # v5 + id: release + with: + config-file: release-please-config.json + manifest-file: .release-please-manifest.json + + # Runs only when release-please actually cut a new release (i.e. a release PR was merged). + # Chained onto this workflow (rather than triggered by the tag it pushes) because tags/releases + # created via the default GITHUB_TOKEN do not trigger other workflows — a separate tag-triggered + # workflow would silently never run. + publish: + needs: release-please + if: ${{ needs.release-please.outputs.release_created == 'true' }} + runs-on: ubuntu-latest + permissions: + contents: write + env: + VS_MARKETPLACE_TOKEN: ${{ secrets.VS_MARKETPLACE_TOKEN }} + OPEN_VSX_TOKEN: ${{ secrets.OPEN_VSX_TOKEN }} + steps: + - name: Checkout release tag + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + ref: ${{ needs.release-please.outputs.tag_name }} + + - name: Setup Node.js + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 + with: + node-version: 24.19.0 + cache: 'npm' + + - name: Install dependencies + run: npm ci + + - name: Build + run: npm run build + + - name: Package extension + run: npx @vscode/vsce package + + # release-please-config.json sets draft:true for this package specifically so this step can + # run at all: GitHub's immutable-releases feature locks a release the instant it's published, + # so assets can only be attached while it's still a draft. We un-draft it in the next step, + # once the vsix is safely attached. + # + # GitHub's "get release by tag" API doesn't resolve drafts, so `gh release upload ` + # can't find this release yet. Using upload_url (which release-please-action already gives + # us straight from the create-release response) sidesteps that lookup entirely. + # + # Lets users grab the .vsix straight from the GitHub Release and "Install from VSIX...", + # no Marketplace/Open VSX account or token needed for this path. + - name: Attach VSIX to GitHub Release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + UPLOAD_URL: ${{ needs.release-please.outputs.upload_url }} + run: | + VSIX_FILE=$(ls *.vsix) + BASE_URL="${UPLOAD_URL%%\{*}" + curl -sSf -X POST \ + -H "Authorization: token ${GH_TOKEN}" \ + -H "Content-Type: application/octet-stream" \ + --data-binary @"${VSIX_FILE}" \ + "${BASE_URL}?name=${VSIX_FILE}" + + - name: Publish GitHub Release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + UPLOAD_URL: ${{ needs.release-please.outputs.upload_url }} + run: | + RELEASE_ID=$(echo "${UPLOAD_URL}" | sed -E 's#.*/releases/([0-9]+)/.*#\1#') + gh api -X PATCH "repos/${{ github.repository }}/releases/${RELEASE_ID}" -f draft=false + + # Publishes to the official VS Code Marketplace (skipped until VS_MARKETPLACE_TOKEN is set). + # continue-on-error so a failure here (e.g. re-running on an already-published version) + # doesn't prevent the independent Open VSX step below from running. + - name: Publish to Visual Studio Marketplace + if: ${{ env.VS_MARKETPLACE_TOKEN != '' }} + continue-on-error: true + uses: HaaLeo/publish-vscode-extension@ca5561daa085dee804bf9f37fe0165785a9b14db # v2 + with: + pat: ${{ env.VS_MARKETPLACE_TOKEN }} + registryUrl: https://marketplace.visualstudio.com + + # Publishes to Open VSX Registry (skipped until OPEN_VSX_TOKEN is set) + - name: Publish to Open VSX Registry + if: ${{ env.OPEN_VSX_TOKEN != '' }} + continue-on-error: true + uses: HaaLeo/publish-vscode-extension@ca5561daa085dee804bf9f37fe0165785a9b14db # v2 + with: + pat: ${{ env.OPEN_VSX_TOKEN }} + registryUrl: https://open-vsx.org diff --git a/.github/workflows/sync-main-to-development.yml b/.github/workflows/sync-main-to-development.yml deleted file mode 100644 index 30ed8ea..0000000 --- a/.github/workflows/sync-main-to-development.yml +++ /dev/null @@ -1,49 +0,0 @@ -name: Sync main to development - -# Direct pushes/hotfixes (and release-please's own version-bump commits) land on main -# without ever touching development. This keeps development from drifting by opening -# a PR that merges main back in whenever main moves ahead. - -on: - push: - branches: [main] - -permissions: - contents: write - pull-requests: write - -jobs: - sync: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - with: - fetch-depth: 0 - - - name: Sync main into development - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - set -euo pipefail - git config user.name "github-actions[bot]" - git config user.email "github-actions[bot]@users.noreply.github.com" - - AHEAD=$(git rev-list --count origin/development..origin/main) - if [ "$AHEAD" -eq 0 ]; then - echo "development is already up to date with main; nothing to sync." - exit 0 - fi - - BRANCH="sync/main-to-development" - git checkout -B "$BRANCH" origin/development - git merge origin/main --no-edit - - git push origin "$BRANCH" --force - - if gh pr list --base development --head "$BRANCH" --state open --json number -q '.[0].number' | grep -q .; then - echo "Sync PR already open; branch push above updated it." - else - gh pr create --base development --head "$BRANCH" \ - --title "chore: sync main into development" \ - --body "Automated sync after a push to main (hotfix, direct push, or release-please version bump)." - fi