Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
166 changes: 160 additions & 6 deletions .github/scripts/test_maintenance_workflow.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]:
Expand Down Expand Up @@ -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)
Expand All @@ -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:",
):
Expand All @@ -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",
Expand All @@ -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",
Expand Down Expand Up @@ -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)
22 changes: 15 additions & 7 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand 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
Expand All @@ -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
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/stale.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading