From 07ee0a602a8a1035ab8240aad7d5ba00742499e4 Mon Sep 17 00:00:00 2001 From: Tunglies <77394545+Tunglies@users.noreply.github.com> Date: Sun, 1 Nov 2026 21:21:05 +0800 Subject: [PATCH] ci(release): unify RC and stable publication through dev --- .github/workflows/prepare-release.yml | 79 +++ .github/workflows/release.yml | 187 ++++--- eslint.config.ts | 8 + scripts-workflow/release.mjs | 761 ++++++++++++++++++++++++++ 4 files changed, 965 insertions(+), 70 deletions(-) create mode 100644 .github/workflows/prepare-release.yml create mode 100644 scripts-workflow/release.mjs diff --git a/.github/workflows/prepare-release.yml b/.github/workflows/prepare-release.yml new file mode 100644 index 000000000..912b7c821 --- /dev/null +++ b/.github/workflows/prepare-release.yml @@ -0,0 +1,79 @@ +name: Prepare Release + +on: + pull_request_target: + types: [edited, closed] + branches: [dev] + workflow_dispatch: + inputs: + channel: + description: Release channel + required: true + type: choice + options: [rc, stable] + default: rc + release_tag: + description: 'Optional tag: vX.Y.Z-rc[.N] for RC, vX.Y.Z for stable' + required: false + type: string + +permissions: + contents: write + pull-requests: write + +concurrency: + group: prepare-release + cancel-in-progress: false + +jobs: + prepare: + if: >- + ${{ github.event_name == 'workflow_dispatch' || + (github.event.pull_request.head.repo.full_name == github.repository && + github.event.pull_request.user.login == 'github-actions[bot]' && + ((startsWith(github.event.pull_request.head.ref, 'release/v') && + ((github.event.action == 'closed' && github.event.pull_request.merged == true) || + (github.event.changes.body && github.event.pull_request.state == 'open' && + contains(github.event.pull_request.body, '[x] Refresh this PR from latest dev')))) || + (startsWith(github.event.pull_request.head.ref, 'next-dev/v') && + github.event.changes.body && github.event.pull_request.state == 'open' && + (contains(github.event.pull_request.body, '[x] Refresh this PR from latest dev') || + contains(github.event.pull_request.body, '[x] Use the next minor version') != + contains(github.event.changes.body.from, '[x] Use the next minor version'))))) }} + runs-on: ubuntu-latest + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + steps: + - name: Require the dev branch + if: github.event_name == 'workflow_dispatch' + env: + SOURCE_REF: ${{ github.ref }} + run: | + if [[ "$SOURCE_REF" != 'refs/heads/dev' ]]; then + echo '::error::Run Prepare Release from the dev branch.' + exit 1 + fi + + - uses: actions/checkout@v7 + with: + ref: refs/heads/dev + fetch-depth: 0 + + - uses: actions/setup-node@v7 + with: + node-version: '26.13.0' + + - uses: pnpm/action-setup@v6 + if: github.event.action != 'closed' + with: + run_install: false + + - run: pnpm install --frozen-lockfile --ignore-scripts + if: github.event.action != 'closed' + + - name: Create or refresh version or publication PR + env: + RELEASE_TAG: ${{ inputs.release_tag }} + RELEASE_CHANNEL: ${{ inputs.channel }} + RELEASE_COMMAND: ${{ github.event.action == 'closed' && 'publish-pr' || startsWith(github.event.pull_request.head.ref, 'next-dev/') && 'next-dev' || 'prepare' }} + run: node scripts-workflow/release.mjs "$RELEASE_COMMAND" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 16c8aaed4..335f99087 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,6 +1,9 @@ name: Release Build on: + pull_request: + types: [edited] + branches: [main] push: # -rc tag 时预览发布, 跳过 telegram 通知、跳过 winget 提交、跳过 latest.json 文件更新 tags: @@ -11,75 +14,59 @@ env: RUST_BACKTRACE: short HUSKY: 0 concurrency: - group: '${{ github.workflow }} - ${{ github.head_ref || github.ref }}' - cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} + group: release-publication + cancel-in-progress: false jobs: - check_tag_version: - name: Check Release Tag and package.json Version Consistency + prepare_release: + name: Approve Snapshot and Prepare Draft Release + if: >- + ${{ github.event_name == 'push' || + (github.event.changes.body && + github.event.pull_request.head.repo.full_name == github.repository && + github.event.pull_request.user.login == 'github-actions[bot]' && + startsWith(github.event.pull_request.head.ref, 'publish/v') && + contains(github.event.pull_request.body, '[x] Publish this approved release')) }} runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: read + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + outputs: + release_tag: ${{ steps.source.outputs.release_tag }} + source_sha: ${{ steps.source.outputs.source_sha }} + channel: ${{ steps.source.outputs.channel }} + release_id: ${{ steps.draft.outputs.id }} steps: - - name: Checkout repository + - name: Checkout trusted release tooling uses: actions/checkout@v7 with: + ref: refs/heads/dev fetch-depth: 0 - - - name: Check if tag is from main branch - run: | - TAG_REF="${GITHUB_REF##*/}" - echo "Checking if tag $TAG_REF is from main branch..." - - TAG_COMMIT=$(git rev-list -n 1 $TAG_REF) - MAIN_COMMITS=$(git rev-list origin/main) - - if echo "$MAIN_COMMITS" | grep -q "$TAG_COMMIT"; then - echo "✅ Tag $TAG_REF is from main branch" - else - echo "❌ Tag $TAG_REF is not from main branch" - echo "This release workflow only accepts tags from main branch." - exit 1 - fi - - - name: Check tag and package.json version - run: | - TAG_REF="${GITHUB_REF_NAME:-${GITHUB_REF##*/}}" - echo "Current tag: $TAG_REF" - - PKG_VERSION=$(jq -r .version package.json) - echo "package.json version: $PKG_VERSION" - - EXPECTED_TAG="v$PKG_VERSION" - - if [[ "$TAG_REF" != "$EXPECTED_TAG" ]]; then - echo "❌ Version mismatch:" - echo " Git tag : $TAG_REF" - echo " package.json : $EXPECTED_TAG" - exit 1 - fi - - echo "✅ Tag and package.json version are consistent." - - prepare_release: - name: Prepare Draft Release - runs-on: ubuntu-latest - needs: [check_tag_version] - outputs: - release_id: ${{ steps.prepare_release.outputs.id }} - steps: - - name: Create or update draft release - id: prepare_release - uses: softprops/action-gh-release@v3 + - uses: actions/setup-node@v7 with: - tag_name: ${{ github.ref_name }} - target_commitish: ${{ github.sha }} - name: 'Clash Verge Rev ${{ github.ref_name }}' - body: 'Draft release, will be updated later.' - token: ${{ secrets.GITHUB_TOKEN }} - draft: true - prerelease: ${{ contains(github.ref_name, '-rc') }} + node-version: '26.13.0' + - name: Validate source and publish the approved snapshot + id: source + env: + RELEASE_COMMAND: ${{ github.event_name == 'push' && 'validate-tag' || 'publish' }} + run: node scripts-workflow/release.mjs "$RELEASE_COMMAND" + + - name: Create or reuse draft release + id: draft + env: + RELEASE_TAG: ${{ steps.source.outputs.release_tag }} + SOURCE_SHA: ${{ steps.source.outputs.source_sha }} + run: node scripts-workflow/release.mjs prepare-draft update_tag: - name: Update tag + name: Publish Completed Release + env: + RELEASE_TAG: ${{ needs.prepare_release.outputs.release_tag }} + outputs: + source_sha: ${{ needs.prepare_release.outputs.source_sha }} + channel: ${{ needs.prepare_release.outputs.channel }} runs-on: ubuntu-latest needs: [ @@ -91,6 +78,8 @@ jobs: steps: - name: Checkout repository uses: actions/checkout@v7 + with: + ref: ${{ needs.prepare_release.outputs.source_sha }} - name: Group changelog platforms run: bash ./scripts-workflow/group_platforms.sh @@ -103,7 +92,7 @@ jobs: - name: Set Env run: | echo "BUILDTIME=$(TZ=Asia/Shanghai date)" >> $GITHUB_ENV - TAG_REF="${GITHUB_REF##*/}" + TAG_REF="$RELEASE_TAG" echo "TAG_NAME=$TAG_REF" >> $GITHUB_ENV VERSION=$(echo "$TAG_REF" | sed 's/^v//') echo "VERSION=$VERSION" >> $GITHUB_ENV @@ -149,10 +138,16 @@ jobs: Created at ${{ env.BUILDTIME }}. EOF + - name: Verify release tag still points to the approved source + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + SOURCE_SHA: ${{ needs.prepare_release.outputs.source_sha }} + run: node scripts-workflow/release.mjs verify-target + - name: Publish Release env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - PRERELEASE: ${{ contains(github.ref_name, '-rc') }} + PRERELEASE: ${{ contains(env.RELEASE_TAG, '-rc') }} run: | release_id="${{ needs.prepare_release.outputs.release_id }}" @@ -181,6 +176,8 @@ jobs: release: name: Release Build needs: [prepare_release] + env: + RELEASE_TAG: ${{ needs.prepare_release.outputs.release_tag }} strategy: fail-fast: false matrix: @@ -200,6 +197,8 @@ jobs: steps: - name: Checkout Repository uses: actions/checkout@v7 + with: + ref: ${{ needs.prepare_release.outputs.source_sha }} - name: Install Rust Stable uses: dtolnay/rust-toolchain@master @@ -249,7 +248,6 @@ jobs: echo "Rust target ${{ matrix.target }} installed." - name: Tauri build - # 上游 5.24 修改了 latest.json 的生成逻辑,且依赖 tauri-plugin-update 2.10.0 暂未发布,故锁定在 0.5.23 版本 uses: tauri-apps/tauri-action@v1.0.0 env: NODE_OPTIONS: '--max_old_space_size=4096' @@ -264,11 +262,14 @@ jobs: APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} with: releaseId: ${{ needs.prepare_release.outputs.release_id }} - tagName: ${{ github.ref_name }} + tagName: ${{ env.RELEASE_TAG }} releaseBody: 'Draft release, will be updated later.' + releaseDraft: true + prerelease: ${{ needs.prepare_release.outputs.channel == 'rc' }} + releaseCommitish: ${{ needs.prepare_release.outputs.source_sha }} tauriScript: pnpm args: --target ${{ matrix.target }} - includeUpdaterJson: true + uploadUpdaterJson: ${{ needs.prepare_release.outputs.channel == 'stable' }} - name: Attest Windows bundles if: matrix.os == 'windows-latest' @@ -293,6 +294,8 @@ jobs: release-for-linux-arm: name: Release Build for Linux ARM needs: [prepare_release] + env: + RELEASE_TAG: ${{ needs.prepare_release.outputs.release_tag }} strategy: fail-fast: false matrix: @@ -307,6 +310,8 @@ jobs: steps: - name: Checkout Repository uses: actions/checkout@v7 + with: + ref: ${{ needs.prepare_release.outputs.source_sha }} - name: Install Rust Stable uses: dtolnay/rust-toolchain@master @@ -420,7 +425,7 @@ jobs: env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | - gh release upload "${{ github.ref_name }}" \ + gh release upload "$RELEASE_TAG" \ target/${{ matrix.target }}/release/bundle/deb/*.deb \ target/${{ matrix.target }}/release/bundle/rpm/*.rpm \ --clobber @@ -428,6 +433,8 @@ jobs: release-for-fixed-webview2: name: Release Build for Fixed WebView2 needs: [prepare_release] + env: + RELEASE_TAG: ${{ needs.prepare_release.outputs.release_tag }} strategy: fail-fast: false matrix: @@ -442,6 +449,8 @@ jobs: steps: - name: Checkout Repository uses: actions/checkout@v7 + with: + ref: ${{ needs.prepare_release.outputs.source_sha }} - name: Install Rust Stable uses: dtolnay/rust-toolchain@master @@ -532,17 +541,50 @@ jobs: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | $files = Get-ChildItem "target\${{ matrix.target }}\release\bundle\nsis\*setup*" | Select-Object -ExpandProperty FullName - gh release upload "${{ github.ref_name }}" $files --clobber + gh release upload "$env:RELEASE_TAG" $files --clobber if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + prepare-next-dev: + name: Prepare Next Dev Version + if: ${{ needs.update_tag.outputs.channel == 'stable' }} + needs: [prepare_release, update_tag] + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write + concurrency: + group: prepare-release + cancel-in-progress: false + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + RELEASE_TAG: ${{ needs.prepare_release.outputs.release_tag }} + SOURCE_SHA: ${{ needs.prepare_release.outputs.source_sha }} + steps: + - uses: actions/checkout@v7 + with: + ref: refs/heads/dev + fetch-depth: 0 + - uses: actions/setup-node@v7 + with: + node-version: '26.13.0' + - uses: pnpm/action-setup@v6 + with: + run_install: false + - name: Prepare version and changelog PR + run: | + pnpm install --frozen-lockfile --ignore-scripts + node scripts-workflow/release.mjs next-dev + release-update: - if: ${{ !contains(github.ref_name, '-rc') }} + if: ${{ needs.update_tag.outputs.channel == 'stable' }} name: Release Update runs-on: ubuntu-latest needs: [update_tag] steps: - name: Checkout repository uses: actions/checkout@v7 + with: + ref: ${{ needs.update_tag.outputs.source_sha }} - name: Install Node uses: actions/setup-node@v7 @@ -563,12 +605,14 @@ jobs: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} release-update-for-fixed-webview2: - if: ${{ !contains(github.ref_name, '-rc') }} + if: ${{ needs.update_tag.outputs.channel == 'stable' }} runs-on: ubuntu-latest needs: [update_tag] steps: - name: Checkout repository uses: actions/checkout@v7 + with: + ref: ${{ needs.update_tag.outputs.source_sha }} - name: Install Node uses: actions/setup-node@v7 @@ -589,7 +633,7 @@ jobs: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} submit-to-winget: - if: ${{ !contains(github.ref_name, '-rc') }} + if: ${{ needs.update_tag.outputs.channel == 'stable' }} name: Submit to Winget runs-on: ubuntu-latest needs: [update_tag, release-update] @@ -597,6 +641,7 @@ jobs: - name: Checkout repository uses: actions/checkout@v7 with: + ref: ${{ needs.update_tag.outputs.source_sha }} fetch-depth: 0 - name: Get Version run: | @@ -613,7 +658,7 @@ jobs: token: ${{ secrets.WINGET_TOKEN }} notify-telegram: - if: ${{ !contains(github.ref_name, '-rc') }} + if: ${{ needs.update_tag.outputs.channel == 'stable' }} name: Notify Telegram runs-on: ubuntu-latest needs: @@ -626,6 +671,8 @@ jobs: steps: - name: Checkout repository uses: actions/checkout@v7 + with: + ref: ${{ needs.update_tag.outputs.source_sha }} - name: Group changelog platforms run: bash ./scripts-workflow/group_platforms.sh diff --git a/eslint.config.ts b/eslint.config.ts index 1e1840416..2f3aa9dc0 100644 --- a/eslint.config.ts +++ b/eslint.config.ts @@ -129,6 +129,7 @@ export default defineConfig([ 'eslint.config.ts', `vite.config.mts`, 'scripts/*.mjs', + 'scripts-workflow/*.mjs', 'tests/*.ts', 'src/polyfills/*.js', 'tests/*.mjs', @@ -238,6 +239,13 @@ export default defineConfig([ }, }, }, + { + files: ['scripts-workflow/*.mjs'], + languageOptions: { + globals: globals.node, + }, + rules: { 'es-x/no-object-groupby': 'off' }, + }, { files: ['scripts/perf/*.{mjs,mts}'], languageOptions: { diff --git a/scripts-workflow/release.mjs b/scripts-workflow/release.mjs new file mode 100644 index 000000000..ed0a63c0d --- /dev/null +++ b/scripts-workflow/release.mjs @@ -0,0 +1,761 @@ +import { execFileSync } from 'node:child_process' +import { appendFileSync, readFileSync, writeFileSync } from 'node:fs' + +const env = process.env +const repository = env.GITHUB_REPOSITORY +const refreshLabel = 'Refresh this PR from latest dev' +const publishLabel = 'Publish this approved release' +const minorLabel = 'Use the next minor version' +const checked = (body, label = refreshLabel) => + (body || '') + .split('\n') + .some((line) => line.toLowerCase() === `- [x] ${label}`.toLowerCase()) +const versionPattern = '(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)' +const rcPattern = new RegExp(`^v${versionPattern}-rc(?:\\.(0|[1-9][0-9]*))?$`) +const stablePattern = new RegExp(`^v${versionPattern}$`) +const versionFiles = [ + 'package.json', + 'src-tauri/tauri.conf.json', + 'src-tauri/Cargo.toml', + 'Cargo.lock', +] +const read = (path) => readFileSync(path, 'utf8') +const json = (path) => JSON.parse(read(path)) +const run = (command, args, options = {}) => + execFileSync(command, args, { encoding: 'utf8', ...options }).trim() +const git = (...args) => run('git', args) +const gitFile = (sha, path) => + execFileSync('git', ['show', `${sha}:${path}`], { encoding: 'utf8' }) +const isRc = (tag) => tag.match(rcPattern)?.[0] === tag +const isRelease = (tag) => isRc(tag) || stablePattern.test(tag) +const titleFor = (tag) => `${isRc(tag) ? 'Pre-Release' : 'Release'}: ${tag}` +const branchTag = (branch, prefix = 'release/') => + branch.startsWith(prefix) && isRelease(branch.slice(prefix.length)) + ? branch.slice(prefix.length) + : undefined +const latest = (tags) => + tags.sort((a, b) => a.localeCompare(b, 'en', { numeric: true })).at(-1) +const summary = (text) => appendFileSync(env.GITHUB_STEP_SUMMARY, `${text}\n`) +const endpoint = (path) => + path === 'graphql' ? path : `repos/${repository}/${path}` +const list = (path) => + JSON.parse(run('gh', ['api', '--paginate', '--slurp', endpoint(path)])).flat() + +function api(path, body, method = 'POST') { + const response = run( + 'gh', + ['api', '--method', method, endpoint(path), '--input', '-'], + { + input: JSON.stringify(body), + }, + ) + const result = response ? JSON.parse(response) : null + if (result?.errors) throw new Error(JSON.stringify(result.errors)) + return result +} + +const get = (path) => JSON.parse(run('gh', ['api', endpoint(path)])) +const canWrite = (login) => + ['write', 'maintain', 'admin'].includes( + get(`collaborators/${login}/permission`).permission, + ) + +function output(values) { + appendFileSync( + env.GITHUB_OUTPUT, + Object.entries(values) + .map(([key, value]) => `${key}=${value}\n`) + .join(''), + ) +} + +function versionPatternFor(path) { + if (path.endsWith('.json')) return /("version"\s*:\s*")[^"]+/ + return path === 'Cargo.lock' + ? /(\[\[package\]\]\r?\nname = "clash-verge"\r?\nversion = ")[^"]+/ + : /(^version\s*=\s*")[^"]+/m +} + +function validateVersions(tag, sha) { + for (const path of versionFiles) { + const content = sha ? gitFile(sha, path) : read(path) + const version = path.endsWith('.json') + ? JSON.parse(content).version + : content.match(versionPatternFor(path))?.[0].split('"').at(-1) + if (`v${version}` !== tag) + throw new Error(`${path} version does not match ${tag}.`) + } +} + +function validateVersionCommit(tag, sha) { + validateVersions(tag, sha) + const commit = get(`commits/${sha}`) + if (!commit.commit.verification.verified || commit.parents.length !== 1) + throw new Error('The version commit must be signed and have one parent.') + const title = commit.commit.message.split('\n')[0] + if (title.replace(/ \(#\d+\)$/, '') !== titleFor(tag)) + throw new Error('The final version commit has an unexpected title.') + const parent = commit.parents[0].sha + const changed = git('diff', '--name-only', parent, sha) + .split('\n') + .filter(Boolean) + if ( + changed.some((path) => !versionFiles.includes(path)) || + git('diff', '--summary', parent, sha) + ) + throw new Error( + 'The version commit changes files outside the four version files.', + ) + for (const path of versionFiles) { + const before = gitFile(parent, path) + const after = gitFile(sha, path) + if ( + before.replace(versionPatternFor(path), '$1VERSION') !== + after.replace(versionPatternFor(path), '$1VERSION') + ) + throw new Error(`${path} contains changes other than its version.`) + } +} + +function remoteRefs() { + return git('ls-remote', '--heads', '--tags', '--refs', 'origin') + .split('\n') + .filter(Boolean) + .map((line) => line.split('\t')) +} + +function fetchBranches() { + git( + 'fetch', + '--no-tags', + 'origin', + '+refs/heads/dev:refs/remotes/origin/dev', + '+refs/heads/main:refs/remotes/origin/main', + ) +} + +function prepare() { + let refresh + if (env.GITHUB_EVENT_NAME === 'pull_request_target') { + const event = json(env.GITHUB_EVENT_PATH) + if (checked(event.changes?.body?.from) || !checked(event.pull_request.body)) + return + if (!canWrite(event.sender.login)) + throw new Error('Refreshing a version PR requires write access.') + refresh = get(`pulls/${event.pull_request.number}`) + if (refresh.state !== 'open' || !checked(refresh.body)) return + requireBotPr(refresh, 'dev', 'release/') + } + fetchBranches() + git('checkout', '--detach', 'origin/dev') + const channel = refresh + ? isRc(branchTag(refresh.head.ref)) + ? 'rc' + : 'stable' + : env.RELEASE_CHANNEL || 'rc' + if (!['rc', 'stable'].includes(channel)) + throw new Error('Unknown release channel.') + const requested = refresh + ? branchTag(refresh.head.ref) + : env.RELEASE_TAG || '' + if ( + requested && + !(channel === 'rc' ? isRc(requested) : stablePattern.test(requested)) + ) + throw new Error( + channel === 'rc' + ? 'Expected vX.Y.Z-rc or vX.Y.Z-rc.N.' + : 'Expected vX.Y.Z.', + ) + const existing = + !refresh && + list('pulls?state=open&base=dev&per_page=100').find( + (pr) => + pr.head.repo?.full_name === repository && + pr.user.login === 'github-actions[bot]' && + branchTag(pr.head.ref) && + isRc(branchTag(pr.head.ref)) === (channel === 'rc') && + (!requested || branchTag(pr.head.ref) === requested), + ) + if (existing) { + api(`issues/${existing.number}/labels`, { labels: ['release'] }) + output({ existing_pr: existing.html_url }) + summary( + `Existing version PR: ${existing.html_url}\nClose it before preparing a replacement; its candidate is unchanged.`, + ) + return + } + const base = (requested || `v${json('package.json').version}`).split( + /[-+]/, + )[0] + if (!stablePattern.test(base)) + throw new Error(`Invalid base version: ${base}`) + const refs = remoteRefs().map(([, ref]) => ref) + const tags = refs + .filter((ref) => ref.startsWith('refs/tags/')) + .map((ref) => ref.slice(10)) + const reserved = refs + .filter((ref) => ref.startsWith('refs/heads/')) + .filter((ref) => !refresh || ref !== `refs/heads/${refresh.head.ref}`) + .map( + (ref) => branchTag(ref.slice(11)) || branchTag(ref.slice(11), 'publish/'), + ) + .filter(Boolean) + const sameBase = (tag) => isRc(tag) && tag.startsWith(`${base}-rc`) + const occupied = [...tags, ...reserved] + const last = latest(occupied.filter(sameBase)) + const tag = + requested || + (channel === 'stable' + ? base + : last + ? `${base}-rc.${BigInt(last.match(rcPattern)[4] ?? 0) + 1n}` + : `${base}-rc`) + if (occupied.includes(tag)) + throw new Error(`Tag ${tag} already exists or is reserved.`) + const sha = git('rev-parse', 'HEAD') + const body = releaseBody(tag, sha, tags, false) + summary(body) + if ( + remoteRefs().some( + ([, ref]) => + ref === `refs/tags/${tag}` || ref === `refs/heads/publish/${tag}`, + ) + ) + throw new Error(`Tag ${tag} already exists or is reserved for publication.`) + if (json('package.json').version !== tag.slice(1)) + run('node', ['scripts/release-version.mjs', tag]) + validateVersions(tag) + const title = titleFor(tag) + const branch = refresh + ? `release-refresh/${env.GITHUB_RUN_ID}-${env.GITHUB_RUN_ATTEMPT}` + : `release/${tag}` + writeVersionPr({ sha, body, refresh, title, branch, files: versionFiles }) +} + +function releaseBody(tag, sha, tags, publishing) { + const previous = + (isRc(tag) && + latest( + tags.filter( + (value) => + isRc(value) && + value.startsWith(`${tag.split('-')[0]}-rc`) && + value !== tag, + ), + )) || + latest(tags.filter((value) => stablePattern.test(value) && value !== tag)) + const url = `${env.GITHUB_SERVER_URL}/${repository}` + let range = sha + let comparison = 'No previous release tag' + if (previous) { + git('fetch', '--no-tags', 'origin', `refs/tags/${previous}`) + range = `${git('rev-parse', 'FETCH_HEAD^{commit}')}..${sha}` + comparison = `Changes since **${previous}** · [Compare changes](${url}/compare/${previous}...${sha})` + } + comparison += ` · **${git('rev-list', '--count', range)} commits**` + const commits = git('log', '--format=%h %s', range) + const groups = Object.groupBy(commits ? commits.split('\n') : [], (line) => { + if (/^\S+ chore\(deps\)!?: /i.test(line)) return 'deps' + const type = line.match(/^\S+ (\w+)(?:\([^)]*\))?!?: /)?.[1]?.toLowerCase() + return type === 'doc' ? 'docs' : type || 'other' + }) + const labels = { + fix: 'Fixes', + feat: 'Features', + perf: 'Performance', + revert: 'Reverts', + refactor: 'Refactoring', + build: 'Build', + deps: 'Dependencies', + ci: 'CI', + docs: 'Documentation', + test: 'Tests', + chore: 'Maintenance', + style: 'Style', + other: 'Other', + } + const names = { + macos: 'macOS', + windows: 'Windows', + ui: 'UI', + ci: 'CI', + tun: 'TUN', + } + const order = Object.keys(labels) + const rank = (type) => + order.includes(type) ? order.indexOf(type) : order.length + const scope = + Object.entries(groups) + .sort(([a], [b]) => rank(a) - rank(b) || a.localeCompare(b)) + .map(([type, lines]) => { + const entries = lines.map((line) => { + const hash = line.slice(0, line.indexOf(' ')) + const title = line + .slice(hash.length + 1) + .replace(/\s*\((?:[\w.-]+\/[\w.-]+)?#\d+\)/g, '') + .replace(/(?:[\w.-]+\/[\w.-]+)?#\d+\b/g, '') + .replace(/\s{2,}/g, ' ') + .trim() + .replace( + /^\w+(?:\(([^)]*)\))?(!)?:\s*/, + (_, scope, breaking) => + `${breaking ? 'Breaking: ' : ''}${scope ? `${Object.hasOwn(names, scope) ? names[scope] : scope}: ` : ''}`, + ) + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replace(/[\\`*_[\]]/g, '\\$&') + return `- ${title} [#${hash}](${url}/commit/${hash})` + }) + return `### ${Object.hasOwn(labels, type) ? labels[type] : type} · ${lines.length}\n\n${entries.join('\n')}` + }) + .join('\n\n') || 'No new commits.' + return `## ${titleFor(tag)} + +Source: \`dev\` · [${sha}](${url}/commit/${sha}) \n${comparison} +${ + publishing + ? ` + +After approval, select the checkbox below to publish. +Do not use the normal Squash or Rebase merge buttons. + +- [ ] ${publishLabel}` + : ` +- [ ] ${refreshLabel} + +Refreshing requires a new approval.` +} + +## Changes + +${scope} +` +} + +function writeVersionPr({ sha, body, refresh, title, branch, files }) { + // Create-only ref and expectedHeadOid prevent replacing another candidate. + api('git/refs', { ref: `refs/heads/${branch}`, sha }) + let pr + try { + const commit = api('graphql', { + query: `mutation($input: CreateCommitOnBranchInput!) { + createCommitOnBranch(input: $input) { commit { oid } } + }`, + variables: { + input: { + branch: { repositoryNameWithOwner: repository, branchName: branch }, + expectedHeadOid: sha, + message: { headline: title }, + fileChanges: { + additions: files.map((path) => ({ + path, + contents: readFileSync(path).toString('base64'), + })), + }, + }, + }, + }) + if (!commit.data?.createCommitOnBranch?.commit?.oid) + throw new Error('Signed version commit failed.') + if (refresh) { + const current = get(`pulls/${refresh.number}`) + if ( + current.state !== 'open' || + current.head.sha !== refresh.head.sha || + current.base.ref !== refresh.base.ref || + current.body !== refresh.body + ) + throw new Error( + 'The version pull request changed while preparing the update.', + ) + const oid = commit.data.createCommitOnBranch.commit.oid + updateRefs(refresh.base.repo.node_id, [ + { + name: `refs/heads/${refresh.head.ref}`, + beforeOid: refresh.head.sha, + afterOid: oid, + force: true, + }, + { + name: `refs/heads/${branch}`, + beforeOid: oid, + afterOid: '0'.repeat(40), + }, + ]) + for (const review of list( + `pulls/${refresh.number}/reviews?per_page=100`, + )) { + if (review.state === 'APPROVED') + api( + `pulls/${refresh.number}/reviews/${review.id}/dismissals`, + { + message: + 'The version PR was refreshed from dev; please review again.', + }, + 'PUT', + ) + } + pr = api(`pulls/${refresh.number}`, { title, body }, 'PATCH') + } else { + pr = api('pulls', { + head: branch, + base: 'dev', + title, + body, + }) + } + } catch (error) { + if (refresh) { + try { + api(`git/refs/heads/${branch}`, {}, 'DELETE') + } catch {} + } + throw error + } + api(`issues/${pr.number}/labels`, { labels: ['release'] }) + summary(`Version pull request: ${pr.html_url}`) +} + +function updateRefs(repositoryId, refUpdates) { + api('graphql', { + query: `mutation($input: UpdateRefsInput!) { updateRefs(input: $input) { clientMutationId } }`, + variables: { input: { repositoryId, refUpdates } }, + }) +} + +function requireBotPr(pr, base, prefix) { + const tag = branchTag(pr.head.ref, prefix) + if ( + !tag || + pr.base.ref !== base || + pr.base.repo.full_name !== repository || + pr.head.repo?.full_name !== repository || + pr.user.login !== 'github-actions[bot]' + ) + throw new Error( + 'Expected a release PR created by this repository workflow.', + ) + return tag +} + +function publishPr() { + const event = json(env.GITHUB_EVENT_PATH) + const pr = get(`pulls/${event.pull_request.number}`) + const tag = requireBotPr(pr, 'dev', 'release/') + if (!pr.merged || pr.merge_commit_sha !== event.pull_request.merge_commit_sha) + throw new Error('Expected the final merged version commit on dev.') + const sha = pr.merge_commit_sha + fetchBranches() + git('fetch', '--no-tags', 'origin', sha) + git('merge-base', '--is-ancestor', sha, 'origin/dev') + validateVersionCommit(tag, sha) + const branch = `publish/${tag}` + const existing = list( + `pulls?state=all&base=main&head=${repository.split('/')[0]}:${branch}&per_page=100`, + ) + if (existing.length) { + const publication = existing[0] + requireBotPr(publication, 'main', 'publish/') + if (publication.head.sha !== sha) + throw new Error( + 'This tag is already reserved by a different publication snapshot.', + ) + summary(`Existing publication PR: ${publication.html_url}`) + return + } + const refs = remoteRefs() + if (refs.some(([, ref]) => ref === `refs/tags/${tag}`)) + throw new Error(`Tag ${tag} already exists.`) + git('merge-base', '--is-ancestor', 'origin/main', sha) + const head = refs.find(([, ref]) => ref === `refs/heads/${branch}`)?.[0] + if (head && head !== sha) + throw new Error('Publication branch already points to another commit.') + if (!head) api('git/refs', { ref: `refs/heads/${branch}`, sha }) + const body = releaseBody( + tag, + sha, + refs + .filter(([, ref]) => ref.startsWith('refs/tags/')) + .map(([, ref]) => ref.slice(10)), + true, + ) + const publication = api('pulls', { + head: branch, + base: 'main', + title: titleFor(tag), + body, + }) + api(`issues/${publication.number}/labels`, { labels: ['release'] }) + summary(`Publication PR: ${publication.html_url}\n\n${body}`) +} + +function requireApproval(pr, sha) { + const reviews = new Map() + for (const review of list(`pulls/${pr.number}/reviews?per_page=100`)) { + if (['APPROVED', 'CHANGES_REQUESTED', 'DISMISSED'].includes(review.state)) + reviews.set(review.user.login, review) + } + let approved = false + for (const review of reviews.values()) { + if (!canWrite(review.user.login)) continue + if (review.state === 'CHANGES_REQUESTED') + throw new Error('A reviewer with write access has requested changes.') + if (review.state === 'APPROVED' && review.commit_id === sha) approved = true + } + if (!approved) + throw new Error( + 'The current PR head needs approval from a reviewer with write access.', + ) +} + +function releaseOutput(tag, sha) { + output({ + release_tag: tag, + source_sha: sha, + channel: isRc(tag) ? 'rc' : 'stable', + }) +} + +function publish() { + const event = json(env.GITHUB_EVENT_PATH) + const tag = requireBotPr(event.pull_request, 'main', 'publish/') + if ( + checked(event.changes?.body?.from, publishLabel) || + !checked(event.pull_request.body, publishLabel) + ) + throw new Error('Publishing requires a new checkbox selection.') + if (!canWrite(event.sender.login)) + throw new Error('Publishing requires write access.') + const sha = event.pull_request.head.sha + const pr = get(`pulls/${event.pull_request.number}`) + requireBotPr(pr, 'main', 'publish/') + if ( + pr.head.sha !== sha || + !checked(pr.body, publishLabel) || + !pr.body.includes(``) + ) + throw new Error( + 'The publication snapshot changed after the checkbox selection.', + ) + fetchBranches() + git('fetch', '--no-tags', 'origin', sha) + git('merge-base', '--is-ancestor', sha, 'origin/dev') + validateVersionCommit(tag, sha) + const marker = `Release workflow run: ${env.GITHUB_RUN_ID}\nSource: ${sha}\nPR: ${pr.number}` + const existing = remoteRefs().find(([, ref]) => ref === `refs/tags/${tag}`) + if (existing) { + const ref = get(`git/ref/tags/${tag}`) + const object = + ref.object.type === 'tag' && get(`git/tags/${ref.object.sha}`) + if ( + !object || + object.object.type !== 'commit' || + object.object.sha !== sha || + object.message.trim() !== marker || + Number(env.GITHUB_RUN_ATTEMPT) <= 1 + ) + throw new Error( + 'An existing tag can only be recovered by its original release run.', + ) + git('merge-base', '--is-ancestor', sha, 'origin/main') + summary(`Resuming ${tag} at ${sha} from this release run.`) + } else { + if (pr.state !== 'open' || pr.draft) + throw new Error('Expected an open, ready publication PR.') + requireApproval(pr, sha) + const main = git('rev-parse', 'origin/main') + git('merge-base', '--is-ancestor', main, sha) + const object = api('git/tags', { + tag, + message: marker, + object: sha, + type: 'commit', + }) + // The tag records run ownership in the same transaction as the fast-forward. + updateRefs(pr.base.repo.node_id, [ + { name: 'refs/heads/main', beforeOid: main, afterOid: sha, force: false }, + { + name: `refs/heads/${pr.head.ref}`, + beforeOid: sha, + afterOid: sha, + force: false, + }, + { + name: `refs/tags/${tag}`, + beforeOid: '0'.repeat(40), + afterOid: object.sha, + force: false, + }, + ]) + summary(`Fast-forwarded main and tagged ${tag} at approved commit ${sha}.`) + } + releaseOutput(tag, sha) +} + +function validateTag() { + const tag = env.GITHUB_REF_NAME + if (!isRelease(tag)) throw new Error('Expected a stable or RC release tag.') + fetchBranches() + const sha = git('rev-parse', `${env.GITHUB_SHA}^{commit}`) + const ref = get(`git/ref/tags/${tag}`) + const object = ref.object.type === 'tag' && get(`git/tags/${ref.object.sha}`) + if (object && object.message.includes('Release workflow run:')) + throw new Error( + 'Resume the original publication run for workflow-owned tags.', + ) + git('fetch', '--no-tags', 'origin', `refs/tags/${tag}`) + if (git('rev-parse', 'FETCH_HEAD^{commit}') !== sha) + throw new Error('The tag moved after this run started.') + for (const branch of ['main', 'dev']) + git('merge-base', '--is-ancestor', sha, `origin/${branch}`) + validateVersions(tag, sha) + releaseOutput(tag, sha) +} + +function prepareDraft() { + const tag = env.RELEASE_TAG + verifyTarget() + const release = list('releases?per_page=100').find( + (release) => release.tag_name === tag, + ) + if (release && !release.draft) + throw new Error('This release is already public.') + const draft = + release || + api('releases', { + tag_name: tag, + target_commitish: env.SOURCE_SHA, + name: `Clash Verge Rev ${tag}`, + body: 'Draft release, will be updated later.', + draft: true, + prerelease: isRc(tag), + }) + output({ id: draft.id }) +} + +function verifyTarget(tag = env.RELEASE_TAG, sha = env.SOURCE_SHA) { + const ref = get(`git/ref/tags/${tag}`) + const target = + ref.object.type === 'tag' + ? get(`git/tags/${ref.object.sha}`).object + : ref.object + if (target.type !== 'commit' || target.sha !== sha) + throw new Error('The release tag no longer points to the validated source.') +} + +function nextDev() { + let refresh + let tag = env.RELEASE_TAG + let source = env.SOURCE_SHA + let minor = false + if (env.GITHUB_EVENT_NAME === 'pull_request_target') { + const event = json(env.GITHUB_EVENT_PATH) + const oldBody = event.changes?.body?.from + const body = event.pull_request.body + if ( + checked(oldBody, minorLabel) === checked(body, minorLabel) && + (checked(oldBody) || !checked(body)) + ) + return + if (!canWrite(event.sender.login)) + throw new Error('Changing the next dev version requires write access.') + refresh = get(`pulls/${event.pull_request.number}`) + if (refresh.state !== 'open') return + tag = requireBotPr(refresh, 'dev', 'next-dev/') + if ( + refresh.head.sha !== event.pull_request.head.sha || + refresh.body !== body + ) + throw new Error( + 'The version update PR changed after the checkbox selection.', + ) + minor = checked(body, minorLabel) + source = body.match(//)?.[1] + } + if (!stablePattern.test(tag) || !/^[a-f0-9]{40}$/.test(source)) + throw new Error('The next dev version requires a stable release snapshot.') + verifyTarget(tag, source) + const release = get(`releases/tags/${tag}`) + if (release.draft || release.prerelease) + throw new Error('The stable release must be public before preparing dev.') + const branch = `next-dev/${tag}` + if (!refresh) { + const existing = list( + `pulls?state=all&base=dev&head=${repository.split('/')[0]}:${branch}&per_page=100`, + )[0] + if (existing) { + requireBotPr(existing, 'dev', 'next-dev/') + summary(`Existing dev version PR: ${existing.html_url}`) + return + } + } + fetchBranches() + git('fetch', '--no-tags', 'origin', source) + git('merge-base', '--is-ancestor', source, 'origin/main') + git('merge-base', '--is-ancestor', source, 'origin/dev') + git('checkout', '--detach', 'origin/dev') + const sha = git('rev-parse', 'HEAD') + validateVersions(tag, source) + validateVersions(tag, sha) + if (read('Changelog.md') !== gitFile(source, 'Changelog.md')) + throw new Error( + 'Changelog.md changed after this release. Preserve the unreleased entries and prepare the dev update manually.', + ) + if ( + !read('Changelog.md').split('\n').includes(`## ${tag}`) || + read('docs/Changelog.history.md').split('\n').includes(`## ${tag}`) + ) + throw new Error('The released changelog is missing or already archived.') + const [major, minorVersion, patch] = tag.slice(1).split('.').map(BigInt) + const patchTag = `v${major}.${minorVersion}.${patch + 1n}` + const minorTag = `v${major}.${minorVersion + 1n}.0` + const nextTag = minor ? minorTag : patchTag + run('pnpm', ['release-version', nextTag]) + run('bash', ['scripts-workflow/bump_changelog.sh']) + const changelog = read('Changelog.md') + if (!changelog.includes('## v(Version Goes Here)')) + throw new Error('The changelog template has no version placeholder.') + writeFileSync( + 'Changelog.md', + changelog.replace('## v(Version Goes Here)', `## ${nextTag}`), + ) + validateVersions(nextTag) + const title = `Dev: ${nextTag}` + const body = `## ${title} + +After [${tag}](${env.GITHUB_SERVER_URL}/${repository}/releases/tag/${tag}). + + +Patch: \`${patchTag}\` · Minor: \`${minorTag}\` + +- [${minor ? 'x' : ' '}] ${minorLabel} +- [ ] ${refreshLabel} + +Changing the version or refreshing requires a new approval. +` + writeVersionPr({ + sha, + body, + refresh, + title, + branch: refresh + ? `next-dev-refresh/${env.GITHUB_RUN_ID}-${env.GITHUB_RUN_ATTEMPT}` + : branch, + files: [...versionFiles, 'Changelog.md', 'docs/Changelog.history.md'], + }) +} + +const commands = { + prepare, + 'next-dev': nextDev, + 'publish-pr': publishPr, + publish, + 'validate-tag': validateTag, + 'prepare-draft': prepareDraft, + 'verify-target': verifyTarget, +} +const command = commands[process.argv[2]] +if (!command) throw new Error('Unknown release command.') +command()