diff --git a/.github/workflows/Prerelease.yml b/.github/workflows/Prerelease.yml index 3b65b206b..58ffd55b0 100644 --- a/.github/workflows/Prerelease.yml +++ b/.github/workflows/Prerelease.yml @@ -33,7 +33,19 @@ jobs: GITHUB_TOKEN: ${{ steps.generate_token.outputs.token }} id: get_version run: node ./Update/GetVersion.js + # 版本号没变(例如合并了没有更新版本号的 PR)时,不能把已发布的正式版覆盖成预发布版;同版本的预发布版照常更新 + - name: Check for an existing release + id: existing_release + env: + GITHUB_TOKEN: ${{ steps.generate_token.outputs.token }} + VERSION: ${{ steps.get_version.outputs.version }} + run: | + if [ "$(gh release view "$VERSION" --json isPrerelease --jq .isPrerelease 2>/dev/null)" = "false" ]; then + echo "Release $VERSION is already published as a stable release, skipping the prerelease." + echo exists=true >> $GITHUB_OUTPUT + fi - id: create_release + if: steps.existing_release.outputs.exists != 'true' uses: softprops/action-gh-release@v2 env: GITHUB_TOKEN: ${{ steps.generate_token.outputs.token }} diff --git a/.github/workflows/UpdateToRelease.yml b/.github/workflows/UpdateToRelease.yml index 5f82aa0fb..aeed3d394 100644 --- a/.github/workflows/UpdateToRelease.yml +++ b/.github/workflows/UpdateToRelease.yml @@ -11,15 +11,25 @@ on: jobs: UpdateToRelease: runs-on: ubuntu-latest - # 添加条件:如果是 bot 触发的则跳过,避免无限循环 - if: github.event.pull_request.user.login != 'github-actions[bot]' + # 添加条件:如果是 bot 触发的则跳过,避免无限循环;fork 的 PR 拿不到 secrets,也跳过 + if: github.event.pull_request.head.repo.full_name == github.repository && github.event.pull_request.user.login != 'github-actions[bot]' permissions: pull-requests: write contents: write steps: + # 用 GitHub App 的 token 创建版本 PR,这样它的工作流无需手动批准就能运行并自动合并 + - name: Generate a token + id: generate_token + uses: actions/create-github-app-token@v3 + with: + app-id: ${{ secrets.APP_ID }} + private-key: ${{ secrets.APP_PRIVATE_KEY }} + permission-contents: write + permission-pull-requests: write - uses: actions/checkout@v6 with: fetch-depth: 0 + token: ${{ steps.generate_token.outputs.token }} - name: Update to release run: | if [[ ${{ github.event.pull_request.head.ref }} != dev && $(git diff --name-only HEAD HEAD~1) == *XMOJ.user.js* ]]; then @@ -27,8 +37,8 @@ jobs: gh pr comment ${{ github.event.pull_request.number }} --body "请向\`dev\`分支提交pull request, 本pull request将被自动关闭" gh pr close ${{ github.event.pull_request.number }} else - node ./Update/UpdateToRelease.js ${{ secrets.GITHUB_TOKEN }} ${{ github.event.pull_request.number }} "$PR_BODY" + node ./Update/UpdateToRelease.js ${{ steps.generate_token.outputs.token }} ${{ github.event.pull_request.number }} "$PR_BODY" fi env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITHUB_TOKEN: ${{ steps.generate_token.outputs.token }} PR_BODY: ${{ github.event.pull_request.body }} diff --git a/.github/workflows/UpdateVersion.yml b/.github/workflows/UpdateVersion.yml index e43ba0c82..dbf982f73 100644 --- a/.github/workflows/UpdateVersion.yml +++ b/.github/workflows/UpdateVersion.yml @@ -24,6 +24,11 @@ jobs: private-key: ${{ secrets.APP_PRIVATE_KEY }} - uses: actions/checkout@v6 - name: Update version + # PR 标题不可信,通过环境变量传入,不直接拼进 shell 命令 env: + GITHUB_TOKEN: ${{ steps.generate_token.outputs.token }} + PR_NUMBER: ${{ github.event.number }} + PR_TITLE: ${{ github.event.pull_request.title }} PR_BODY: ${{ github.event.pull_request.body }} - run: node ./Update/UpdateVersion.js ${{ steps.generate_token.outputs.token }} ${{ github.event.number }} "${{ github.event.pull_request.title }}" "$PR_BODY" "${{ github.event.action }}" + PR_ACTION: ${{ github.event.action }} + run: node ./Update/UpdateVersion.js "$GITHUB_TOKEN" "$PR_NUMBER" "$PR_TITLE" "$PR_BODY" "$PR_ACTION" diff --git a/.github/workflows/UpdateVersionFork.yml b/.github/workflows/UpdateVersionFork.yml new file mode 100644 index 000000000..a75fb2397 --- /dev/null +++ b/.github/workflows/UpdateVersionFork.yml @@ -0,0 +1,54 @@ +name: UpdateVersionFork +# fork 的 PR 拿不到 secrets,也不能推送到 fork,所以合并前没法更新版本号。 +# 维护者合并后会推送到 dev:在这次(可信的)推送上找出所有已合并、但 Update.json 里还没有记录的 fork PR, +# 逐个基于最新的 dev 更新版本号,并以 GitHub App 身份开自动合并的 PR,等它合并后再处理下一个。 +# 并发组最多保留一个排队的运行,被替换掉的运行要处理的 PR 也会被后面的运行补上。 +on: + push: + branches: + - dev + paths: + - XMOJ.user.js + workflow_dispatch: +concurrency: + group: update-version-fork + cancel-in-progress: false +jobs: + UpdateVersionFork: + runs-on: ubuntu-latest + if: ${{ !endsWith(github.actor, '[bot]') }} + permissions: + contents: read + steps: + - name: Generate a token + id: generate_token + uses: actions/create-github-app-token@v3 + with: + app-id: ${{ secrets.APP_ID }} + private-key: ${{ secrets.APP_PRIVATE_KEY }} + permission-contents: write + permission-pull-requests: write + - uses: actions/checkout@v6 + with: + ref: dev + token: ${{ steps.generate_token.outputs.token }} + - name: Update versions of merged fork PRs + env: + GITHUB_TOKEN: ${{ steps.generate_token.outputs.token }} + REPOSITORY: ${{ github.repository }} + # 在此之前合并的 fork PR 不再补记版本号 + SINCE: "2026-10-06T00:00:00Z" + run: | + PRS=$(gh pr list --repo "$REPOSITORY" --base dev --state merged --limit 50 --json number,isCrossRepository,mergedAt \ + --jq "map(select(.isCrossRepository and .mergedAt >= \"$SINCE\")) | sort_by(.mergedAt) | .[].number") + for PR_NUMBER in $PRS; do + git fetch -q origin dev + git checkout -q -B dev origin/dev + if jq -e --argjson pr "$PR_NUMBER" '[.UpdateHistory[].UpdateContents[].PR] | index($pr)' Update.json > /dev/null; then + echo "#$PR_NUMBER already has a version." + continue + fi + PR_TITLE=$(gh api "repos/$REPOSITORY/pulls/$PR_NUMBER" --jq .title) + PR_BODY=$(gh api "repos/$REPOSITORY/pulls/$PR_NUMBER" --jq '.body // ""') + node ./Update/UpdateVersion.js "$GITHUB_TOKEN" "$PR_NUMBER" "$PR_TITLE" "$PR_BODY" fork-merged + done diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml deleted file mode 100644 index fdd59cbdb..000000000 --- a/.github/workflows/main.yml +++ /dev/null @@ -1,25 +0,0 @@ -name: Qodana -on: - workflow_dispatch: - pull_request: - push: - branches: - - master - - dev - -jobs: - qodana: - runs-on: ubuntu-latest - permissions: - contents: write - pull-requests: write - checks: write - steps: - - uses: actions/checkout@v6 - with: - ref: ${{ github.event.pull_request.head.sha }} # to check out the actual pull request commit, not the merge commit - fetch-depth: 0 # a full history is required for pull request analysis - - name: 'Qodana Scan' - uses: JetBrains/qodana-action@v2025.2 - env: - QODANA_TOKEN: ${{ secrets.QODANA_TOKEN }} diff --git a/.github/workflows/merge.yml b/.github/workflows/merge.yml deleted file mode 100644 index d127affd3..000000000 --- a/.github/workflows/merge.yml +++ /dev/null @@ -1,51 +0,0 @@ -name: Merge changes to extern-contrib -on: - push: - branches: - - dev - workflow_dispatch: -jobs: - create_pull_request: - name: 'Create PR' - if: github.ref == 'refs/heads/extern-contrib' - runs-on: ubuntu-latest - permissions: - pull-requests: write - steps: - - name: 'Create PR' - uses: actions/github-script@v7 - with: - script: | - const { repo, owner } = context.repo; - const pulls = await github.rest.pulls.list({ - owner: owner, - repo: repo, - head: context.ref, - base: 'dev', - state: 'open', - }); - - if (pulls.data.length < 1) { - await github.rest.pulls.create({ - title: 'Merge dev into extern-contrib', - owner: owner, - repo: repo, - head: context.ref, - base: 'dev', - body: [ - 'This PR is auto-generated by', - '[actions/github-script](https://github.com/actions/github-script)', - ].join('\n'), - }); - } else { - const existingPR = pulls.data[0]; - await github.rest.pulls.update({ - owner: owner, - repo: repo, - pull_number: existingPR.number, - body: [ - existingPR.body, - `Updated by Job ${context.job}`, - ].join('\n'), - }); - } diff --git a/.github/workflows/sync-to-extern-contrib.yml b/.github/workflows/sync-to-extern-contrib.yml deleted file mode 100644 index 2bb08cb94..000000000 --- a/.github/workflows/sync-to-extern-contrib.yml +++ /dev/null @@ -1,24 +0,0 @@ -name: Sync -on: - push: - branches: - - dev - -jobs: - sync-branches: - runs-on: ubuntu-latest - name: Syncing branches - steps: - - name: Checkout - uses: actions/checkout@v6 - - name: Set up Node - uses: actions/setup-node@v4 - with: - node-version: 12 - - name: Opening pull request - id: pull - uses: tretuna/sync-branches@1.4.0 - with: - GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}} - FROM_BRANCH: "dev" - TO_BRANCH: "extern-contrib" diff --git a/CLAUDE.md b/CLAUDE.md index 61b28a221..d4e127410 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -52,8 +52,7 @@ Use: Task tool with xmoj-code-navigator agent to find specific sections ### Branch Structure and PR Requirements - `master`: Production branch - **DO NOT make PRs directly to master** -- `dev`: Development branch - **ALL PRs must be based on and target this branch** -- `extern-contrib`: External contributors must submit PRs to this branch +- `dev`: Development branch - **ALL PRs must be based on and target this branch**, including PRs from forks **CRITICAL: All pull requests must:** 1. Be based on the `dev` branch (branch off from `dev`) @@ -191,10 +190,10 @@ If you see "XMOJ.user.js and Update.json have different patch versions": ### PR Requirements - **All PRs must be based on and target `dev` branch, not `master`** -- Only PRs from the same repository (not forks) trigger auto-versioning +- Only PRs from the same repository trigger UpdateVersion before merge. Fork PRs get no secrets, so after a fork PR merges, `UpdateVersionFork` (on the push to `dev`) bumps the version on `actions/version-` and opens an auto-merging PR - PRs must modify `XMOJ.user.js` to trigger version bumps - Must merge `dev` into your branch before submitting -- External contributors must target `extern-contrib` branch +- External contributors fork the repo and target `dev` too ### Single-File Architecture diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0f5c6e143..95ca944d8 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -5,7 +5,7 @@ We are happy to have you here with us! We believe that you must be excited to contribute to our repo, but first, please read the contribution guidelines! > [!IMPORTANT] -> 请注意, 外部开发者应向`extern-contrib`提交 pull requests。 +> 请向 `dev` 分支提交 pull requests。外部开发者请先 fork 本仓库,从 `dev` 拉分支,再向本仓库的 `dev` 提 PR。版本号由维护者和 CI 更新,不要手动修改。 - Our goal for `xmoj-script` is **stability before features**. This means we focus on squashing critical bugs before adding new features. Often, we can do both in tandem, but bugs will take priority over a new feature. - We use Bootstrap in our project. Please use Bootstrap classes instead of writing your own CSS whenever possible. diff --git a/README.md b/README.md index 663645e0e..3b291e01a 100644 --- a/README.md +++ b/README.md @@ -86,7 +86,7 @@ 链接戳[这里](https://opencollective.com/xmoj-script-dev)。 > [!IMPORTANT] -> 请注意, 外部开发者应向`extern-contrib`提交 pull requests。 +> 请向 `dev` 分支提交 pull requests。外部开发者请先 fork 本仓库,从 `dev` 拉分支,再向本仓库的 `dev` 提 PR。版本号由维护者和 CI 更新,不要手动修改。 ## Contributors diff --git a/Update.json b/Update.json index a9b9faee7..926f44f03 100644 --- a/Update.json +++ b/Update.json @@ -3904,7 +3904,7 @@ "Description": "Fix image enlarger and minimalist skin on video replay pages; add page title" } ], - "Notes": "- 修复回放视频进度条拖动球被当作图片放大的问题\n- 修复极简黑白界面下视频播放器按钮出现灰色方框的问题\n- 回放页面增加页面标题" + "Notes": "" }, "3.9.0": { "UpdateDate": 1791249917714, diff --git a/Update/UpdateVersion.js b/Update/UpdateVersion.js index 891d2c46f..1b5ff2e32 100644 --- a/Update/UpdateVersion.js +++ b/Update/UpdateVersion.js @@ -4,20 +4,29 @@ import {execSync} from "child_process"; var GithubToken = process.argv[2]; var PRNumber = process.argv[3]; process.env.GITHUB_TOKEN = GithubToken; -execSync("gh pr checkout " + PRNumber); -console.info("PR #" + PRNumber + " has been checked out."); - -// Check if the last commit was made by github-actions[bot] -// Only skip for synchronize events (push-triggered) to prevent infinite loops. -// For edited events (PR title/body changes), allow metadata updates even when -// the branch tip is a bot commit. const eventAction = String(process.argv[6] || ""); -const lastCommitAuthor = execSync("git log -1 --pretty=format:'%an'").toString().trim(); -console.log("Last commit author: " + lastCommitAuthor); +// A merged PR from a fork: we cannot push to the fork, so bump the version on +// a branch of this repository (checked out at dev) and open an auto-merging PR. +const ForkMerged = eventAction === "fork-merged"; +const ForkBranch = "actions/version-" + Number(PRNumber); console.log("Event action : " + eventAction); -if (lastCommitAuthor === "github-actions[bot]" && eventAction !== "edited") { - console.log("Last commit was made by github-actions[bot]. Skipping to prevent infinite loop."); - process.exit(0); +if (ForkMerged) { + execSync("git checkout -B " + ForkBranch); + console.info("Bumping the version for merged fork PR #" + PRNumber + " on " + ForkBranch + "."); +} else { + execSync("gh pr checkout " + PRNumber); + console.info("PR #" + PRNumber + " has been checked out."); + + // Check if the last commit was made by github-actions[bot] + // Only skip for synchronize events (push-triggered) to prevent infinite loops. + // For edited events (PR title/body changes), allow metadata updates even when + // the branch tip is a bot commit. + const lastCommitAuthor = execSync("git log -1 --pretty=format:'%an'").toString().trim(); + console.log("Last commit author: " + lastCommitAuthor); + if (lastCommitAuthor === "github-actions[bot]" && eventAction !== "edited") { + console.log("Last commit was made by github-actions[bot]. Skipping to prevent infinite loop."); + process.exit(0); + } } const JSONFileName = "./Update.json"; @@ -26,7 +35,10 @@ var JSONFileContent = readFileSync(JSONFileName, "utf8"); var JSFileContent = readFileSync(JSFileName, "utf8"); execSync("git config --global user.email \"github-actions[bot]@users.noreply.github.com\""); execSync("git config --global user.name \"github-actions[bot]\""); -if (JSONFileContent.includes('//!ci-no-touch')) { +if (ForkMerged && JSONFileContent.includes('//!ci-no-touch')) { + // The marker only means something on an open PR branch. + JSONFileContent = JSONFileContent.replace('//!ci-no-touch', ''); +} else if (JSONFileContent.includes('//!ci-no-touch')) { var updatedContent = JSONFileContent.replace('//!ci-no-touch', ''); writeFileSync(JSONFileName, updatedContent, "utf8"); execSync("git config pull.rebase false"); @@ -115,6 +127,33 @@ writeFileSync(JSONFileName, JSON.stringify(JSONObject, null, 4), "utf8"); console.warn("Update.json has been updated."); +if (ForkMerged) { + execSync("git commit -a -m \"" + CommitMessage + "\""); + execSync("git push -u origin " + ForkBranch + " -f"); + console.log("Pushed to " + ForkBranch + "."); + // A rerun may find the PR from an earlier attempt; reuse it instead of failing to create a duplicate. + var VersionPR = execSync("gh pr list --base dev --head " + ForkBranch + " --state open --json url --jq '.[0].url // empty'").toString().trim(); + if (VersionPR) { + console.log("Reusing " + VersionPR + "."); + } else { + VersionPR = execSync("gh pr create --base dev --head " + ForkBranch + " --title \"" + CommitMessage + " (#" + CurrentPR + ")\" --body \"Version bump for fork PR #" + CurrentPR + ".\"").toString().trim(); + console.log("Created " + VersionPR + "."); + } + execSync("gh pr merge " + VersionPR + " --merge --auto"); + console.log("Enabled auto merge."); + // Wait for the merge, so the next fork PR is bumped from a dev that already has this version. + for (var Attempt = 0; ; Attempt++) { + var State = execSync("gh pr view " + VersionPR + " --json state --jq .state").toString().trim(); + if (State == "MERGED") break; + if (State == "CLOSED" || Attempt >= 120) { + console.error(VersionPR + " was not merged (state: " + State + ")."); + process.exit(1); + } + execSync("sleep 10"); + } + console.log(VersionPR + " has been merged."); + process.exit(0); +} execSync("git config pull.rebase false"); execSync("git pull"); execSync("git commit -a -m \"" + CommitMessage + "\""); diff --git a/child-protection.html b/child-protection.html index f4b5f4a9d..104c1a305 100644 --- a/child-protection.html +++ b/child-protection.html @@ -62,7 +62,7 @@

