diff --git a/.github/scripts/test_maintenance_workflow.py b/.github/scripts/test_maintenance_workflow.py index 63d8fff87..a86b37919 100644 --- a/.github/scripts/test_maintenance_workflow.py +++ b/.github/scripts/test_maintenance_workflow.py @@ -6,16 +6,30 @@ import json import os from pathlib import Path +import re import subprocess import tempfile import unittest REPOSITORY_ROOT = Path(__file__).resolve().parents[2] -WORKFLOW_PATH = REPOSITORY_ROOT / ".github/workflows/test.yml" +TEST_WORKFLOW_PATH = REPOSITORY_ROOT / ".github/workflows/test.yml" +RELEASE_WORKFLOW_PATH = REPOSITORY_ROOT / ".github/workflows/release.yml" +STALE_WORKFLOW_PATH = REPOSITORY_ROOT / ".github/workflows/stale.yml" CLASSIFIER_PATH = REPOSITORY_ROOT / ".github/scripts/classify-maintenance-scope.sh" WEB_PACKAGE_PATH = REPOSITORY_ROOT / "modules/web/package.json" +TARGET_ACTION_MAJORS = { + "actions/checkout": 7, + "actions/setup-java": 6, + "gradle/actions/setup-gradle": 6, + "actions/upload-artifact": 7, + "actions/download-artifact": 8, + "actions/stale": 11, + "pnpm/action-setup": 6, + "actions/setup-node": 7, +} + class MaintenanceScopeContractTest(unittest.TestCase): def classify(self, *paths: str) -> dict[str, str]: @@ -80,9 +94,60 @@ def test_ci_implementation_changes_run_both_builds(self) -> None: class MaintenanceWorkflowContractTest(unittest.TestCase): @classmethod def setUpClass(cls) -> None: - cls.workflow = WORKFLOW_PATH.read_text(encoding="utf-8") + cls.workflow = TEST_WORKFLOW_PATH.read_text(encoding="utf-8") + cls.release_workflow = RELEASE_WORKFLOW_PATH.read_text(encoding="utf-8") + cls.stale_workflow = STALE_WORKFLOW_PATH.read_text(encoding="utf-8") cls.web_package = json.loads(WEB_PACKAGE_PATH.read_text(encoding="utf-8")) + def assert_action_matrix( + self, + workflow: str, + expected_actions: set[str], + ) -> None: + actual: dict[str, set[int]] = {} + for action, major in re.findall(r"uses:\s+([^\s@]+)@v(\d+)", workflow): + if action in TARGET_ACTION_MAJORS: + actual.setdefault(action, set()).add(int(major)) + self.assertEqual( + { + action: {TARGET_ACTION_MAJORS[action]} + for action in expected_actions + }, + actual, + ) + + def action_steps(self, workflow: str, action: str) -> list[str]: + return [ + step + for step in workflow.split("\n - ") + if f"uses: {action}@" in step + ] + + def test_each_workflow_uses_only_approved_action_majors(self) -> None: + self.assert_action_matrix( + self.workflow, + { + "actions/checkout", + "actions/setup-java", + "gradle/actions/setup-gradle", + "actions/upload-artifact", + "actions/download-artifact", + "pnpm/action-setup", + "actions/setup-node", + }, + ) + self.assert_action_matrix( + self.release_workflow, + { + "actions/checkout", + "actions/setup-java", + "gradle/actions/setup-gradle", + "actions/upload-artifact", + "actions/download-artifact", + }, + ) + self.assert_action_matrix(self.stale_workflow, {"actions/stale"}) + def test_all_pull_requests_and_master_pushes_trigger(self) -> None: self.assertIn("pull_request:", self.workflow) self.assertIn("push:", self.workflow) @@ -96,12 +161,12 @@ def test_all_pull_requests_and_master_pushes_trigger(self) -> None: def test_android_gate_uses_jdk17_and_debug_only(self) -> None: for token in ( - "actions/setup-java@v5", + "actions/setup-java@v6", "java-version: 17", ":app:testAppDebugUnitTest", ":app:lintAppDebug", ":app:assembleAppDebug", - "actions/upload-artifact@v6", + "actions/upload-artifact@v7", "if: always()", "retention-days:", ): @@ -117,7 +182,7 @@ def test_android_gate_uses_jdk17_and_debug_only(self) -> None: def test_web_gate_is_frozen_and_read_only(self) -> None: for token in ( "node-version: 22", - "pnpm/action-setup@v5", + "pnpm/action-setup@v6", "version: 9.15.9", "pnpm install --frozen-lockfile", "pnpm test:chapter-html", @@ -136,6 +201,67 @@ def test_web_gate_is_frozen_and_read_only(self) -> None: self.web_package["scripts"]["test:static-links"], ) + def test_non_push_checkouts_do_not_persist_credentials(self) -> None: + checkout_steps = self.action_steps(self.workflow, "actions/checkout") + self.assertGreater(len(checkout_steps), 0) + for step in checkout_steps: + self.assertIn("persist-credentials: false", step) + + def test_gradle_cache_and_node_cache_ownership_are_explicit(self) -> None: + gradle_steps = self.action_steps( + self.workflow, + "gradle/actions/setup-gradle", + ) + self.assertGreater(len(gradle_steps), 0) + for step in gradle_steps: + self.assertIn("cache-provider: basic", step) + + pnpm_steps = self.action_steps(self.workflow, "pnpm/action-setup") + self.assertEqual(1, len(pnpm_steps)) + self.assertIn("version: 9.15.9", pnpm_steps[0]) + self.assertIn("run_install: false", pnpm_steps[0]) + self.assertIn("cache: false", pnpm_steps[0]) + + node_steps = self.action_steps(self.workflow, "actions/setup-node") + self.assertGreater(len(node_steps), 0) + for step in node_steps: + self.assertIn("package-manager-cache: false", step) + pnpm_cache_steps = [step for step in node_steps if "cache: pnpm" in step] + self.assertEqual(1, len(pnpm_cache_steps)) + self.assertIn( + "cache-dependency-path: modules/web/pnpm-lock.yaml", + pnpm_cache_steps[0], + ) + + def test_artifact_actions_use_named_archives_and_strict_downloads(self) -> None: + upload_steps = self.action_steps(self.workflow, "actions/upload-artifact") + self.assertGreater(len(upload_steps), 0) + for step in upload_steps: + self.assertIn("archive: true", step) + self.assertNotIn("overwrite:", step) + self.assertNotIn("include-hidden-files:", step) + + download_steps = self.action_steps( + self.workflow, + "actions/download-artifact", + ) + self.assertEqual(1, len(download_steps)) + for token in ( + "name: action-upgrade-fixture-${{ github.run_id }}-${{ github.run_attempt }}", + "skip-decompress: false", + "digest-mismatch: error", + ): + self.assertIn(token, download_steps[0]) + + for token in ( + "artifact-upload:", + "artifact-download:", + "needs: artifact-upload", + "retention-days: 1", + "sha256sum --check --strict payload.sha256", + ): + self.assertIn(token, self.workflow) + def test_repository_gate_pins_openspec_and_actionlint(self) -> None: for token in ( "@fission-ai/openspec@1.8.0", @@ -169,17 +295,45 @@ def test_android_codeql_compiles_sources_without_gradle_build_cache(self) -> Non def test_stable_aggregate_gate_distinguishes_skip_and_failure(self) -> None: for token in ( "name: 维护门禁", - "needs: [scope, android, web, repository, codeql]", + "needs: [scope, android, web, repository, codeql, artifact-download]", "needs.android.result", "needs.web.result", "needs.repository.result", "needs.codeql.result", + "needs.artifact-download.result", + 'require_success "Artifact 下载核验" "$ARTIFACT_RESULT"', ): self.assertIn(token, self.workflow) def test_legacy_web_workflow_is_removed(self) -> None: self.assertFalse((REPOSITORY_ROOT / ".github/workflows/web.yml").exists()) + def test_stale_is_issue_only_with_exact_minimum_permissions(self) -> None: + permission_lines = { + line.strip() + for line in self.stale_workflow.split("permissions:", 1)[1] + .split("\n\n steps:", 1)[0] + .splitlines() + if line.strip() + } + self.assertEqual({"issues: write", "contents: read"}, permission_lines) + for token in ( + "only-issue-labels: needs-info", + "exempt-issue-labels: crash,data-loss,security", + "days-before-issue-stale: 30", + "days-before-pr-stale: -1", + "days-before-pr-close: -1", + "remove-issue-stale-when-updated: true", + ): + self.assertIn(token, self.stale_workflow) + for token in ( + "pull-requests: write", + "contents: write", + "actions: write", + "releases: write", + ): + self.assertNotIn(token, self.stale_workflow) + if __name__ == "__main__": unittest.main(verbosity=2) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ddb884ea0..ce782776e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -36,10 +36,11 @@ jobs: exit 1 - name: 检出候选提交 - uses: actions/checkout@v4 + uses: actions/checkout@v7 with: fetch-depth: 0 ref: ${{ inputs.expected_sha }} + persist-credentials: false - name: 核对候选提交 shell: bash @@ -90,10 +91,11 @@ jobs: ORG_GRADLE_PROJECT_RELEASE_KEY_PASSWORD: ${{ secrets.RELEASE_KEY_PASSWORD }} steps: - name: 检出候选提交 - uses: actions/checkout@v4 + uses: actions/checkout@v7 with: fetch-depth: 0 ref: ${{ inputs.expected_sha }} + persist-credentials: false - name: 再次核对候选提交 shell: bash @@ -116,7 +118,7 @@ jobs: fi - name: 配置 JDK 17 - uses: actions/setup-java@v4 + uses: actions/setup-java@v6 with: distribution: temurin java-version: 17 @@ -152,7 +154,9 @@ jobs: sed -i "/^def version =/c\\def version = \"$VERSION\"" app/build.gradle - name: 配置 Gradle - uses: gradle/actions/setup-gradle@v4 + uses: gradle/actions/setup-gradle@v6 + with: + cache-provider: basic - name: 构建 APK run: ./gradlew :app:assembleAppRelease --build-cache --no-daemon --warning-mode all @@ -171,12 +175,13 @@ jobs: sha256sum "dist/legado_app_${VERSION}_release.apk" - name: 上传 APK - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: release-apk path: dist/legado_app_${{ env.VERSION }}_release.apk if-no-files-found: error retention-days: 30 + archive: true draft-release: name: 创建并核对草稿 Release @@ -190,16 +195,19 @@ jobs: GH_TOKEN: ${{ github.token }} steps: - name: 检出候选提交 - uses: actions/checkout@v4 + uses: actions/checkout@v7 with: fetch-depth: 0 ref: ${{ inputs.expected_sha }} + persist-credentials: true - name: 下载 APK - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v8 with: name: release-apk path: dist + skip-decompress: false + digest-mismatch: error - name: 核对唯一 APK shell: bash diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index 66af2ea20..7b91c6450 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -13,7 +13,7 @@ jobs: contents: read steps: - - uses: actions/stale@v9 + - uses: actions/stale@v11 with: repo-token: ${{ secrets.GITHUB_TOKEN }} only-issue-labels: needs-info diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 4772b56da..6951696fc 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -37,9 +37,10 @@ jobs: head_sha: ${{ steps.classify.outputs.head_sha }} steps: - name: 检出代码 - uses: actions/checkout@v5 + uses: actions/checkout@v7 with: fetch-depth: 0 + persist-credentials: false - name: 分类并锁定验证提交范围 id: classify @@ -93,10 +94,12 @@ jobs: runs-on: ubuntu-latest steps: - name: 检出代码 - uses: actions/checkout@v5 + uses: actions/checkout@v7 + with: + persist-credentials: false - name: 配置 JDK 17 - uses: actions/setup-java@v5 + uses: actions/setup-java@v6 with: distribution: temurin java-version: 17 @@ -107,7 +110,9 @@ jobs: : > app/src/main/assets/18PlusList.txt - name: 配置 Gradle 缓存 - uses: gradle/actions/setup-gradle@v5 + uses: gradle/actions/setup-gradle@v6 + with: + cache-provider: basic - name: 运行 Android lint run: >- @@ -118,7 +123,7 @@ jobs: - name: 上传 Android lint 报告 if: always() - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@v7 with: name: android-lint-${{ github.sha }} path: | @@ -127,6 +132,7 @@ jobs: app/build/reports/lint-results-appDebug.txt if-no-files-found: warn retention-days: 30 + archive: true - name: 运行 Android 单元测试 run: >- @@ -143,12 +149,13 @@ jobs: --warning-mode all - name: 上传 Debug APK - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@v7 with: name: legado-app-debug-${{ github.sha }} path: app/build/outputs/apk/app/debug/*.apk if-no-files-found: error retention-days: 7 + archive: true web: name: Web 质量检查 @@ -157,20 +164,24 @@ jobs: runs-on: ubuntu-latest steps: - name: 检出代码 - uses: actions/checkout@v5 + uses: actions/checkout@v7 + with: + persist-credentials: false - name: 配置 pnpm 9.15.9 - uses: pnpm/action-setup@v5 + uses: pnpm/action-setup@v6 with: version: 9.15.9 run_install: false + cache: false - name: 配置 Node.js 22 与 pnpm 缓存 - uses: actions/setup-node@v5 + uses: actions/setup-node@v7 with: node-version: 22 cache: pnpm cache-dependency-path: modules/web/pnpm-lock.yaml + package-manager-cache: false - name: 冻结安装网页端依赖 working-directory: modules/web @@ -215,14 +226,16 @@ jobs: ACTIONLINT_SHA256: 8aca8db96f1b94770f1b0d72b6dddcb1ebb8123cb3712530b08cc387b349a3d8 steps: - name: 检出代码 - uses: actions/checkout@v5 + uses: actions/checkout@v7 with: fetch-depth: 0 + persist-credentials: false - name: 配置 Node.js 22 - uses: actions/setup-node@v5 + uses: actions/setup-node@v7 with: node-version: 22 + package-manager-cache: false - name: 安装固定版本 OpenSpec run: npm install --global @fission-ai/openspec@1.8.0 @@ -272,18 +285,22 @@ jobs: build-mode: none steps: - name: 检出代码 - uses: actions/checkout@v5 + uses: actions/checkout@v7 + with: + persist-credentials: false - name: 配置 JDK 17 if: matrix.language == 'java-kotlin' - uses: actions/setup-java@v5 + uses: actions/setup-java@v6 with: distribution: temurin java-version: 17 - name: 配置 Gradle 缓存 if: matrix.language == 'java-kotlin' - uses: gradle/actions/setup-gradle@v5 + uses: gradle/actions/setup-gradle@v6 + with: + cache-provider: basic - name: 初始化 CodeQL uses: github/codeql-action/init@v4 @@ -310,9 +327,71 @@ jobs: with: category: /language:${{ matrix.language }} + artifact-upload: + name: Artifact v7 上传 + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: 生成确定性完整性夹具 + shell: bash + env: + FIXTURE_DIR: ${{ runner.temp }}/action-upgrade-fixture + run: | + set -euo pipefail + mkdir -p "$FIXTURE_DIR" + printf '%s\n' 'legado-actions-artifact-v1' > "$FIXTURE_DIR/payload.txt" + ( + cd "$FIXTURE_DIR" + sha256sum payload.txt > payload.sha256 + ) + + - name: 上传完整性夹具 + uses: actions/upload-artifact@v7 + with: + name: action-upgrade-fixture-${{ github.run_id }}-${{ github.run_attempt }} + path: ${{ runner.temp }}/action-upgrade-fixture/ + if-no-files-found: error + retention-days: 1 + archive: true + + artifact-download: + name: Artifact v8 下载核验 + needs: artifact-upload + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: 下载完整性夹具 + uses: actions/download-artifact@v8 + with: + name: action-upgrade-fixture-${{ github.run_id }}-${{ github.run_attempt }} + path: ${{ runner.temp }}/downloaded-action-upgrade-fixture + skip-decompress: false + digest-mismatch: error + + - name: 核对文件集合与 SHA-256 + shell: bash + env: + DOWNLOAD_DIR: ${{ runner.temp }}/downloaded-action-upgrade-fixture + run: | + set -euo pipefail + mapfile -t entries < <( + find "$DOWNLOAD_DIR" -mindepth 1 -maxdepth 1 -printf '%f\n' | sort + ) + expected=(payload.sha256 payload.txt) + if [[ "${entries[*]}" != "${expected[*]}" ]]; then + printf '下载文件集合不符合预期:%s\n' "${entries[*]}" + exit 1 + fi + ( + cd "$DOWNLOAD_DIR" + sha256sum --check --strict payload.sha256 + ) + gate: name: 维护门禁 - needs: [scope, android, web, repository, codeql] + needs: [scope, android, web, repository, codeql, artifact-download] if: always() runs-on: ubuntu-latest steps: @@ -326,6 +405,7 @@ jobs: WEB_RESULT: ${{ needs.web.result }} REPOSITORY_RESULT: ${{ needs.repository.result }} CODEQL_RESULT: ${{ needs.codeql.result }} + ARTIFACT_RESULT: ${{ needs.artifact-download.result }} run: | set -euo pipefail fail=false @@ -356,6 +436,7 @@ jobs: require_success "范围识别" "$SCOPE_RESULT" require_success "OpenSpec 与仓库检查" "$REPOSITORY_RESULT" require_success "CodeQL" "$CODEQL_RESULT" + require_success "Artifact 下载核验" "$ARTIFACT_RESULT" require_scoped_result "Android 质量检查" "$ANDROID_REQUIRED" "$ANDROID_RESULT" require_scoped_result "Web 质量检查" "$WEB_REQUIRED" "$WEB_RESULT" diff --git a/app/src/test/java/io/legado/app/help/update/ReleaseWorkflowContractTest.kt b/app/src/test/java/io/legado/app/help/update/ReleaseWorkflowContractTest.kt index 8c3fde147..fd8fe8c9f 100644 --- a/app/src/test/java/io/legado/app/help/update/ReleaseWorkflowContractTest.kt +++ b/app/src/test/java/io/legado/app/help/update/ReleaseWorkflowContractTest.kt @@ -8,6 +8,11 @@ import java.io.File class ReleaseWorkflowContractTest { private val workflow = repoFile(".github/workflows/release.yml").readText() + private val authorizeSection = workflow.substringAfter("\n authorize:") + .substringBefore("\n build:") + private val buildSection = workflow.substringAfter("\n build:") + .substringBefore("\n draft-release:") + private val draftReleaseSection = workflow.substringAfter("\n draft-release:") private val buildGradle = repoFile("app/build.gradle").readText() private val appConst = repoFile( "app/src/main/java/io/legado/app/constant/AppConst.kt" @@ -50,6 +55,54 @@ class ReleaseWorkflowContractTest { assertFalse(workflow.contains("pattern: release-apk-*")) } + @Test + fun `Action 大版本与 checkout 凭据边界精确`() { + assertTrue(authorizeSection.contains("uses: actions/checkout@v7")) + assertTrue(authorizeSection.contains("persist-credentials: false")) + assertTrue(buildSection.contains("uses: actions/checkout@v7")) + assertTrue(buildSection.contains("persist-credentials: false")) + assertTrue(draftReleaseSection.contains("uses: actions/checkout@v7")) + assertTrue(draftReleaseSection.contains("persist-credentials: true")) + assertTrue(workflow.split("uses: actions/checkout@v7").size - 1 == 3) + assertFalse(workflow.contains(Regex("actions/checkout@v(?!7\\b)"))) + } + + @Test + fun `JDK 和 Gradle 缓存边界精确`() { + assertTrue(buildSection.contains("uses: actions/setup-java@v6")) + assertTrue(buildSection.contains("distribution: temurin")) + assertTrue(buildSection.contains("java-version: 17")) + assertTrue(buildSection.contains("uses: gradle/actions/setup-gradle@v6")) + assertTrue(buildSection.contains("cache-provider: basic")) + } + + @Test + fun `release-apk 使用压缩上传和严格解压下载`() { + assertTrue(buildSection.contains("uses: actions/upload-artifact@v7")) + assertTrue(buildSection.contains("name: release-apk")) + assertTrue(buildSection.contains("archive: true")) + assertTrue(buildSection.contains("retention-days: 30")) + assertTrue(draftReleaseSection.contains("uses: actions/download-artifact@v8")) + assertTrue(draftReleaseSection.contains("name: release-apk")) + assertTrue(draftReleaseSection.contains("path: dist")) + assertTrue(draftReleaseSection.contains("skip-decompress: false")) + assertTrue(draftReleaseSection.contains("digest-mismatch: error")) + assertTrue(workflow.split("name: release-apk").size - 1 == 2) + } + + @Test + fun `下载核验先于 tag 和草稿 Release`() { + assertTrue( + workflow.indexOf("下载 APK") < workflow.indexOf("核对唯一 APK") + ) + assertTrue( + workflow.indexOf("核对唯一 APK") < workflow.indexOf("创建候选 tag") + ) + assertTrue( + workflow.indexOf("创建候选 tag") < workflow.indexOf("创建草稿 Release") + ) + } + @Test fun `签名材料缺失时在构建前失败且没有回退签名`() { listOf( diff --git a/openspec/changes/upgrade-github-actions-v6/.openspec.yaml b/openspec/changes/upgrade-github-actions-v6/.openspec.yaml new file mode 100644 index 000000000..ecf3b45d3 --- /dev/null +++ b/openspec/changes/upgrade-github-actions-v6/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-08-31 diff --git a/openspec/changes/upgrade-github-actions-v6/design.md b/openspec/changes/upgrade-github-actions-v6/design.md new file mode 100644 index 000000000..124254ce8 --- /dev/null +++ b/openspec/changes/upgrade-github-actions-v6/design.md @@ -0,0 +1,156 @@ +## Context + +参见 [proposal.md](proposal.md) 的动机。2026-09-01 实时复核时,当前 `master` 为 +`c009929fb`,#73 的 head 为 `cdc989ee5`,相对 `master` 落后 2 个提交、领先 1 个提交; +其 Android、Web 和 CodeQL +检查通过,但 `OpenSpec 与仓库检查` 及稳定聚合 `维护门禁` 失败。直接失败点是维护契约仍 +精确要求 `actions/setup-java@v5` 和 `pnpm/action-setup@v5`,但现有契约没有完整覆盖 +`release.yml`、`stale.yml` 和其余 Action 版本。 + +目标 Action 均运行于 Node 24,官方给出的最低兼容 Actions Runner 为 `2.327.1`;仓库当前 +只使用 GitHub-hosted `ubuntu-latest`,没有 self-hosted runner。三个工作流分别承载必需质量 +门禁、正式签名草稿发布和 issue 自动关闭,不能用一次普通构建成功推断另外两个工作流安全。 +前置 `harden-element-plus-static-links` 已通过独立 Pull Request #79 合并为 `c009929fb`, +其临时分支也已清理;本变更工作区只保留自身 OpenSpec 工件,不会把两批改动混成一个 +Pull Request。 + +## Goals / Non-Goals + +**Goals:** + +- 让每个目标 Action 大版本都有可追溯的官方行为判断、精确配置和聚焦合同。 +- 先让新合同对旧工作流产生可解释 RED,再以最小工作流改动得到 GREEN。 +- 在普通 CI 中真实执行 v7 上传与 v8 下载闭环,同时保持正式发布 workflow 未触发。 +- 收紧 checkout 凭据、缓存 provider、artifact 解压与摘要、pnpm 缓存所有权等隐式默认值。 +- 从最新 `master` 创建替代 Pull Request,完整 CI 通过后再关闭 #73。 + +**Non-Goals:** + +- 不把本变更扩展为依赖、runner 镜像、CodeQL、构建工具、pnpm 或 Node.js 版本迁移。 +- 不通过正式签名构建、tag、草稿 Release 或设备测试验证 Action 升级。 +- 不改变 stale 的标签、时限、提示文案或业务权限,不改变正式发布授权和候选公开流程。 +- 不在本变更中引入 YAML 解析库、自动合并、SHA pinning 策略或新的自托管 runner。 + +## Decisions + +### 1. 从合并后的最新 master 重建替代变更,不复用 #73 的提交 + +实现前先完成当前 Element Plus 静态链接变更的独立提交、Pull Request、合并和归档读回,随后 +基于新的 `origin/master` 创建本变更分支。#73 只提供目标版本、release notes 和真实 RED +证据,不 cherry-pick、不 rebase 后直接合并。这样可以避免其落后分支覆盖最新的工作流合同和 +OpenSpec 产物,也能让替代 Pull Request 的差异只包含本变更。 + +备选方案是更新 #73 分支并修正合同;该方案保留机器人提交但把后续治理变化与一次 +跨 8 个 Action 的自动更新混在一起,难以建立逐项责任链,因此不采用。 + +### 2. 每个 Action 显式保留或收紧关键默认值 + +| Action | 目标 | 关键变化 | 本仓库决策 | +|---|---|---|---| +| `actions/checkout` | v7 | Node 24;v6 将持久化凭据移到独立文件;v7 阻止受信任事件默认检出 fork PR | 保留既有 `fetch-depth` 和 `clean` 语义;所有不执行 push 的 checkout 显式 `persist-credentials: false`,只有创建候选 tag 的发布作业显式保留凭据 | +| `actions/setup-java` | v6 | Node 24、ESM;Zulu 元数据源变化;输入兼容别名 | 继续固定 Temurin JDK 17,不启用其依赖缓存,由 `setup-gradle` 单独管理 Gradle 缓存 | +| `gradle/actions/setup-gradle` | v6 | Node 24;默认 enhanced 缓存受独立条款约束;缓存协议变化会导致首次 miss | 每处显式设置 `cache-provider: basic`,使用 100% MIT provider;正常 Android/Release 构建继续允许 Gradle build cache,CodeQL 命令继续 `--no-build-cache` | +| `actions/upload-artifact` | v7 | Node 24、ESM;新增单文件 direct upload | 显式 `archive: true`,继续使用命名压缩 artifact;保留现有 `if-no-files-found`、保留期和唯一名称,不启用会忽略 `name` 的 direct upload | +| `actions/download-artifact` | v8 | Node 24、ESM;支持 direct download;摘要不一致默认失败 | 继续按名称下载,显式 `skip-decompress: false` 与 `digest-mismatch: error`;不使用受 v5 路径变化影响的单 artifact ID 下载 | +| `actions/stale` | v11 | Node 24、ESM、安全依赖更新 | 保留 issue-only 参数、豁免标签和 `issues: write`/`contents: read`,不增加 Pull Request 写权限 | +| `pnpm/action-setup` | v6 | Node 24;仍支持 pnpm 10 及以下,pnpm 11+ 才建议迁移到 `pnpm/setup` | 继续固定 pnpm 9.15.9 与 `run_install: false`,显式 `cache: false`,避免与 `setup-node` 双重缓存 | +| `actions/setup-node` | v7 | Node 24、ESM;pnpm 不自动缓存,npm 自动缓存规则变化 | 继续固定 Node.js 22,显式 `cache: pnpm`、锁文件路径和 `package-manager-cache: false`,使缓存所有权唯一 | + +不采用 `pnpm/setup`,因为它要求 pnpm 11+,会把 Action 更新扩展为包管理器迁移;不采用 +Gradle enhanced provider,因为本仓库不需要为当前 CI 引入商业缓存组件和额外条款;不采用 +artifact direct upload,因为现有跨作业合同依赖稳定的 artifact 名称与解压后路径。 + +2026-09-01 实时复核时,8 个 major ref 分别解析为:checkout v7 +`3d3c42e5a`、setup-java v6 `dd06d9cba`、gradle/actions v6 `4733eaac7`、 +upload-artifact v7 `043fb46d1`、download-artifact v8 `3e5f45b2c`、stale v11 +`4391f3da6`、pnpm/action-setup v6 `f520eceda`、setup-node v7 `820762786`。 +对应 `action.yml` 均声明 `node24`;官方迁移说明要求最低 runner `2.327.1`。修改前清点结果为: +`test.yml` 含 checkout 5 处、setup-java/setup-gradle/upload-artifact/setup-node 各 2 处、 +pnpm/action-setup 1 处,保留 CodeQL v4;`release.yml` 含 checkout 3 处及 JDK、Gradle、上传、 +下载各 1 处,只有草稿作业执行 tag push;`stale.yml` 含 stale 1 处。三个工作流分别只由 +`pull_request`/`master` push/手动探针、手动发布、定时/手动 stale 触发,均使用 +GitHub-hosted `ubuntu-latest`,没有 self-hosted runner。 + +### 3. 合同先行,并分别覆盖三个工作流 + +先扩展 `.github/scripts/test_maintenance_workflow.py`,让它分别读取 `test.yml`、 +`release.yml` 和 `stale.yml`,按工作流断言目标版本和关键输入;不新增 PyYAML 等运行时依赖。 +继续使用现有 Kotlin `ReleaseWorkflowContractTest` 锁定正式发布顺序,并补充发布 upload/download +版本、显式压缩/解压、摘要失败、唯一名称和操作先后关系。 + +合同修改后、工作流修改前必须运行 Python 契约和聚焦 Kotlin 测试,记录预期失败的旧版本 +token;只允许这些版本/缺失配置断言失败。随后修改三个工作流并重跑得到 GREEN。版本断言按 +工作流分段,禁止以“文件中某处出现目标 token”代替正确作业中的配置;同时保留以下既有 +合同: + +- CodeQL `init/analyze@v4` 和 Android `--no-build-cache`; +- JDK 17、Node.js 22、pnpm 9.15.9 与冻结安装; +- 正式签名 Secrets 不进入 Pull Request 验证; +- stale 只处理 issue; +- 稳定聚合门禁区分成功、失败和合法跳过。 + +备选方案是只替换现有两个失败字符串;该方案不能发现 release/stale 漂移,也不能证明新默认值 +没有扩大权限或改变 artifact 路径,因此不采用。 + +### 4. 用无签名夹具建立真实 artifact 上传下载闭环 + +在 `test.yml` 增加两个轻量作业:上传作业生成一个确定性文本夹具和对应 SHA-256 文件,使用 +`upload-artifact@v7` 以唯一运行名称、`archive: true`、`if-no-files-found: error` 和 1 天保留期 +上传;下载作业只依赖上传作业,使用 `download-artifact@v8` 按名称下载,并显式设置 +`skip-decompress: false` 与 `digest-mismatch: error`。下载后必须核对文件集合恰好为夹具和摘要 +文件,再执行 `sha256sum --check --strict`。 + +两个作业不 checkout 仓库、不读取 Secrets、只使用 `contents: read`,并在 Pull Request、 +`master` push 和人工 scope probe 中执行。稳定 `维护门禁` 将下载核验作业加入 `needs` 并要求 +成功。相比增加新的路径分类维度,始终运行两个极小作业更简单,也避免“恰好在工作流升级 +Pull Request 中被合法跳过”的漏洞;代价是每次 CI 多一次很小的 artifact 传输。 + +该闭环只证明 v7/v8 服务互操作、压缩解压和内容保持,不证明正式 APK、签名或 Release 流程; +正式 `release.yml` 仍只做静态合同和 actionlint 检查,不执行 `workflow_dispatch`。 + +### 5. 验证顺序保持可归因且失败即停止 + +本地顺序为:Python 维护合同、聚焦 `ReleaseWorkflowContractTest`、固定版本 actionlint、 +OpenSpec 全量严格校验、`git diff --check`。Pull Request 必须实际产生范围识别、Android、Web、 +CodeQL Android/Web、OpenSpec/仓库、artifact 闭环和稳定聚合门禁结果;因为工作流文件属于 CI +实现范围,Android 与 Web 均必须实际运行而不是合法跳过。 + +不触发 `Release Build` 是设计约束,不是“未完成验证”:发布授权、签名、tag、Release 和设备 +门禁没有变化,且其无副作用合同由静态检查覆盖。若 actionlint、合同、任一构建、CodeQL 或 +artifact 服务失败,替代 Pull Request 保持未合并,#73 保持打开。 + +## Risks / Trade-offs + +- [Node 24 Action 需要 runner `2.327.1`] → 当前只使用 GitHub-hosted `ubuntu-latest`;合同记录 + 下限。未来引入 self-hosted runner 必须另行验证,不允许静默复用本结论。 +- [checkout v7 对 `pull_request_target`/`workflow_run` 的 fork 检出更严格] → 当前工作流不使用 + 这两个事件;合同锁住实际触发器,不添加绕过开关。 +- [显式关闭多数 checkout 的凭据持久化可能暴露隐含 push 依赖] → 当前只有发布候选 tag 作业 + 需要 push,并在该作业单独保留;若其他作业因此失败,应修复错误依赖而非全局恢复凭据。 +- [Basic Gradle 缓存首次 miss 且可能慢于 enhanced provider] → 把首次冷启动作为预期,仍要求 + 真实构建成功;用开放许可和较小数据边界换取有限的构建耗时。 +- [artifact 闭环增加外部服务依赖和少量 CI 成本] → 夹具极小、保留 1 天;服务异常保持门禁 + 失败并可在服务恢复后重跑,不把故障降级为成功。 +- [Major tag 可变] → 本变更沿用仓库现有 Dependabot major-tag 策略并用精确合同监测漂移;完整 + commit SHA pinning 会改变更新治理模型,留待独立安全变更。 +- [文本合同可能受无关排版影响] → 按工作流和作业片段缩小断言范围;有意把安全关键默认值写成 + 显式 YAML,以可审计的少量脆性换取 fail-closed。 +- [dirty worktree 混入无关变更] → 前一变更已独立合并并清理;本次只暂存本变更的精确路径, + 不使用 `git add -A`。 + +## Migration Plan + +1. 完成 `harden-element-plus-static-links` 的独立提交、Pull Request、合并、归档后读回和分支 + 清理,确认本地基于最新 `origin/master` 且没有混合未提交改动。 +2. 重新读取 #73 的 head SHA、ahead/behind、检查结果和目标版本;若目标或官方兼容事实变化, + 先更新本变更工件并重新评审。 +3. 先修改 Python/Kotlin 合同并运行,保存只由旧 Action 版本和缺失显式边界造成的 RED。 +4. 修改三个工作流和稳定聚合门禁,运行聚焦合同、actionlint、OpenSpec 和差异检查至 GREEN。 +5. 创建替代 Pull Request,等待 Android、Web、CodeQL、仓库、artifact 和聚合门禁全部绿色; + 不触发 Release workflow。 +6. 人工评审后合并,重新读取 `origin/master` 和合并提交的全部检查。只有读回证据完整时才用 + 替代 Pull Request 链接关闭 #73,随后验证、归档本 OpenSpec 变更并清理已合并临时分支。 + +回滚使用独立 revert Pull Request,不重写 `master`。若合并后发现 runner、缓存、artifact、 +stale 或发布合同回归,保持正式发布冻结;尚未关闭的 #73 继续保留,已关闭则重新打开并说明 +回滚原因,直到新的兼容方案通过同一门禁。 diff --git a/openspec/changes/upgrade-github-actions-v6/proposal.md b/openspec/changes/upgrade-github-actions-v6/proposal.md new file mode 100644 index 000000000..5eac5313a --- /dev/null +++ b/openspec/changes/upgrade-github-actions-v6/proposal.md @@ -0,0 +1,59 @@ +## Why + +Dependabot Pull Request #73 将 8 个 GitHub Action 跨多个大版本升级,但其分支当前落后 +`master` 2 个提交,并因维护工作流的精确版本契约仍要求旧版本而失败。直接合并该分支既会 +绕过对 Node 24、缓存许可、artifact 完整性和 stale 权限语义的审查,也可能覆盖随后完成的 +治理变更,因此需要从最新 `master` 建立可归因、失败即停止的替代变更。 + +## What Changes + +- 从最新 `master` 统一升级三个工作流中的 8 个 Action 大版本: + `actions/checkout@v7`、`actions/setup-java@v6`、`gradle/actions/setup-gradle@v6`、 + `actions/upload-artifact@v7`、`actions/download-artifact@v8`、`actions/stale@v11`、 + `pnpm/action-setup@v6` 和 `actions/setup-node@v7`。 +- 在接受升级前显式核对 Node 24 与最低 runner `2.327.1`、checkout 凭据与清理语义、 + Gradle 缓存许可和首次缓存失效、pnpm/Node 缓存边界、artifact 上传下载兼容性与摘要校验, + 以及 stale 只处理 `needs-info` issue 的权限和筛选语义。 +- 扩展维护与发布工作流契约,使三个工作流的目标 Action 版本、发布唯一 APK 传递闭环、 + CodeQL `--no-build-cache` 和现有最小权限边界均被精确锁定;不得通过删除或模糊断言使门禁 + 变绿。 +- 以当前 #73 的失败作为真实 RED,从最新 `master` 实现并验证替代变更;替代 Pull Request + 通过评审与完整 CI 后才能关闭 #73,并记录替代关系。 +- 非目标:不升级 CodeQL、pnpm 9.15.9、Node.js 22、Gradle、AGP 或应用依赖;不修改应用 + 行为、持久化数据、最低 SDK、签名材料、发布授权逻辑或 stale 的业务策略;验证期间不触发 + 正式 Release。 + +## Capabilities + +### New Capabilities + +无。 + +### Modified Capabilities + +- `repository-maintenance-governance`:增加 GitHub Actions 大版本升级的逐项审查、精确契约、 + stale 语义保持以及落后 Dependabot Pull Request 的替代关闭条件。 +- `maintenance-quality-baseline`:要求 CI Action 运行时兼容且三个工作流的版本和关键安全边界 + 由失败即停止的仓库契约覆盖。 +- `release-verification`:要求发布 workflow 的 artifact 上传下载在不创建 Release 的验证中 + 证明唯一 APK 路径保持一致,并在摘要不匹配时失败。 + +## Impact + +- 受影响文件:`.github/workflows/test.yml`、`.github/workflows/release.yml`、 + `.github/workflows/stale.yml`、`.github/scripts/test_maintenance_workflow.py`、 + `app/src/test/java/io/legado/app/help/update/ReleaseWorkflowContractTest.kt`,以及必要的聚焦 + 工作流契约测试文件。 +- 受影响系统:GitHub-hosted runner、Actions artifact 服务、Gradle Actions 缓存、Dependabot + Pull Request #73 和 `master` 必需检查;正式发布 Secrets 不进入非发布验证。 +- 向后兼容:应用二进制、包名、签名、Room 数据、书源/订阅源规则、导入 URI、备份格式和 + 历史 Release 均不变化,无数据迁移。工作流改用 Node 24 Action,GitHub-hosted runner 满足 + 最低版本;若未来改用低于 `2.327.1` 的 self-hosted runner,门禁必须失败并停止升级。 +- 安全风险:`gradle/actions@v6` 默认启用受独立条款约束的 enhanced 缓存,因此本变更显式 + 选择 100% MIT 的 `basic` provider;升级后首次运行预计缓存未命中; + `download-artifact@v8` 的摘要不一致默认失败必须保留;checkout 凭据、正式签名 Secrets、 + stale 写权限和 CodeQL 无构建缓存边界不得扩大。 +- 可观察验收:替代 Pull Request 在最新 `master` 上产生 Android、Web、CodeQL、OpenSpec/ + 仓库检查和稳定聚合门禁的绿色结果;发布 artifact 契约证明上传和下载仍得到唯一预期 APK; + stale 契约证明 Pull Request、崩溃、数据损坏和安全 issue 不会被时间规则误关;未触发任何 + 正式 Release,且 #73 只在上述证据可读后按替代关系关闭。 diff --git a/openspec/changes/upgrade-github-actions-v6/specs/maintenance-quality-baseline/spec.md b/openspec/changes/upgrade-github-actions-v6/specs/maintenance-quality-baseline/spec.md new file mode 100644 index 000000000..6ee9a7b0f --- /dev/null +++ b/openspec/changes/upgrade-github-actions-v6/specs/maintenance-quality-baseline/spec.md @@ -0,0 +1,54 @@ +## ADDED Requirements + +### Requirement: 工作流依赖运行时和关键安全合同必须失败即停止 +Pull Request、`master`、发布和 stale 工作流使用的 JavaScript Action MUST 与执行 runner +兼容;本次目标 Action 运行于 Node 24,runner 版本 MUST 不低于 `2.327.1`。仓库契约 MUST +精确覆盖三个工作流中的目标 Action 大版本,并继续覆盖 CodeQL v4、Android CodeQL +`--no-build-cache`、JDK 17、Node.js 22、pnpm 9.15.9、只读 Web 构建、正式签名 Secrets +隔离和稳定聚合门禁。任一版本或安全边界漂移 MUST 使仓库检查失败,不能以部分工作成功代替。 + +#### Scenario: 目标 Action 版本与契约不一致 +- **WHEN** 任一工作流使用的目标 Action 大版本与批准组合不一致 +- **THEN** 仓库契约检查失败并指出发生漂移的工作流和 Action +- **AND** 稳定聚合门禁报告失败 + +#### Scenario: runner 不满足 Node 24 下限 +- **WHEN** 工作流在低于 `2.327.1` 的 runner 上执行 Node 24 Action +- **THEN** 对应检查必须失败或被明确阻止 +- **AND** 系统不得把未执行的后续构建描述为通过 + +#### Scenario: 维护契约真实 RED 被同步修复 +- **WHEN** Action 大版本升级使旧版本精确断言失败 +- **THEN** 系统只将断言更新为逐工作流批准的新版本并补齐缺失覆盖 +- **AND** 不删除 JDK、Node.js、pnpm、CodeQL、缓存、签名隔离或聚合门禁断言 + +#### Scenario: 工作流实现文件发生变化 +- **WHEN** Pull Request 修改测试、发布、stale 工作流或其仓库契约 +- **THEN** 变更范围识别必须触发 Android、Web、CodeQL、OpenSpec 和仓库验证的完整适用门禁 +- **AND** 每个实际运行的失败均阻止稳定聚合门禁通过 + +### Requirement: Gradle Action 缓存升级必须显式选择开放边界并保留可审计失败 +使用 `gradle/actions/setup-gradle@v6` 的工作流 MUST 显式选择 100% MIT 的 `basic` 缓存 +provider,不得加载默认的 enhanced 商业缓存组件,并将升级后的首次缓存未命中视为预期冷启动 +而非构建失败。缓存保存或恢复异常 MUST 在作业摘要或日志中可见,且 Android CodeQL 构建 +MUST 继续使用 `--no-build-cache`,不得用缓存命中替代 Java/Kotlin 源码编译证据。 + +#### Scenario: 缓存 provider 配置漂移 +- **WHEN** `setup-gradle@v6` 没有显式选择 `basic` provider 或改为 enhanced provider +- **THEN** 仓库契约检查失败 +- **AND** 变更不得在缺少新的独立评审时合并 + +#### Scenario: 升级后首次运行缓存未命中 +- **WHEN** 新缓存协议使既有 Gradle 缓存失效 +- **THEN** 工作流重新填充缓存并继续执行真实构建 +- **AND** 不将预期缓存未命中报告为应用兼容性回归 + +#### Scenario: CodeQL Android 任务执行 +- **WHEN** CodeQL 分析 Java/Kotlin 源码 +- **THEN** Gradle 构建禁用 build cache 并实际编译目标源码 +- **AND** 缓存恢复成功不得代替 CodeQL 可处理源码的证据 + +#### Scenario: 缓存操作失败 +- **WHEN** Gradle 缓存保存或恢复失败 +- **THEN** 工作流日志或摘要必须暴露该状态 +- **AND** 不得伪造缓存成功或隐藏后续真实构建结果 diff --git a/openspec/changes/upgrade-github-actions-v6/specs/release-verification/spec.md b/openspec/changes/upgrade-github-actions-v6/specs/release-verification/spec.md new file mode 100644 index 000000000..66e6ab7de --- /dev/null +++ b/openspec/changes/upgrade-github-actions-v6/specs/release-verification/spec.md @@ -0,0 +1,42 @@ +## ADDED Requirements + +### Requirement: 发布工作流内部 artifact 传递必须保持唯一且完整 +发布工作流 MUST 将唯一普通正式 APK 以唯一名称上传,并在后续作业按该名称下载到确定路径; +下载结果 MUST 恰好包含预期文件名的一个 APK。artifact 服务报告的摘要或下载内容摘要不一致 +时 MUST 失败,不得创建 tag、草稿 Release 或其他发布对象。Action 升级不得改变 APK 名称、 +路径、保留期、包名、签名身份或 Release 唯一资产约束。 + +#### Scenario: 唯一 APK 完整传递 +- **WHEN** 构建作业产生唯一预期普通正式 APK 并完成 artifact 上传 +- **THEN** 后续作业按唯一 artifact 名称下载到预期目录 +- **AND** 下载后只存在文件名、大小和摘要均符合预期的一个 APK + +#### Scenario: artifact 摘要不匹配 +- **WHEN** 下载内容摘要与 artifact 服务提供的预期摘要不一致 +- **THEN** 发布工作流立即失败 +- **AND** 不创建 tag、草稿 Release 或替代资产 + +#### Scenario: artifact 数量或路径漂移 +- **WHEN** 下载目录没有 APK、包含多个 APK 或唯一 APK 不在预期路径 +- **THEN** 发布工作流立即失败 +- **AND** 不通过搜索其他目录或重命名未知文件继续发布 + +### Requirement: 发布 Action 升级验证不得产生发布副作用 +系统 MUST 在不读取正式签名 Secrets、不运行正式构建、不推送 tag、不创建或修改 Release 的 +前提下验证发布工作流的触发、权限、目标 Action 版本、唯一 APK 传递和失败即停止合同。只有 +替代变更合并后另行取得明确发布授权,才可手动触发正式发布工作流。 + +#### Scenario: Pull Request 验证发布工作流 +- **WHEN** Pull Request 修改发布工作流或其合同 +- **THEN** 系统通过静态语法检查和仓库合同验证发布边界 +- **AND** 不读取签名 Secrets、不构建正式 APK 且不创建任何发布对象 + +#### Scenario: 验证需要真实 artifact 闭环 +- **WHEN** 静态合同不足以证明升级后的上传下载兼容性 +- **THEN** 系统使用无签名、无发布权限的测试夹具执行上传下载闭环 +- **AND** 夹具不得被当作 APK、Release 资产或正式发布证据 + +#### Scenario: 工作流升级已经合并 +- **WHEN** 替代变更已合并且维护者尚未明确授权新一轮正式发布 +- **THEN** 系统保持发布工作流未触发 +- **AND** 不以 CI 绿色推断签名、设备或公开 Release 门禁已经通过 diff --git a/openspec/changes/upgrade-github-actions-v6/specs/repository-maintenance-governance/spec.md b/openspec/changes/upgrade-github-actions-v6/specs/repository-maintenance-governance/spec.md new file mode 100644 index 000000000..7405a54a4 --- /dev/null +++ b/openspec/changes/upgrade-github-actions-v6/specs/repository-maintenance-governance/spec.md @@ -0,0 +1,46 @@ +## ADDED Requirements + +### Requirement: GitHub Actions 大版本升级必须逐项审查并替代失效队列 +系统 MUST 从最新 `master` 评估 GitHub Actions 大版本升级,并为目标版本的运行时、runner +下限、凭据、缓存、artifact、权限和事件筛选语义保存可验证的判断。目标组合 MUST 为 +`actions/checkout@v7`、`actions/setup-java@v6`、`gradle/actions/setup-gradle@v6`、 +`actions/upload-artifact@v7`、`actions/download-artifact@v8`、`actions/stale@v11`、 +`pnpm/action-setup@v6` 和 `actions/setup-node@v7`;CodeQL、JDK、Node.js、pnpm、Gradle、 +AGP 和应用依赖不得借此变更升级。落后当前 `master` 且无法通过现行仓库契约的机器人 Pull +Request MUST NOT 直接合并,只能在替代 Pull Request 完成评审与全部必需检查后按可追踪的 +替代关系关闭。 + +#### Scenario: 候选分支落后且契约失败 +- **WHEN** GitHub Actions 更新 Pull Request 已落后当前 `master`,并因仍要求旧版本的精确 + 仓库契约而失败 +- **THEN** 系统保留该失败作为真实兼容性 RED,并从最新 `master` 建立替代变更 +- **AND** 不通过删除、跳过或模糊契约断言使原 Pull Request 可合并 + +#### Scenario: 任一大版本缺少兼容证据 +- **WHEN** 目标 Action 的运行时、runner 下限、权限、缓存、凭据或 artifact 行为尚未完成审查 +- **THEN** 系统不得批准或合并该升级 +- **AND** 已完成审查的其他 Action 不得被用于推断该目标兼容 + +#### Scenario: 替代 Pull Request 通过完整门禁 +- **WHEN** 从最新 `master` 创建的替代 Pull Request 已通过人工评审和全部必需检查 +- **THEN** 系统可以关闭被替代的机器人 Pull Request,并在关闭记录中关联替代 Pull Request +- **AND** 该动作不得被解释为允许普通依赖自动合并 + +### Requirement: Action 升级必须保持 stale 的问题范围和最小权限 +升级后的 stale 工作流 MUST 继续只处理带 `needs-info` 标签且超过 30 天未补充资料的 issue, +MUST 禁止对 Pull Request 执行 stale 或 close,并 MUST 继续豁免崩溃、数据损坏和安全 issue。 +工作流权限 MUST 限定为读取仓库内容和写入 issue,不得新增 Pull Request、Release、Actions +或其他写权限。 + +#### Scenario: Pull Request 长期无活动 +- **WHEN** 任意 Pull Request 超过 stale 时间阈值且没有新活动 +- **THEN** stale 工作流不得标记或关闭该 Pull Request + +#### Scenario: 严重 issue 带有豁免标签 +- **WHEN** issue 带有 `crash`、`data-loss` 或 `security` 标签 +- **THEN** stale 工作流不得仅因时间流逝标记或关闭该 issue + +#### Scenario: needs-info issue 长期未补充 +- **WHEN** issue 带有 `needs-info` 标签、没有豁免标签且超过 30 天未补充资料 +- **THEN** stale 工作流可以按既有消息和恢复说明关闭该 issue +- **AND** 执行期间只使用 issue 写权限和内容读权限 diff --git a/openspec/changes/upgrade-github-actions-v6/tasks.md b/openspec/changes/upgrade-github-actions-v6/tasks.md new file mode 100644 index 000000000..b3d8d3665 --- /dev/null +++ b/openspec/changes/upgrade-github-actions-v6/tasks.md @@ -0,0 +1,86 @@ +## 1. 隔离前置变更并刷新事实 + +- [x] 1.1 完成 `harden-element-plus-static-links` 的独立提交、Pull Request、合并、归档读回和 + 已合并临时分支清理,确认本变更将基于合并后的最新 `origin/master`,且不会混入前一变更的 + 未提交文件。 +- [x] 1.2 重新读取 #73 的 head SHA、ahead/behind、目标版本和全部检查;确认它仍因精确版本 + 合同失败并继续保持打开,若事实漂移则先更新并重新评审本 OpenSpec 工件。 +- [x] 1.3 对照官方 release/action metadata 复核 8 个目标 Action 的 Node 24、runner + `2.327.1` 下限、输入和大版本差异,记录 `checkout` fork/凭据、Gradle provider、artifact + 摘要、pnpm 缓存和 stale 权限结论。 +- [x] 1.4 清点三个工作流的 Action 出现位置、事件、权限、缓存、push 和 artifact 路径,确认 + CodeQL v4、JDK 17、Node.js 22、pnpm 9.15.9、Gradle/AGP、应用依赖及正式发布授权均不在 + 升级范围。 + +## 2. 建立精确合同并观察 RED + +- [x] 2.1 扩展 `.github/scripts/test_maintenance_workflow.py`,分别读取 `test.yml`、 + `release.yml` 和 `stale.yml`,按工作流片段断言 8 个批准的大版本,并拒绝目标 Action 的旧 + major 或未批准 major。 +- [x] 2.2 为测试工作流补充合同:非 push checkout 不持久化凭据、所有 `setup-gradle@v6` + 使用 `cache-provider: basic`、pnpm 与 setup-node 缓存所有权唯一、upload 使用压缩 artifact, + 且稳定聚合门禁要求真实 artifact 下载核验成功。 +- [x] 2.3 为 stale 工作流补充合同:仅有 `issues: write` 和 `contents: read`,只处理 + `needs-info` issue,豁免 `crash,data-loss,security`,并保持两个 Pull Request 时限为 `-1`。 +- [x] 2.4 扩展 `ReleaseWorkflowContractTest`,精确断言 release checkout 凭据边界、JDK/Gradle + 目标、唯一 `release-apk` 的 v7 压缩上传、v8 按名解压下载、`digest-mismatch: error` 以及 + 下载核验先于 tag/Release 创建。 +- [x] 2.5 在修改工作流前运行 Python 合同和聚焦 Kotlin 合同,保存只由旧 Action major 和 + 缺失显式安全配置造成的预期 RED;出现其他失败时停止,不进入工作流修改。 + +## 3. 升级测试与安全分析工作流 + +- [x] 3.1 将 `.github/workflows/test.yml` 的 checkout 升至 v7、setup-java 和 setup-gradle + 升至 v6、upload-artifact 和 pnpm/action-setup 升至 v7/v6、setup-node 升至 v7,并保持 + CodeQL `init/analyze@v4` 不变。 +- [x] 3.2 为所有不执行 push 的 checkout 显式设置 `persist-credentials: false`,为每个 + setup-gradle 显式设置 `cache-provider: basic`,同时保持 Android CodeQL 命令只有 + `--no-build-cache` 而没有 `--build-cache`。 +- [x] 3.3 为 pnpm/action-setup 保持 `version: 9.15.9`、`run_install: false` 并显式关闭其 + cache;由 setup-node 继续用 Node.js 22、固定锁文件和 pnpm cache,并显式关闭 npm 自动缓存。 +- [x] 3.4 为现有 lint/Debug artifact 上传显式设置 `archive: true`,保持原名称、缺失文件策略 + 和保留期,不启用 direct upload、overwrite 或隐藏文件上传。 +- [x] 3.5 增加无签名 artifact 上传与下载作业:上传确定性夹具及 SHA-256 文件并保留 1 天, + 下载时按唯一名称使用 `skip-decompress: false` 和 `digest-mismatch: error`,核对精确文件集合 + 后执行 `sha256sum --check --strict`。 +- [x] 3.6 将 artifact 下载核验加入稳定 `维护门禁` 的 `needs` 和成功判定,确保其失败、取消 + 或跳过均不能被聚合为绿色。 + +## 4. 升级发布与 stale 工作流 + +- [x] 4.1 将 `.github/workflows/release.yml` 的 checkout 升至 v7、setup-java 和 setup-gradle + 升至 v6、upload-artifact 升至 v7、download-artifact 升至 v8,不改变触发器、授权、签名、 + 唯一普通 APK、tag、草稿 Release 或目标 SHA 逻辑。 +- [x] 4.2 在 release 的 authorize/build checkout 中关闭凭据持久化,只在需要 push 候选 tag 的 + draft-release checkout 中显式保留;所有 setup-gradle 使用 `cache-provider: basic`。 +- [x] 4.3 为 release upload 显式设置 `archive: true`,为 download 显式设置 + `skip-decompress: false` 和 `digest-mismatch: error`,保持 `release-apk`、`dist` 路径、30 天 + 保留期与下载后唯一 APK 核验。 +- [x] 4.4 将 `.github/workflows/stale.yml` 升至 `actions/stale@v11`,逐项对账触发器、最小权限、 + issue-only 时限、标签、豁免项和中英文关闭说明均无语义变化。 + +## 5. 本地 GREEN 与差异审计 + +- [x] 5.1 运行 `python3 .github/scripts/test_maintenance_workflow.py`,确认版本矩阵、权限、缓存、 + artifact、stale、CodeQL 和聚合合同全部通过。 +- [x] 5.2 运行聚焦 `ReleaseWorkflowContractTest` 及 `:app:testAppDebugUnitTest`,确认发布合同和 + 既有 Android 单元测试通过;不得把未运行的正式签名构建或设备测试描述为通过。 +- [x] 5.3 使用仓库固定的 actionlint `1.7.12` 和已记录 SHA-256 校验三个工作流,确认 YAML、 + 表达式、作业依赖和新增 artifact 闭环无静态错误。 +- [x] 5.4 运行 `openspec validate --all --strict`、`git diff --check`、精确 `git diff --stat` 和 + `git status --short`,确认没有依赖、锁文件、应用源码、Room schema、签名材料、Release 对象 + 或无关生成文件变化。 + +## 6. 替代 Pull Request 与远端闭环 + +- [x] 6.1 只暂存本变更的精确路径,提交并推送独立 `codex/` 分支,创建明确说明“替代 #73、 + 不触发正式 Release”的 Pull Request;不得使用 `git add -A`。 +- [x] 6.2 等待范围识别、Android、Web、CodeQL Android/Web、OpenSpec/仓库、artifact 上传下载 + 和稳定 `维护门禁` 全部实际绿色,核对 Gradle 首次缓存 miss 没有隐藏构建失败,且 CodeQL + Android 确实使用 `--no-build-cache` 编译源码。 +- [x] 6.3 核对替代 Pull Request 没有触发 `Release Build`,没有读取正式签名 Secrets、推送 + tag、创建草稿 Release 或改变 stale 实际对象;任何失败均保持 #73 打开并停止合并。 +- [ ] 6.4 人工评审通过后合并替代 Pull Request,重新读取 `origin/master` SHA、合并提交检查、 + 三个工作流和 OpenSpec 状态;只有读回结果完整时才以替代 Pull Request 链接关闭 #73。 +- [ ] 6.5 按验证与归档流程同步主规范、归档 `upgrade-github-actions-v6`,在归档提交和合并后的 + `master` 上再次严格校验,并只按精确 ref/SHA 清理已合并临时分支;不触发正式发布。