我们收集得很少

请家长留意

申请删除

diff --git a/index.html b/index.html index 1c19f3c84..7228464af 100644 --- a/index.html +++ b/index.html @@ -304,7 +304,7 @@

报告问题

贡献代码

-

欢迎 PR。外部贡献者请提交到 extern-contrib 分支,详见贡献指南。

+

欢迎 PR。fork 本仓库后向 dev 分支提交,详见贡献指南。

赞助

diff --git a/privacy.html b/privacy.html index a55f81d05..91da8adda 100644 --- a/privacy.html +++ b/privacy.html @@ -59,11 +59,11 @@

我们收集什么数据

本产品不面向欧盟居民。XMOJ-Script 主要面向小明的OJ用户,没有专门为欧盟、英国或瑞士的用户提供服务。

-

只留在你电脑上的

+

存在你浏览器里的

会发到我们服务器的

diff --git a/site.js b/site.js index dbac9c5ce..c130681d1 100644 --- a/site.js +++ b/site.js @@ -75,6 +75,36 @@ SelectBrowser(browser); } + // 手机上展开的菜单占据页面高度:先收起菜单,再跳到对应章节,标题才不会被导航栏挡住 + const siteNav = document.getElementById("SiteNav"); + if (siteNav && window.bootstrap) { + const PagePath = (path) => path.replace(/\/index\.html$/, "/"); + // 收起动画期间 show 类已被移除,用 closing 记录状态;动画中再点别的链接,以最后一次为准 + let closing = false; + let pendingLink = null; + siteNav.addEventListener("hide.bs.collapse", () => { + closing = true; + }); + siteNav.addEventListener("hidden.bs.collapse", () => { + closing = false; + if (!pendingLink) return; + const target = document.getElementById(pendingLink.hash.slice(1)); + history.pushState(null, "", pendingLink.hash); + pendingLink = null; + if (target) target.scrollIntoView(); + }); + siteNav.addEventListener("click", (event) => { + if (event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return; + const link = event.target.closest("a[href]"); + if (!link || !link.hash || PagePath(link.pathname) !== PagePath(location.pathname)) return; + if (!document.getElementById(link.hash.slice(1))) return; + if (!siteNav.classList.contains("show") && !closing) return; + event.preventDefault(); + pendingLink = link; + if (!closing) bootstrap.Collapse.getOrCreateInstance(siteNav).hide(); + }); + } + const versionElements = document.querySelectorAll("[data-latest-version]"); if (versionElements.length > 0) { fetch("Update.json", {cache: "no-cache"}) diff --git a/tests/chinese-language.test.cjs b/tests/chinese-language.test.cjs index 3db28d04c..6a11d5764 100644 --- a/tests/chinese-language.test.cjs +++ b/tests/chinese-language.test.cjs @@ -14,7 +14,7 @@ const language = Between('function InitializeChineseLanguage(', '// The /web app const route = Between('function IsContestWebApp(', 'function GetContestRoute('); // Check reload behavior without navigating a real page away from its assertions. -function Context({cookie = '', pathname = '/problem.php', english = false, fail = false} = {}) { +function Context({cookie = '', pathname = '/problem.php', fail = false} = {}) { const storage = new Map(); const requests = []; const nodes = []; @@ -32,7 +32,7 @@ function Context({cookie = '', pathname = '/problem.php', english = false, fail document: { cookie, documentElement: {}, head: {appendChild: node => nodes.push(node)}, body: {prepend: node => nodes.push(node)}, getElementById: id => nodes.find(node => node.id === id), - querySelectorAll: () => [], querySelector: () => english ? {} : null, + querySelectorAll: () => [], querySelector: () => null, createElement: () => ({appendChild() {}, addEventListener() {}}) } }); @@ -52,7 +52,7 @@ test('forces the web cookie before requests and reloads an existing English app }); test('classic English pages do not call the broken language endpoint or reload', async () => { - const {scope, requests, reloads} = Context({english: true}); + const {scope, requests, reloads} = Context({cookie: 'XMOJ_LANG=en'}); assert.equal(await scope.EnsureChinesePage(), false); assert.equal(scope.document.documentElement.lang, 'zh-CN'); assert.equal(requests.length, 0); diff --git a/tests/fixtures/profile.html b/tests/fixtures/profile.html index 6c3812ab9..27659e64b 100644 --- a/tests/fixtures/profile.html +++ b/tests/fixtures/profile.html @@ -20,8 +20,8 @@