From 86b2855b46faa2f6f8f907742ba3042fc2985873 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A9=BA=E5=A0=82?= Date: Wed, 22 Jul 2026 15:35:54 +0800 Subject: [PATCH 1/7] ci: harden GitHub Actions execution boundaries --- .github/workflows/ci.yml | 16 +++++++--- .github/workflows/codeql.yml | 6 +++- .github/workflows/docs.yml | 16 +++++----- .github/workflows/extended-ci.yml | 35 ++++++++++----------- .github/workflows/release.yml | 2 +- .github/workflows/runner-image.yml | 10 ++++-- .github/workflows/skill-upper-self-eval.yml | 27 +++++++--------- 7 files changed, 63 insertions(+), 49 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c9189df..d2e56de 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,7 +18,7 @@ on: # Expensive engine, sandbox, Windows and release checks live in extended-ci.yml. concurrency: group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} - cancel-in-progress: true + cancel-in-progress: ${{ github.event_name == 'pull_request' }} permissions: contents: read @@ -26,10 +26,14 @@ permissions: jobs: build: name: Build & Test - runs-on: self-hosted + # Pull requests can contain untrusted code. Use an ephemeral GitHub-hosted + # runner instead of a persistent self-hosted machine. + runs-on: ubuntu-24.04 timeout-minutes: 10 steps: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false - uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6 with: @@ -56,10 +60,12 @@ jobs: e2e-smoke: name: E2E Smoke - runs-on: self-hosted + runs-on: ubuntu-24.04 timeout-minutes: 15 steps: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false - uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6 with: @@ -82,10 +88,12 @@ jobs: lint: name: Lint - runs-on: self-hosted + runs-on: ubuntu-24.04 timeout-minutes: 10 steps: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false - uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6 with: diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 8ee00e4..17c8085 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -27,7 +27,9 @@ jobs: # - https://gh.io/supported-runners-and-hardware-resources # - https://gh.io/using-larger-runners (GitHub.com only) # Consider using larger runners or machines with greater resources for possible analysis time improvements. - runs-on: self-hosted + # This workflow executes pull-request code during autobuild. Keep it off + # persistent self-hosted runners. + runs-on: ubuntu-24.04 permissions: # required for all workflows security-events: write @@ -60,6 +62,8 @@ jobs: steps: - name: Checkout repository uses: actions/checkout@v7 + with: + persist-credentials: false # Add any setup steps before running the `github/codeql-action/init` action. # This includes steps like installing compilers or runtimes (`actions/setup-node` diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 6fc5b4c..4e2702d 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -12,11 +12,8 @@ on: - '.github/workflows/docs.yml' workflow_dispatch: -# Required for GitHub Pages deployment via actions/deploy-pages. -permissions: - contents: read - pages: write - id-token: write +# Jobs opt into only the permissions they need. +permissions: {} # Allow only one concurrent deployment, but do not cancel in-progress runs. concurrency: @@ -25,7 +22,9 @@ concurrency: jobs: build: - runs-on: self-hosted + runs-on: [self-hosted, linux, x64, trusted] + permissions: + contents: read steps: - name: Checkout uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 @@ -54,7 +53,10 @@ jobs: deploy: needs: build - runs-on: self-hosted + runs-on: [self-hosted, linux, x64, trusted] + permissions: + pages: write + id-token: write environment: name: github-pages url: ${{ steps.deployment.outputs.page_url }} diff --git a/.github/workflows/extended-ci.yml b/.github/workflows/extended-ci.yml index 50e9d98..a5fbd8f 100644 --- a/.github/workflows/extended-ci.yml +++ b/.github/workflows/extended-ci.yml @@ -14,7 +14,9 @@ permissions: jobs: e2e-windows: name: E2E (none runtime, Windows) - runs-on: self-hosted + # No trusted Windows self-hosted runner is provisioned. Use a clean, + # explicitly versioned GitHub-hosted Windows image. + runs-on: windows-2025 timeout-minutes: 20 steps: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 @@ -41,7 +43,7 @@ jobs: e2e: name: E2E (none runtime) - runs-on: self-hosted + runs-on: [self-hosted, linux, x64, trusted] timeout-minutes: 35 steps: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 @@ -52,11 +54,10 @@ jobs: DASHSCOPE_API_KEY: ${{ secrets.DASHSCOPE_API_KEY }} run: | if [ -z "$DASHSCOPE_API_KEY" ]; then - echo "::notice::Skipping e2e: DASHSCOPE_API_KEY not provisioned." - echo "available=false" >> "$GITHUB_OUTPUT" - else - echo "available=true" >> "$GITHUB_OUTPUT" + echo "::error::DASHSCOPE_API_KEY is required for full e2e." + exit 1 fi + echo "available=true" >> "$GITHUB_OUTPUT" - uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6 if: steps.secrets.outputs.available == 'true' @@ -135,7 +136,7 @@ jobs: e2e-opensandbox: name: E2E (opensandbox runtime) - runs-on: self-hosted + runs-on: [self-hosted, linux, x64, docker, trusted] timeout-minutes: 35 steps: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 @@ -146,11 +147,10 @@ jobs: DASHSCOPE_API_KEY: ${{ secrets.DASHSCOPE_API_KEY }} run: | if [ -z "$DASHSCOPE_API_KEY" ]; then - echo "::notice::Skipping opensandbox e2e: DASHSCOPE_API_KEY not provisioned." - echo "available=false" >> "$GITHUB_OUTPUT" - else - echo "available=true" >> "$GITHUB_OUTPUT" + echo "::error::DASHSCOPE_API_KEY is required for opensandbox e2e." + exit 1 fi + echo "available=true" >> "$GITHUB_OUTPUT" - uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6 if: steps.secrets.outputs.available == 'true' @@ -239,7 +239,7 @@ jobs: e2e-docker: name: E2E (docker runtime) - runs-on: self-hosted + runs-on: [self-hosted, linux, x64, docker, trusted] timeout-minutes: 20 steps: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 @@ -262,7 +262,7 @@ jobs: e2e-docker-full: name: E2E (docker runtime, full LLM) - runs-on: self-hosted + runs-on: [self-hosted, linux, x64, docker, trusted] timeout-minutes: 25 steps: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 @@ -273,11 +273,10 @@ jobs: DASHSCOPE_API_KEY: ${{ secrets.DASHSCOPE_API_KEY }} run: | if [ -z "$DASHSCOPE_API_KEY" ]; then - echo "::notice::Skipping docker full e2e: DASHSCOPE_API_KEY not provisioned." - echo "available=false" >> "$GITHUB_OUTPUT" - else - echo "available=true" >> "$GITHUB_OUTPUT" + echo "::error::DASHSCOPE_API_KEY is required for docker full e2e." + exit 1 fi + echo "available=true" >> "$GITHUB_OUTPUT" - uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6 if: steps.secrets.outputs.available == 'true' @@ -310,7 +309,7 @@ jobs: release-dryrun: name: GoReleaser Check - runs-on: self-hosted + runs-on: [self-hosted, linux, x64, trusted] timeout-minutes: 15 steps: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6b3940e..81d78ee 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -10,7 +10,7 @@ permissions: jobs: goreleaser: - runs-on: self-hosted + runs-on: [self-hosted, linux, x64, trusted] steps: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: diff --git a/.github/workflows/runner-image.yml b/.github/workflows/runner-image.yml index 7b5fc6b..8cb1760 100644 --- a/.github/workflows/runner-image.yml +++ b/.github/workflows/runner-image.yml @@ -55,7 +55,7 @@ concurrency: jobs: build: - runs-on: self-hosted + runs-on: [self-hosted, linux, x64, docker, trusted] permissions: contents: read packages: write @@ -68,7 +68,13 @@ jobs: # Fork PRs can't push packages; build-only for them. SAME_REPO: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository }} PUBLISH_LATEST: ${{ github.event_name == 'push' || inputs.publish_latest }} + REQUESTED_TAG: ${{ inputs.tag || 'v0.1.0' }} run: | + set -euo pipefail + if [[ ! "$REQUESTED_TAG" =~ ^[A-Za-z0-9_][A-Za-z0-9_.-]{0,127}$ ]]; then + echo "::error::Invalid image tag" + exit 1 + fi { echo "tags<<__TAGS__" if [ "${{ github.event_name }}" = "pull_request" ]; then @@ -80,7 +86,7 @@ jobs: else # push to main / manual dispatch = release; reusable callers can # opt into latest explicitly, but self-eval uses an isolated tag. - echo "${IMAGE}:${{ inputs.tag || 'v0.1.0' }}" + echo "${IMAGE}:${REQUESTED_TAG}" if [ "$PUBLISH_LATEST" = "true" ]; then echo "${IMAGE}:latest" fi diff --git a/.github/workflows/skill-upper-self-eval.yml b/.github/workflows/skill-upper-self-eval.yml index 4b91419..6b1c31b 100644 --- a/.github/workflows/skill-upper-self-eval.yml +++ b/.github/workflows/skill-upper-self-eval.yml @@ -49,7 +49,7 @@ jobs: dogfood: name: Eval skill-upper (${{ matrix.engine }}) needs: image - runs-on: self-hosted # Docker container action — Linux only + runs-on: [self-hosted, linux, x64, docker, trusted] strategy: fail-fast: false matrix: @@ -82,9 +82,8 @@ jobs: echo "dogfood will use ${img}" grep 'image: docker://' action.yml - # Fork PRs run without repository secrets; skip cleanly instead of 401ing. - # The judge hop always needs the dashscope key, so require it for every - # engine; qodercli additionally needs its own PAT. + # This workflow is manual and trusted. Missing credentials are a + # configuration error and must fail instead of producing a green no-op. - name: Check secret availability id: secrets env: @@ -94,19 +93,13 @@ jobs: DASHSCOPE_API_KEY: ${{ secrets.DASHSCOPE_API_KEY }} run: | if [ -z "$DASHSCOPE_API_KEY" ]; then - echo "::notice::Skipping self-eval (${{ matrix.engine }}): required secret not provisioned (likely a fork PR)." - echo "available=false" >> "$GITHUB_OUTPUT" + echo "::error::DASHSCOPE_API_KEY is required for self-eval (${{ matrix.engine }})." + exit 1 elif [ "${{ matrix.engine }}" = "qodercli" ] && [ -z "$QODER_ACCESS_TOKEN" ]; then - echo "::notice::Skipping self-eval (${{ matrix.engine }}): required secret not provisioned (likely a fork PR)." - echo "available=false" >> "$GITHUB_OUTPUT" - else - echo "available=true" >> "$GITHUB_OUTPUT" - if [ "${{ matrix.engine }}" = "qodercli" ]; then - echo "api_key=${QODER_ACCESS_TOKEN}" >> "$GITHUB_OUTPUT" - else - echo "api_key=${DASHSCOPE_API_KEY}" >> "$GITHUB_OUTPUT" - fi + echo "::error::QODER_ACCESS_TOKEN is required for qodercli self-eval." + exit 1 fi + echo "available=true" >> "$GITHUB_OUTPUT" - name: Run skill-upper eval via the action if: steps.secrets.outputs.available == 'true' @@ -132,7 +125,9 @@ jobs: engine: ${{ matrix.engine }} provider: ${{ matrix.provider }} model: ${{ matrix.model }} - api-key: ${{ steps.secrets.outputs.api_key }} + # Pass the selected credential directly; never copy a secret through + # step outputs, which GitHub may suppress as secret-bearing data. + api-key: ${{ matrix.engine == 'qodercli' && secrets.QODER_ACCESS_TOKEN || secrets.DASHSCOPE_API_KEY }} # Run cases concurrently so the larger per-case timeout (600s, needed # by the slower codex coder model) doesn't blow up wall-clock — but # keep qodercli serial: concurrent qodercli processes race on the fixed From 6bb82f5088b189fa5463d5c1b7a4e0065e5e19ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A9=BA=E5=A0=82?= Date: Wed, 22 Jul 2026 16:38:07 +0800 Subject: [PATCH 2/7] ci: pin workflow supply chain --- .github/workflows/codeql.yml | 16 ++----- .github/workflows/docs.yml | 2 + .github/workflows/extended-ci.yml | 29 ++++++++---- .github/workflows/release.yml | 28 +++++++++-- .github/workflows/runner-image.yml | 29 ++++++++++-- .github/workflows/skill-upper-self-eval.yml | 17 ++----- action.yml | 5 +- action/Dockerfile | 52 ++++++++++++++------- action/entry.sh | 21 ++++++--- action/main.py | 45 +++++++++++++----- 10 files changed, 168 insertions(+), 76 deletions(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 17c8085..2ab7857 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -30,15 +30,9 @@ jobs: # This workflow executes pull-request code during autobuild. Keep it off # persistent self-hosted runners. runs-on: ubuntu-24.04 + timeout-minutes: 45 permissions: - # required for all workflows security-events: write - - # required to fetch internal or private CodeQL packs - packages: read - - # only required for workflows in private repositories - actions: read contents: read strategy: @@ -61,7 +55,7 @@ jobs: # your codebase is analyzed, see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/codeql-code-scanning-for-compiled-languages steps: - name: Checkout repository - uses: actions/checkout@v7 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false @@ -72,13 +66,13 @@ jobs: # uses: actions/setup-example@v1 - name: Set up Python if: matrix.language == 'python' - uses: actions/setup-python@v6 + uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0 with: python-version: '3.12' # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@v4 + uses: github/codeql-action/init@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4.37.3 with: languages: ${{ matrix.language }} build-mode: ${{ matrix.build-mode }} @@ -107,6 +101,6 @@ jobs: exit 1 - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v4 + uses: github/codeql-action/analyze@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4.37.3 with: category: "/language:${{matrix.language}}" diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 4e2702d..54e5cc6 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -23,6 +23,7 @@ concurrency: jobs: build: runs-on: [self-hosted, linux, x64, trusted] + timeout-minutes: 15 permissions: contents: read steps: @@ -54,6 +55,7 @@ jobs: deploy: needs: build runs-on: [self-hosted, linux, x64, trusted] + timeout-minutes: 10 permissions: pages: write id-token: write diff --git a/.github/workflows/extended-ci.yml b/.github/workflows/extended-ci.yml index a5fbd8f..6464f23 100644 --- a/.github/workflows/extended-ci.yml +++ b/.github/workflows/extended-ci.yml @@ -73,7 +73,7 @@ jobs: - name: Install claude-code CLI if: steps.secrets.outputs.available == 'true' run: | - npm install -g --include=optional @anthropic-ai/claude-code + npm install -g --include=optional '@anthropic-ai/claude-code@2.1.217' claude --version - name: Install codex CLI @@ -85,7 +85,7 @@ jobs: - name: Install qwen-code CLI if: steps.secrets.outputs.available == 'true' run: | - npm install -g @qwen-code/qwen-code + npm install -g '@qwen-code/qwen-code@0.20.1' qwen --version - name: Install qodercli (pinned v1.0.14) @@ -171,11 +171,12 @@ jobs: - name: Start OpenSandbox server if: steps.secrets.outputs.available == 'true' env: - OPENSANDBOX_IMAGE: node:22 - OPENSANDBOX_EXECD_IMAGE: opensandbox/execd:v1.0.16 + OPENSANDBOX_IMAGE: node:22@sha256:5647be709086c696ff32edaaf1c70cd26d1da6ab2b39c32f3c7b4c4a31957e37 + OPENSANDBOX_EXECD_IMAGE: opensandbox/execd:v1.0.16@sha256:af7b55c861926c1304371c4578007fbaa424538219154a6a49a5d636217d2a3a run: | docker version api_key="ci-$(openssl rand -hex 16)" + echo "::add-mask::$api_key" config="$RUNNER_TEMP/sandbox.toml" cat > "$config" < "$log" 2>&1 & + echo "OPENSANDBOX_SERVER_PID=$!" >> "$GITHUB_ENV" for i in $(seq 1 60); do if curl -fsS http://127.0.0.1:8080/health 2>/dev/null | grep -q healthy; then echo "OpenSandbox server is healthy" @@ -219,6 +221,17 @@ jobs: OPENAI_MODEL: qwen3.6-plus SKILL_UP_E2E_ARTIFACT_DIR: ${{ github.workspace }}/e2e-opensandbox-artifacts + - name: Stop OpenSandbox server + if: always() && steps.secrets.outputs.available == 'true' + run: | + if [ -n "${OPENSANDBOX_SERVER_PID:-}" ]; then + if kill "$OPENSANDBOX_SERVER_PID" 2>/dev/null; then + echo "OpenSandbox server stopped" + else + echo "::notice::OpenSandbox server was already stopped" + fi + fi + - name: Upload OpenSandbox server log if: always() && steps.secrets.outputs.available == 'true' uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 @@ -255,7 +268,7 @@ jobs: docker info - name: Pre-pull base image - run: docker pull alpine:3.20 + run: docker pull alpine:3.20@sha256:d9e853e87e55526f6b2917df91a2115c36dd7c696a35be12163d44e6e2a4b6bc - name: Run docker integration tests run: go test -tags docker_integration -timeout 600s -count=1 -v ./internal/runtime/ @@ -286,7 +299,7 @@ jobs: - name: Pre-pull Docker image if: steps.secrets.outputs.available == 'true' - run: docker pull node:22 + run: docker pull node:22@sha256:5647be709086c696ff32edaaf1c70cd26d1da6ab2b39c32f3c7b4c4a31957e37 - name: Run docker full e2e if: steps.secrets.outputs.available == 'true' @@ -325,12 +338,12 @@ jobs: uses: goreleaser/goreleaser-action@f06c13b6b1a9625abc9e6e439d9c05a8f2190e94 # v7 with: distribution: goreleaser - version: "~> v2" + version: "v2.17.0" args: check - name: GoReleaser snapshot build uses: goreleaser/goreleaser-action@f06c13b6b1a9625abc9e6e439d9c05a8f2190e94 # v7 with: distribution: goreleaser - version: "~> v2" + version: "v2.17.0" args: release --snapshot --clean --skip=publish diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 81d78ee..007e19d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -5,16 +5,38 @@ on: tags: - "v*" -permissions: - contents: write +permissions: {} + +concurrency: + group: release-${{ github.ref }} + cancel-in-progress: false jobs: goreleaser: runs-on: [self-hosted, linux, x64, trusted] + timeout-minutes: 30 + environment: release + permissions: + contents: write steps: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: fetch-depth: 0 + persist-credentials: false + + - name: Validate release tag + env: + RELEASE_TAG: ${{ github.ref_name }} + run: | + set -euo pipefail + if [[ ! "$RELEASE_TAG" =~ ^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-[0-9A-Za-z]+([.-][0-9A-Za-z]+)*)?(\+[0-9A-Za-z]+([.-][0-9A-Za-z]+)*)?$ ]]; then + echo "::error::Release tag must be valid SemVer with a v prefix: $RELEASE_TAG" + exit 1 + fi + if ! git merge-base --is-ancestor "$GITHUB_SHA" refs/remotes/origin/main; then + echo "::error::Release commit must be reachable from origin/main" + exit 1 + fi - uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6 with: @@ -24,7 +46,7 @@ jobs: uses: goreleaser/goreleaser-action@f06c13b6b1a9625abc9e6e439d9c05a8f2190e94 # v7 with: distribution: goreleaser - version: "~> v2" + version: "v2.17.0" args: release --clean env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/runner-image.yml b/.github/workflows/runner-image.yml index 8cb1760..7e9a2ee 100644 --- a/.github/workflows/runner-image.yml +++ b/.github/workflows/runner-image.yml @@ -10,8 +10,6 @@ name: Runner Image # - push to main touching the action runtime -> republish. # - workflow_dispatch -> manual (re)publish at an explicit tag. # -# Fork PRs lack package-write on GITHUB_TOKEN: they build without pushing. -# # NOTE: pushing under the org namespace requires the org to allow Actions to # create packages (verified working for this repo). @@ -44,7 +42,7 @@ on: description: 'Also publish the latest tag' required: true type: boolean - default: true + default: false env: IMAGE: ghcr.io/${{ github.repository_owner }}/skill-up-runner @@ -56,16 +54,20 @@ concurrency: jobs: build: runs-on: [self-hosted, linux, x64, docker, trusted] + timeout-minutes: 30 + environment: release-image permissions: contents: read packages: write steps: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false - name: Resolve tags & push policy id: meta env: - # Fork PRs can't push packages; build-only for them. + # Keep the reusable workflow build-only if a future PR caller is added. SAME_REPO: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository }} PUBLISH_LATEST: ${{ github.event_name == 'push' || inputs.publish_latest }} REQUESTED_TAG: ${{ inputs.tag || 'v0.1.0' }} @@ -106,6 +108,7 @@ jobs: password: ${{ secrets.GITHUB_TOKEN }} - name: Build and push + id: build uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 with: context: ./action @@ -117,5 +120,23 @@ jobs: platforms: linux/amd64 push: ${{ steps.meta.outputs.push == 'true' }} tags: ${{ steps.meta.outputs.tags }} + build-args: | + VCS_REF=${{ github.sha }} + provenance: mode=max + sbom: true # Avoid the GitHub Actions Cache backend here: transient GHA cache # outages fail the whole image build after the registry push starts. + + - name: Record image digest + if: steps.meta.outputs.push == 'true' + env: + IMAGE_DIGEST: ${{ steps.build.outputs.digest }} + run: | + if [ -z "$IMAGE_DIGEST" ]; then + echo "::error::Build completed without an image digest" + exit 1 + fi + echo "### Published runner image" >> "$GITHUB_STEP_SUMMARY" + echo '```text' >> "$GITHUB_STEP_SUMMARY" + echo "${IMAGE}@${IMAGE_DIGEST}" >> "$GITHUB_STEP_SUMMARY" + echo '```' >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/skill-upper-self-eval.yml b/.github/workflows/skill-upper-self-eval.yml index 6b1c31b..0ece4bb 100644 --- a/.github/workflows/skill-upper-self-eval.yml +++ b/.github/workflows/skill-upper-self-eval.yml @@ -5,8 +5,8 @@ name: Skill Upper Self-Eval # green check here is the action's end-to-end integration test for every engine # it claims to support. # -# NOTE: the runner image bakes skill-up from the public GitHub Releases -# (Dockerfile: install.sh … latest), so an engine can only be dogfooded here +# NOTE: the runner image bakes a pinned skill-up version from GitHub Releases, +# so an engine can only be dogfooded here # once a skill-up RELEASE ships it. qwen_code is intentionally NOT in the matrix # yet — the released skill-up would reject `--engine qwen_code` as unsupported. # Add the row once a release includes the engine (the action image already @@ -50,6 +50,7 @@ jobs: name: Eval skill-upper (${{ matrix.engine }}) needs: image runs-on: [self-hosted, linux, x64, docker, trusted] + timeout-minutes: 60 strategy: fail-fast: false matrix: @@ -78,7 +79,7 @@ jobs: run: | tag="self-eval-${{ github.run_id }}" img="ghcr.io/${{ github.repository_owner }}/skill-up-runner:${tag}" - sed -i "s#image: docker://ghcr.io/[^[:space:]]*/skill-up-runner:.*#image: docker://${img}#" action.yml + sed -E -i "s#image: docker://ghcr.io/[^[:space:]]*/skill-up-runner[:@][^[:space:]]+#image: docker://${img}#" action.yml echo "dogfood will use ${img}" grep 'image: docker://' action.yml @@ -138,16 +139,6 @@ jobs: # resolves cases (evals/cases/*.yaml) relative to the skill root. skill-target: skills/skill-upper - - name: Restore report ownership - # The Docker container action runs as root. The action entrypoint fixes - # this on exit; keep a host-side fallback so artifact upload and the - # next checkout can read and remove generated reports on self-hosted - # runners. - if: always() && steps.secrets.outputs.available == 'true' - run: | - sudo chown -R "$(id -u):$(id -g)" "$GITHUB_WORKSPACE/skill-up-workspace" || true - chmod -R u+rwX,go+rX "$GITHUB_WORKSPACE/skill-up-workspace" || true - - name: Upload eval report if: always() && steps.secrets.outputs.available == 'true' uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 diff --git a/action.yml b/action.yml index c035557..350f161 100644 --- a/action.yml +++ b/action.yml @@ -43,7 +43,7 @@ inputs: skill-up-version: description: 'skill-up version. Ignored when the image already ships skill-up (the default).' required: false - default: 'latest' + default: '0.7.0' skill-up-command: description: 'The skill-up command itself; must start with `skill-up`.' required: false @@ -70,7 +70,8 @@ outputs: # NOTE: Docker container actions run on Linux runners only. runs: using: docker - image: docker://ghcr.io/alibaba/skill-up-runner:v0.1.0 + # v0.1.0; update the digest deliberately when publishing a verified image. + image: docker://ghcr.io/alibaba/skill-up-runner@sha256:f90aa1a320dadd14f354c12013dbbf78df262ece0e4f82683b8a70140a65bbcf args: - --engine - ${{ inputs.engine }} diff --git a/action/Dockerfile b/action/Dockerfile index af39377..4c95587 100644 --- a/action/Dockerfile +++ b/action/Dockerfile @@ -11,14 +11,23 @@ # node:22 — @qwen-code/qwen-code declares engines.node ">=22.0.0"; the older # node:20 base tripped an engine mismatch at `qwen --version`. 22 also suits # claude-code / codex and matches skill-up's own Node bootstrap default. -FROM node:22-bookworm-slim +FROM node:22-bookworm-slim@sha256:8607a9064d4a571140998ae9e52a3b3fcf9cff361d04642d5971e6cd76d39e27 # Pin the agent CLI versions. Bump these in a PR; the build workflow republishes # the image. Keep codex aligned with the codexDefaultVersion skill-up expects. -ARG CLAUDE_VERSION=latest +ARG CLAUDE_VERSION=2.1.217 ARG CODEX_VERSION=0.80.0 -ARG QWEN_CODE_VERSION=latest -ARG SKILL_UP_VERSION=latest +ARG QWEN_CODE_VERSION=0.20.1 +ARG QODER_VERSION=1.0.14 +ARG QODER_SHA256=4d83ec3d3a0948b73012cfdd6e2757be0a5da5c89a288425a5607fd42be1de7b +ARG SKILL_UP_VERSION=0.7.0 +ARG SKILL_UP_INSTALL_COMMIT=5ac7ce0467a164d07aacbbf7052bcffda68a446b +ARG SKILL_UP_INSTALL_SHA256=2b7fbea303dc8b6feb09db6f9cc7307fb9263ff6cc4b0c68cdfe9c2897d109ef +ARG VCS_REF=unknown + +LABEL org.opencontainers.image.source="https://github.com/alibaba/skill-up" \ + org.opencontainers.image.revision="${VCS_REF}" \ + org.opencontainers.image.version="${SKILL_UP_VERSION}" # python3 (for main.py) + curl/bash/git/ca-certificates/unzip (installers). RUN apt-get update \ @@ -38,22 +47,29 @@ RUN npm install -g \ && codex --version \ && qwen --version -# qodercli -> official installer (qoder.com/install), NOT npm: the npm -# @qoder-ai/qodercli package is a different release line (1.x) whose flags -# don't match skill-up's adapter (`--permission-mode=bypass_permissions -p`, -# built against the installer's 0.x line) — it no-ops silently. The installer -# drops the binary under $HOME/.qoder and symlinks $HOME/.local/bin; symlink -# it into /usr/local/bin because $HOME differs at Actions runtime -# (/github/home, not /root). -RUN curl -fsSL https://qoder.com/install | bash \ - && ln -sf /root/.local/bin/qodercli /usr/local/bin/qodercli \ +# qodercli: download the same release validated by extended CI and verify the +# vendor-published SHA-256 before extraction. Do not execute the mutable remote +# installer, which resolves its release from a live channel manifest. +RUN tmp_dir="$(mktemp -d)" \ + && curl -fsSL --retry 3 \ + "https://qoder-ide.oss-accelerate.aliyuncs.com/qodercli/releases/${QODER_VERSION}/qodercli-linux-x64.tar.gz" \ + -o "$tmp_dir/qodercli.tar.gz" \ + && echo "${QODER_SHA256} $tmp_dir/qodercli.tar.gz" | sha256sum -c - \ + && tar -xzf "$tmp_dir/qodercli.tar.gz" -C /usr/local/bin qodercli \ + && chmod 0755 /usr/local/bin/qodercli \ + && rm -rf "$tmp_dir" \ && qodercli --version -# skill-up — public GitHub Releases via the official installer. -RUN INSTALL_DIR=/usr/local/bin \ - SKILL_UP_VERSION="${SKILL_UP_VERSION}" \ - bash -c 'if [ "$SKILL_UP_VERSION" = "latest" ]; then unset SKILL_UP_VERSION; fi; \ - curl -fsSL https://raw.githubusercontent.com/alibaba/skill-up/main/install.sh | bash' \ +# skill-up: pin both the installer source commit and installer checksum. The +# installer then downloads the pinned release asset and verifies its published +# checksum before copying the binary. +RUN curl -fsSL --retry 3 \ + "https://raw.githubusercontent.com/alibaba/skill-up/${SKILL_UP_INSTALL_COMMIT}/install.sh" \ + -o /tmp/install-skill-up.sh \ + && echo "${SKILL_UP_INSTALL_SHA256} /tmp/install-skill-up.sh" | sha256sum -c - \ + && INSTALL_DIR=/usr/local/bin SKILL_UP_VERSION="${SKILL_UP_VERSION}" \ + bash /tmp/install-skill-up.sh \ + && rm -f /tmp/install-skill-up.sh \ && skill-up --version # Action code. diff --git a/action/entry.sh b/action/entry.sh index 714b0a6..a22ddae 100755 --- a/action/entry.sh +++ b/action/entry.sh @@ -15,7 +15,7 @@ for p in /usr/local/bin /usr/bin "$HOME/.local/bin"; do done export PATH -set -e +set -euo pipefail restore_workspace_permissions() { local workspace="${GITHUB_WORKSPACE:-}" @@ -28,16 +28,25 @@ restore_workspace_permissions() { # Hand generated reports back to the runner user so the next checkout can # clean the workspace on self-hosted runners. local owner - owner="$(stat -c '%u:%g' "$workspace" 2>/dev/null || true)" - if [ -n "$owner" ]; then - chown -R "$owner" "$report_dir" 2>/dev/null || true + if ! owner="$(stat -c '%u:%g' "$workspace")"; then + echo "[entry] failed to determine workspace ownership" >&2 + return 1 + fi + if ! chown -R "$owner" "$report_dir"; then + echo "[entry] failed to restore report ownership" >&2 + return 1 + fi + if ! chmod -R u+rwX,go+rX "$report_dir"; then + echo "[entry] failed to restore report permissions" >&2 + return 1 fi - chmod -R u+rwX,go+rX "$report_dir" 2>/dev/null || true } cleanup() { local status=$? - restore_workspace_permissions + if ! restore_workspace_permissions && [ "$status" -eq 0 ]; then + status=1 + fi exit "$status" } trap cleanup EXIT diff --git a/action/main.py b/action/main.py index 083766c..7d3e2c7 100644 --- a/action/main.py +++ b/action/main.py @@ -24,11 +24,14 @@ """ import argparse +import hashlib import os import shlex import shutil import subprocess import sys +import tempfile +import urllib.request # provider -> per-protocol (openai / anthropic) base_url. @@ -55,7 +58,13 @@ # endpoint (api.anthropic.com / api.openai.com) rather than an internal gateway. ENGINE_DEFAULT_BASE_URL = {} -SKILL_UP_INSTALL_URL = "https://raw.githubusercontent.com/alibaba/skill-up/main/install.sh" +SKILL_UP_INSTALL_URL = ( + "https://raw.githubusercontent.com/alibaba/skill-up/" + "5ac7ce0467a164d07aacbbf7052bcffda68a446b/install.sh" +) +SKILL_UP_INSTALL_SHA256 = ( + "2b7fbea303dc8b6feb09db6f9cc7307fb9263ff6cc4b0c68cdfe9c2897d109ef" +) def _provider_env_prefix(provider): @@ -309,7 +318,7 @@ def parse_inputs(argv=None): parser.add_argument("--base-url", default="") parser.add_argument("--open-sandbox-api-key", default="") parser.add_argument("--skill-target", default=".") - parser.add_argument("--skill-up-version", default="latest") + parser.add_argument("--skill-up-version", default="0.7.0") parser.add_argument("--skill-up-command", default="skill-up run") parser.add_argument("--parallelism", default="") parser.add_argument("--agent-install-command", default="") @@ -332,16 +341,30 @@ def install_skill_up(bin_dir, version): on github.com hosted runners. """ os.makedirs(bin_dir, exist_ok=True) - version_env = "" + install_env = {"INSTALL_DIR": bin_dir} if version and version != "latest": - version_env = "export SKILL_UP_VERSION=" + shlex.quote(version) - script = f""" -set -e -export INSTALL_DIR={shlex.quote(bin_dir)} -{version_env} -curl -fsSL --connect-timeout 10 --max-time 120 {shlex.quote(SKILL_UP_INSTALL_URL)} | bash -""" - _run(script) + install_env["SKILL_UP_VERSION"] = version + + with urllib.request.urlopen(SKILL_UP_INSTALL_URL, timeout=120) as response: + installer = response.read() + actual_sha256 = hashlib.sha256(installer).hexdigest() + if actual_sha256 != SKILL_UP_INSTALL_SHA256: + print( + "error: skill-up installer checksum mismatch " + f"(expected {SKILL_UP_INSTALL_SHA256}, got {actual_sha256})", + file=sys.stderr, + ) + sys.exit(1) + + installer_path = "" + try: + with tempfile.NamedTemporaryFile(delete=False) as installer_file: + installer_file.write(installer) + installer_path = installer_file.name + _run("bash " + shlex.quote(installer_path), env=install_env) + finally: + if installer_path: + os.unlink(installer_path) def _set_output(name, value): From faa8baf5329a5c8618368119b3fc01e87fa7522f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A9=BA=E5=A0=82?= Date: Wed, 22 Jul 2026 17:32:19 +0800 Subject: [PATCH 3/7] ci: complete workflow maintenance hardening --- .github/CODEOWNERS | 8 ++- .github/actionlint.yaml | 5 ++ .github/dependabot.yml | 29 +++++++++ .github/workflows/ci.yml | 28 ++++++--- .github/workflows/codeql.yml | 2 + .github/workflows/docs.yml | 30 ++++++--- .github/workflows/extended-ci.yml | 55 ++++++++++++----- .github/workflows/release.yml | 6 +- .github/workflows/runner-image.yml | 7 ++- .github/workflows/skill-upper-self-eval.yml | 6 +- docs/.vitepress/config.ts | 12 ++++ docs/guide/ci-maintenance.md | 67 +++++++++++++++++++++ docs/zh/guide/ci-maintenance.md | 67 +++++++++++++++++++++ 13 files changed, 284 insertions(+), 38 deletions(-) create mode 100644 .github/actionlint.yaml create mode 100644 docs/guide/ci-maintenance.md create mode 100644 docs/zh/guide/ci-maintenance.md diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 84b1982..3a93e74 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -9,10 +9,10 @@ # that have read access to the repository, otherwise the rule is ignored. # - Multiple owners can be listed, separated by spaces. # -# Recommended Branch Protection (Settings -> Branches -> Add rule): +# Recommended ruleset (Settings -> Rules -> Rulesets): # - Require a pull request before merging # - Require review from Code Owners -# - Require status checks to pass (select `build` and `lint` jobs from CI) +# - Require the stable checks documented in docs/guide/ci-maintenance.md # Global fallback: every file is owned by the maintainers by default. * @hittyt @libinfs @@ -22,6 +22,10 @@ # CI / release configuration. /.github/ @hittyt @libinfs +/.github/workflows/ @hittyt @libinfs +/.github/dependabot.yml @hittyt @libinfs +/action.yml @hittyt @libinfs +/action/ @hittyt @libinfs /Makefile @hittyt @libinfs /.goreleaser.yaml @hittyt @libinfs diff --git a/.github/actionlint.yaml b/.github/actionlint.yaml new file mode 100644 index 0000000..7aa61cf --- /dev/null +++ b/.github/actionlint.yaml @@ -0,0 +1,5 @@ +# Custom labels provisioned on the repository's self-hosted runners. +self-hosted-runner: + labels: + - docker + - trusted diff --git a/.github/dependabot.yml b/.github/dependabot.yml index a227392..72a9461 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -28,3 +28,32 @@ updates: - "ci" commit-message: prefix: "ci" + + # Documentation toolchain dependencies. + - package-ecosystem: "npm" + directory: "/" + schedule: + interval: "weekly" + day: "monday" + open-pull-requests-limit: 3 + groups: + documentation: + patterns: + - "*" + labels: + - "dependencies" + commit-message: + prefix: "chore" + + # Runner image base images and Dockerfile dependencies. + - package-ecosystem: "docker" + directory: "/action" + schedule: + interval: "weekly" + day: "monday" + open-pull-requests-limit: 2 + labels: + - "dependencies" + - "ci" + commit-message: + prefix: "ci" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d2e56de..9f7b85c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,8 +13,10 @@ on: - 'docs/**' - '*.md' - '.github/workflows/docs.yml' + # Required checks must also run for commits synthesized by Merge Queue. + merge_group: -# Keep the default gate small and cancel superseded work on both PRs and main. +# Keep the default gate small and cancel only superseded pull-request work. # Expensive engine, sandbox, Windows and release checks live in extended-ci.yml. concurrency: group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} @@ -31,11 +33,13 @@ jobs: runs-on: ubuntu-24.04 timeout-minutes: 10 steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - name: Checkout source + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false - - uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6 + - name: Set up Go + uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6 with: go-version: "1.25.x" cache: true @@ -51,6 +55,7 @@ jobs: - name: Report coverage run: | + set -euo pipefail pct=$(go tool cover -func=coverage.out | awk '/^total:/ {gsub("%","",$3); print $3}') if [ -z "$pct" ]; then echo "could not parse total coverage" >&2 @@ -63,11 +68,13 @@ jobs: runs-on: ubuntu-24.04 timeout-minutes: 15 steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - name: Checkout source + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false - - uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6 + - name: Set up Go + uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6 with: go-version: "1.25.x" cache: true @@ -91,17 +98,20 @@ jobs: runs-on: ubuntu-24.04 timeout-minutes: 10 steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - name: Checkout source + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false - - uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6 + - name: Set up Go + uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6 with: go-version: "1.25.x" cache: true - name: Check formatting run: | + set -euo pipefail files=$(gofmt -l .) if [ -n "$files" ]; then echo "Following files are not formatted:" @@ -109,7 +119,7 @@ jobs: exit 1 fi - - name: golangci-lint + - name: Run golangci-lint uses: golangci/golangci-lint-action@ba0d7d2ec06a0ea1cb5fa41b2e4a3ab91d21278a # v9 with: version: v2.11.4 @@ -117,5 +127,5 @@ jobs: - name: Install revive run: go install github.com/mgechev/revive@v1.10.0 - - name: revive + - name: Run revive run: revive -config revive.toml ./... diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 2ab7857..987e2e4 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -16,6 +16,7 @@ on: branches: [ "main" ] pull_request: branches: [ "main" ] + merge_group: schedule: - cron: '27 3 * * 6' @@ -93,6 +94,7 @@ jobs: if: matrix.build-mode == 'manual' shell: bash run: | + set -euo pipefail echo 'If you are using a "manual" build mode for one or more of the' \ 'languages you are analyzing, replace this with the commands to build' \ 'your code, for example:' diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 54e5cc6..0e24b3e 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -10,6 +10,13 @@ on: - 'package.json' - 'package-lock.json' - '.github/workflows/docs.yml' + pull_request: + branches: [main] + paths: + - 'docs/**' + - 'package.json' + - 'package-lock.json' + - '.github/workflows/docs.yml' workflow_dispatch: # Jobs opt into only the permissions they need. @@ -17,22 +24,25 @@ permissions: {} # Allow only one concurrent deployment, but do not cancel in-progress runs. concurrency: - group: pages - cancel-in-progress: false + group: docs-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} jobs: build: - runs-on: [self-hosted, linux, x64, trusted] + name: Build + # Documentation dependencies and build scripts are pull-request code. + runs-on: ubuntu-24.04 timeout-minutes: 15 permissions: contents: read steps: - - name: Checkout + - name: Checkout source uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: fetch-depth: 0 + persist-credentials: false - - name: Setup Node + - name: Set up Node.js uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 with: node-version: '20' @@ -44,17 +54,21 @@ jobs: - name: Build VitePress site run: npm run docs:build - - name: Setup Pages + - name: Configure GitHub Pages + if: github.event_name != 'pull_request' uses: actions/configure-pages@45bfe0192ca1faeb007ade9deae92b16b8254a0d # v6 - - name: Upload artifact + - name: Upload GitHub Pages artifact + if: github.event_name != 'pull_request' uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5 with: path: docs/.vitepress/dist deploy: + name: Deploy + if: github.event_name != 'pull_request' needs: build - runs-on: [self-hosted, linux, x64, trusted] + runs-on: ubuntu-24.04 timeout-minutes: 10 permissions: pages: write diff --git a/.github/workflows/extended-ci.yml b/.github/workflows/extended-ci.yml index 6464f23..ef3a13f 100644 --- a/.github/workflows/extended-ci.yml +++ b/.github/workflows/extended-ci.yml @@ -19,9 +19,11 @@ jobs: runs-on: windows-2025 timeout-minutes: 20 steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - name: Checkout source + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - - uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6 + - name: Set up Go + uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6 with: go-version: "1.25.x" cache: true @@ -46,26 +48,30 @@ jobs: runs-on: [self-hosted, linux, x64, trusted] timeout-minutes: 35 steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - name: Checkout source + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Check secret availability id: secrets env: DASHSCOPE_API_KEY: ${{ secrets.DASHSCOPE_API_KEY }} run: | + set -euo pipefail if [ -z "$DASHSCOPE_API_KEY" ]; then echo "::error::DASHSCOPE_API_KEY is required for full e2e." exit 1 fi echo "available=true" >> "$GITHUB_OUTPUT" - - uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6 + - name: Set up Go + uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6 if: steps.secrets.outputs.available == 'true' with: go-version: "1.25.x" cache: true - - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 + - name: Set up Node.js + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 if: steps.secrets.outputs.available == 'true' with: node-version: "22" @@ -73,18 +79,21 @@ jobs: - name: Install claude-code CLI if: steps.secrets.outputs.available == 'true' run: | + set -euo pipefail npm install -g --include=optional '@anthropic-ai/claude-code@2.1.217' claude --version - name: Install codex CLI if: steps.secrets.outputs.available == 'true' run: | + set -euo pipefail npm install -g --include=optional '@openai/codex@0.80.0' codex --version - name: Install qwen-code CLI if: steps.secrets.outputs.available == 'true' run: | + set -euo pipefail npm install -g '@qwen-code/qwen-code@0.20.1' qwen --version @@ -94,6 +103,7 @@ jobs: QODER_VERSION: "1.0.14" QODER_SHA256: "4d83ec3d3a0948b73012cfdd6e2757be0a5da5c89a288425a5607fd42be1de7b" run: | + set -euo pipefail TARBALL=$(mktemp) curl -fsSL "https://qoder-ide.oss-accelerate.aliyuncs.com/qodercli/releases/${QODER_VERSION}/qodercli-linux-x64.tar.gz" -o "$TARBALL" echo "$QODER_SHA256 $TARBALL" | sha256sum -c - @@ -105,8 +115,11 @@ jobs: - name: Verify qodercli on PATH if: steps.secrets.outputs.available == 'true' run: | + set -euo pipefail which qodercli - qodercli --version || true + if ! qodercli --version; then + echo "::warning::qodercli does not support a successful --version probe" + fi - name: Run e2e tests (none runtime, full mode) if: steps.secrets.outputs.available == 'true' @@ -139,20 +152,23 @@ jobs: runs-on: [self-hosted, linux, x64, docker, trusted] timeout-minutes: 35 steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - name: Checkout source + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Check secret availability id: secrets env: DASHSCOPE_API_KEY: ${{ secrets.DASHSCOPE_API_KEY }} run: | + set -euo pipefail if [ -z "$DASHSCOPE_API_KEY" ]; then echo "::error::DASHSCOPE_API_KEY is required for opensandbox e2e." exit 1 fi echo "available=true" >> "$GITHUB_OUTPUT" - - uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6 + - name: Set up Go + uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6 if: steps.secrets.outputs.available == 'true' with: go-version: "1.25.x" @@ -165,6 +181,7 @@ jobs: - name: Install OpenSandbox server if: steps.secrets.outputs.available == 'true' run: | + set -euo pipefail uv tool install 'opensandbox-server==0.1.13' echo "$HOME/.local/bin" >> "$GITHUB_PATH" @@ -174,6 +191,7 @@ jobs: OPENSANDBOX_IMAGE: node:22@sha256:5647be709086c696ff32edaaf1c70cd26d1da6ab2b39c32f3c7b4c4a31957e37 OPENSANDBOX_EXECD_IMAGE: opensandbox/execd:v1.0.16@sha256:af7b55c861926c1304371c4578007fbaa424538219154a6a49a5d636217d2a3a run: | + set -euo pipefail docker version api_key="ci-$(openssl rand -hex 16)" echo "::add-mask::$api_key" @@ -224,6 +242,7 @@ jobs: - name: Stop OpenSandbox server if: always() && steps.secrets.outputs.available == 'true' run: | + set -euo pipefail if [ -n "${OPENSANDBOX_SERVER_PID:-}" ]; then if kill "$OPENSANDBOX_SERVER_PID" 2>/dev/null; then echo "OpenSandbox server stopped" @@ -255,15 +274,18 @@ jobs: runs-on: [self-hosted, linux, x64, docker, trusted] timeout-minutes: 20 steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - name: Checkout source + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - - uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6 + - name: Set up Go + uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6 with: go-version: "1.25.x" cache: true - name: Verify docker is available run: | + set -euo pipefail docker version docker info @@ -278,20 +300,23 @@ jobs: runs-on: [self-hosted, linux, x64, docker, trusted] timeout-minutes: 25 steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - name: Checkout source + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Check secret availability id: secrets env: DASHSCOPE_API_KEY: ${{ secrets.DASHSCOPE_API_KEY }} run: | + set -euo pipefail if [ -z "$DASHSCOPE_API_KEY" ]; then echo "::error::DASHSCOPE_API_KEY is required for docker full e2e." exit 1 fi echo "available=true" >> "$GITHUB_OUTPUT" - - uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6 + - name: Set up Go + uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6 if: steps.secrets.outputs.available == 'true' with: go-version: "1.25.x" @@ -325,11 +350,13 @@ jobs: runs-on: [self-hosted, linux, x64, trusted] timeout-minutes: 15 steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - name: Checkout source + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: fetch-depth: 0 - - uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6 + - name: Set up Go + uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6 with: go-version: "1.25.x" cache: true diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 007e19d..c5d8f3d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -19,7 +19,8 @@ jobs: permissions: contents: write steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - name: Checkout source + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: fetch-depth: 0 persist-credentials: false @@ -38,7 +39,8 @@ jobs: exit 1 fi - - uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6 + - name: Set up Go + uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6 with: go-version: "1.25.x" diff --git a/.github/workflows/runner-image.yml b/.github/workflows/runner-image.yml index 7e9a2ee..9fdb751 100644 --- a/.github/workflows/runner-image.yml +++ b/.github/workflows/runner-image.yml @@ -60,7 +60,8 @@ jobs: contents: read packages: write steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - name: Checkout source + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false @@ -97,7 +98,8 @@ jobs: } >> "$GITHUB_OUTPUT" echo "push=${SAME_REPO}" >> "$GITHUB_OUTPUT" - - uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 - name: Log in to ghcr.io if: steps.meta.outputs.push == 'true' @@ -132,6 +134,7 @@ jobs: env: IMAGE_DIGEST: ${{ steps.build.outputs.digest }} run: | + set -euo pipefail if [ -z "$IMAGE_DIGEST" ]; then echo "::error::Build completed without an image digest" exit 1 diff --git a/.github/workflows/skill-upper-self-eval.yml b/.github/workflows/skill-upper-self-eval.yml index 0ece4bb..d0eee3c 100644 --- a/.github/workflows/skill-upper-self-eval.yml +++ b/.github/workflows/skill-upper-self-eval.yml @@ -70,13 +70,15 @@ jobs: provider: '' model: '' steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - name: Checkout source + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 # Point the local action at the image the `image` job just built for this # run. The tag is intentionally run-scoped so manual dogfood runs from # feature branches never clobber production runner image tags. - name: Pin action to this run's runner image run: | + set -euo pipefail tag="self-eval-${{ github.run_id }}" img="ghcr.io/${{ github.repository_owner }}/skill-up-runner:${tag}" sed -E -i "s#image: docker://ghcr.io/[^[:space:]]*/skill-up-runner[:@][^[:space:]]+#image: docker://${img}#" action.yml @@ -93,6 +95,7 @@ jobs: QODER_ACCESS_TOKEN: ${{ secrets.QODER_ACCESS_TOKEN }} DASHSCOPE_API_KEY: ${{ secrets.DASHSCOPE_API_KEY }} run: | + set -euo pipefail if [ -z "$DASHSCOPE_API_KEY" ]; then echo "::error::DASHSCOPE_API_KEY is required for self-eval (${{ matrix.engine }})." exit 1 @@ -168,6 +171,7 @@ jobs: env: MIN_PASS_PCT: '60' run: | + set -euo pipefail report=$(ls -1 "$GITHUB_WORKSPACE"/skill-up-workspace/iteration-*/report.json 2>/dev/null | tail -1) if [ -z "$report" ] || [ ! -f "$report" ]; then echo "::error::no report.json produced — the eval did not run to completion" diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index 5f77440..7de2fd0 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -68,6 +68,12 @@ export default defineConfig({ { text: 'Migrating from Anthropic', link: '/guide/migration' }, ], }, + { + text: 'Maintainers', + items: [ + { text: 'CI Maintenance', link: '/guide/ci-maintenance' }, + ], + }, ], }, editLink: { @@ -124,6 +130,12 @@ export default defineConfig({ { text: '从 Anthropic 格式迁移', link: '/zh/guide/migration' }, ], }, + { + text: '维护者', + items: [ + { text: 'CI 维护手册', link: '/zh/guide/ci-maintenance' }, + ], + }, ], }, editLink: { diff --git a/docs/guide/ci-maintenance.md b/docs/guide/ci-maintenance.md new file mode 100644 index 0000000..4a76c11 --- /dev/null +++ b/docs/guide/ci-maintenance.md @@ -0,0 +1,67 @@ +# CI Maintenance + +This runbook keeps repository settings aligned with the workflow files. Never put secret values, tokens, runner registration tokens, or credentials in this document. + +## Trust boundaries + +Pull-request code runs on GitHub-hosted runners. Persistent self-hosted runners are reserved for trusted `push`, `merge_group`, `workflow_dispatch`, and reusable-workflow executions. + +| Capability | Required labels | Intended use | +| --- | --- | --- | +| Trusted Linux | `self-hosted`, `linux`, `x64`, `trusted` | Trusted integration and model-backed tests | +| Trusted Linux with Docker | `self-hosted`, `linux`, `x64`, `docker`, `trusted` | Trusted container E2E tests | +| Untrusted PR validation | `ubuntu-24.04` or `windows-2025` | Builds, lint, smoke tests, docs, and CodeQL | + +Do not add `pull_request_target` to workflows that check out or execute pull-request code. Do not place a self-hosted label on PR jobs. Runner hosts are persistent: patch them regularly, remove unused software and credentials, restrict Docker access, and replace a runner after suspected compromise. + +## Stable checks and merge queue + +Configure rulesets using the displayed job names below. Job IDs such as `build` are implementation details and must not be entered as required-check names. + +| Workflow | Events | Required checks | Optional checks | +| --- | --- | --- | --- | +| CI | `push`, `pull_request`, `merge_group` | `Build & Test`, `E2E Smoke`, `Lint` | — | +| CodeQL | `push`, `pull_request`, `merge_group`, schedule | `Analyze (actions)`, `Analyze (go)`, `Analyze (python)` | — | +| Extended CI | `merge_group`, manual | `E2E (none runtime, Windows)`, `E2E (docker runtime)`, `GoReleaser Check` | `E2E (none runtime)`, `E2E (opensandbox runtime)`, `E2E (docker runtime, full LLM)` | +| Docs | docs-related `push` and `pull_request` | Do not make globally required because path filters can leave it absent | `Build` | + +Model-backed checks depend on external services, credentials, quotas, and non-deterministic output, so they remain optional. Promote one to required only after measuring its reliability and ensuring the merge queue can access its environment and secrets. + +Every workflow that supplies a required check must listen to `merge_group`; otherwise a merge queue can wait forever for a check that was never created. After renaming a job, update the repository ruleset only after the new check has completed once. + +## Secrets and environments + +Repository administrators own the following inventory. Record ownership and rotation dates in the organization's secret manager, not secret values here. + +| Name | Scope | Consumer | Rotation trigger | +| --- | --- | --- | --- | +| `DASHSCOPE_API_KEY` | Repository Actions secret | Extended model-backed E2E and self-evaluation | Provider policy, maintainer departure, or suspected exposure | +| `QODER_ACCESS_TOKEN` | Repository Actions secret | Qoder E2E | Provider policy, maintainer departure, or suspected exposure | + +The `release` environment gates GitHub Releases. The `release-image` environment gates GHCR publication. Both should require reviewers, prevent self-review where team size permits, and restrict deployment to their intended tags or branches. Workflows should receive secrets only at the job that needs them. + +## Runner image publication + +1. Review changes under `action/`, especially the Dockerfile and downloaded binaries. +2. Dispatch **Runner Image**, approve the `release-image` deployment, and wait for build and scan results. +3. Copy the published immutable `sha256:` digest from the workflow summary. +4. In a pull request, update the runner image reference in `action.yml` to that digest. +5. Run the composite-action smoke test and Extended CI before merging. + +Never silently move the image tag used by the action. The digest change must remain reviewable and reversible. + +## Release procedure + +1. Ensure the release commit is on `main` and CI is green. +2. Create a signed `v*` semantic-version tag covered by the tag ruleset. +3. Approve the `release` environment deployment after confirming the tag and changelog. +4. Verify the GitHub Release assets and checksums produced by GoReleaser. +5. If publication fails, fix forward and create a new version; do not rewrite a published tag. + +## Artifacts and incidents + +E2E artifacts can contain prompts, model responses, file paths, and generated workspaces. Keep retention short, never upload credentials, and inspect or redact artifacts before sharing them outside the maintainer group. + +For a suspected secret exposure, disable the affected workflow, rotate the credential at its provider, update the GitHub secret, inspect audit and workflow logs, and invalidate affected artifacts. For a suspected runner compromise, remove the runner from GitHub, rotate credentials reachable from the host, rebuild it from a clean image, and re-register it with a new token. + +Review this runbook whenever runner labels, workflow job names, required checks, environments, or secrets change. diff --git a/docs/zh/guide/ci-maintenance.md b/docs/zh/guide/ci-maintenance.md new file mode 100644 index 0000000..e66fdef --- /dev/null +++ b/docs/zh/guide/ci-maintenance.md @@ -0,0 +1,67 @@ +# CI 维护手册 + +本手册用于保持仓库设置与工作流文件一致。禁止在本文中记录 Secret 值、Token、Runner 注册 Token 或任何凭据。 + +## 信任边界 + +Pull Request 代码只在 GitHub 托管 Runner 上运行。持久化的自托管 Runner 仅用于可信的 `push`、`merge_group`、`workflow_dispatch` 和可复用工作流调用。 + +| 能力 | 必需标签 | 用途 | +| --- | --- | --- | +| 可信 Linux | `self-hosted`、`linux`、`x64`、`trusted` | 可信集成测试和模型测试 | +| 支持 Docker 的可信 Linux | `self-hosted`、`linux`、`x64`、`docker`、`trusted` | 可信容器 E2E 测试 | +| 不可信 PR 校验 | `ubuntu-24.04` 或 `windows-2025` | 构建、Lint、冒烟测试、文档和 CodeQL | + +不要给会检出或执行 PR 代码的工作流添加 `pull_request_target`,不要让 PR Job 使用自托管 Runner。Runner 主机是持久化环境,应定期打补丁、移除无用软件和凭据、限制 Docker 权限;怀疑被入侵后必须重建。 + +## 稳定检查与 Merge Queue + +Ruleset 必须使用下表的 Job 展示名称。`build` 等 Job ID 只是实现细节,不能当作 required check 名称。 + +| 工作流 | 事件 | 必需检查 | 可选检查 | +| --- | --- | --- | --- | +| CI | `push`、`pull_request`、`merge_group` | `Build & Test`、`E2E Smoke`、`Lint` | — | +| CodeQL | `push`、`pull_request`、`merge_group`、定时任务 | `Analyze (actions)`、`Analyze (go)`、`Analyze (python)` | — | +| Extended CI | `merge_group`、手动触发 | `E2E (none runtime, Windows)`、`E2E (docker runtime)`、`GoReleaser Check` | `E2E (none runtime)`、`E2E (opensandbox runtime)`、`E2E (docker runtime, full LLM)` | +| Docs | 文档相关的 `push` 和 `pull_request` | 不要设为全局必需;路径过滤会使非文档 PR 没有该检查 | `Build` | + +模型检查依赖外部服务、凭据、额度和非确定性输出,因此保持可选。只有在可靠性经过量化,并确认 Merge Queue 能访问所需环境和 Secret 后,才可升级为必需检查。 + +所有提供必需检查的工作流都必须监听 `merge_group`,否则 Merge Queue 会永久等待一个不会创建的检查。重命名 Job 后,应先让新检查成功运行一次,再修改 Ruleset。 + +## Secrets 与 Environments + +仓库管理员维护下列清单。负责人和轮换日期应记录在组织的密钥管理系统中,本文不记录 Secret 值。 + +| 名称 | 范围 | 使用方 | 轮换时机 | +| --- | --- | --- | --- | +| `DASHSCOPE_API_KEY` | 仓库 Actions Secret | Extended 模型 E2E、自评测 | 按供应商策略、维护者离职或疑似泄露时 | +| `QODER_ACCESS_TOKEN` | 仓库 Actions Secret | Qoder E2E | 按供应商策略、维护者离职或疑似泄露时 | + +`release` Environment 控制 GitHub Release,`release-image` Environment 控制 GHCR 发布。两者都应配置审核者;团队人数允许时开启禁止自审,并把部署来源限制到预期 tag 或分支。Secret 只传给确实需要它的 Job。 + +## Runner 镜像发布 + +1. 审查 `action/` 下的变更,重点检查 Dockerfile 和下载的二进制文件。 +2. 手动触发 **Runner Image**,批准 `release-image` 部署并等待构建与扫描完成。 +3. 从工作流摘要复制已发布的不可变 `sha256:` digest。 +4. 通过 Pull Request 把 `action.yml` 中的 Runner 镜像引用更新为该 digest。 +5. 合并前运行复合 Action 冒烟测试和 Extended CI。 + +禁止静默移动 Action 使用的镜像标签;digest 变更必须可审查、可回滚。 + +## Release 发布 + +1. 确认发布 commit 位于 `main` 且 CI 全部通过。 +2. 创建受 tag Ruleset 保护、已签名的 `v*` 语义化版本 tag。 +3. 核对 tag 和变更日志后,批准 `release` Environment 部署。 +4. 验证 GoReleaser 生成的 GitHub Release 资产与校验和。 +5. 发布失败时修复后发布新版本,不要改写已经发布的 tag。 + +## 产物与事件响应 + +E2E 产物可能包含提示词、模型回复、文件路径和生成的工作区。保留期应尽可能短,禁止上传凭据;向维护者范围外分享前必须检查并脱敏。 + +疑似 Secret 泄露时,应禁用相关工作流、在供应商侧轮换凭据、更新 GitHub Secret、审查审计日志与工作流日志并使相关产物失效。疑似 Runner 被入侵时,应从 GitHub 移除 Runner、轮换主机可访问的凭据、使用干净镜像重建,并用新 Token 重新注册。 + +Runner 标签、Job 名称、必需检查、Environment 或 Secret 发生变化时,都应同步复审本手册。 From 1f450ef80dad83df9948c1393be22c674956a764 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A9=BA=E5=A0=82?= Date: Thu, 23 Jul 2026 11:31:20 +0800 Subject: [PATCH 4/7] chore: Improve github actions --- .github/workflows/ci.yml | 9 ++++-- .github/workflows/codeql.yml | 3 ++ .github/workflows/extended-ci.yml | 29 +++++++++++++++-- .github/workflows/release.yml | 2 ++ .github/workflows/runner-image.yml | 8 +++-- .github/workflows/skill-upper-self-eval.yml | 23 +++++++++----- .github/workflows/workflow-security.yml | 35 +++++++++++++++++++++ docs/guide/ci-maintenance.md | 5 +++ docs/zh/guide/ci-maintenance.md | 5 +++ 9 files changed, 104 insertions(+), 15 deletions(-) create mode 100644 .github/workflows/workflow-security.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9f7b85c..f6edaa3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -22,8 +22,7 @@ concurrency: group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} cancel-in-progress: ${{ github.event_name == 'pull_request' }} -permissions: - contents: read +permissions: {} jobs: build: @@ -32,6 +31,8 @@ jobs: # runner instead of a persistent self-hosted machine. runs-on: ubuntu-24.04 timeout-minutes: 10 + permissions: + contents: read steps: - name: Checkout source uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 @@ -67,6 +68,8 @@ jobs: name: E2E Smoke runs-on: ubuntu-24.04 timeout-minutes: 15 + permissions: + contents: read steps: - name: Checkout source uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 @@ -97,6 +100,8 @@ jobs: name: Lint runs-on: ubuntu-24.04 timeout-minutes: 10 + permissions: + contents: read steps: - name: Checkout source uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 987e2e4..7448802 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -20,6 +20,8 @@ on: schedule: - cron: '27 3 * * 6' +permissions: {} + jobs: analyze: name: Analyze (${{ matrix.language }}) @@ -77,6 +79,7 @@ jobs: with: languages: ${{ matrix.language }} build-mode: ${{ matrix.build-mode }} + queries: security-extended # If you wish to specify custom queries, you can do so here or in a config file. # By default, queries listed here will override any specified in a config file. # Prefix the list here with "+" to use these queries and those in the config file. diff --git a/.github/workflows/extended-ci.yml b/.github/workflows/extended-ci.yml index ef3a13f..f114c35 100644 --- a/.github/workflows/extended-ci.yml +++ b/.github/workflows/extended-ci.yml @@ -8,8 +8,7 @@ concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true -permissions: - contents: read +permissions: {} jobs: e2e-windows: @@ -18,9 +17,13 @@ jobs: # explicitly versioned GitHub-hosted Windows image. runs-on: windows-2025 timeout-minutes: 20 + permissions: + contents: read steps: - name: Checkout source uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false - name: Set up Go uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6 @@ -47,9 +50,13 @@ jobs: name: E2E (none runtime) runs-on: [self-hosted, linux, x64, trusted] timeout-minutes: 35 + permissions: + contents: read steps: - name: Checkout source uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false - name: Check secret availability id: secrets @@ -151,9 +158,13 @@ jobs: name: E2E (opensandbox runtime) runs-on: [self-hosted, linux, x64, docker, trusted] timeout-minutes: 35 + permissions: + contents: read steps: - name: Checkout source uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false - name: Check secret availability id: secrets @@ -273,9 +284,13 @@ jobs: name: E2E (docker runtime) runs-on: [self-hosted, linux, x64, docker, trusted] timeout-minutes: 20 + permissions: + contents: read steps: - name: Checkout source uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false - name: Set up Go uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6 @@ -299,9 +314,13 @@ jobs: name: E2E (docker runtime, full LLM) runs-on: [self-hosted, linux, x64, docker, trusted] timeout-minutes: 25 + permissions: + contents: read steps: - name: Checkout source uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false - name: Check secret availability id: secrets @@ -349,17 +368,21 @@ jobs: name: GoReleaser Check runs-on: [self-hosted, linux, x64, trusted] timeout-minutes: 15 + permissions: + contents: read steps: - name: Checkout source uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: fetch-depth: 0 + persist-credentials: false - name: Set up Go uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6 with: go-version: "1.25.x" - cache: true + # This job builds snapshot release artifacts; avoid cache poisoning. + cache: false - name: GoReleaser check uses: goreleaser/goreleaser-action@f06c13b6b1a9625abc9e6e439d9c05a8f2190e94 # v7 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c5d8f3d..b8055f3 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -43,6 +43,8 @@ jobs: uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6 with: go-version: "1.25.x" + # Release artifacts must not consume caches writable by other jobs. + cache: false - name: Run GoReleaser uses: goreleaser/goreleaser-action@f06c13b6b1a9625abc9e6e439d9c05a8f2190e94 # v7 diff --git a/.github/workflows/runner-image.yml b/.github/workflows/runner-image.yml index 9fdb751..9ebd227 100644 --- a/.github/workflows/runner-image.yml +++ b/.github/workflows/runner-image.yml @@ -44,6 +44,8 @@ on: type: boolean default: false +permissions: {} + env: IMAGE: ghcr.io/${{ github.repository_owner }}/skill-up-runner @@ -72,6 +74,8 @@ jobs: SAME_REPO: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository }} PUBLISH_LATEST: ${{ github.event_name == 'push' || inputs.publish_latest }} REQUESTED_TAG: ${{ inputs.tag || 'v0.1.0' }} + EVENT_NAME: ${{ github.event_name }} + PR_NUMBER: ${{ github.event.pull_request.number }} run: | set -euo pipefail if [[ ! "$REQUESTED_TAG" =~ ^[A-Za-z0-9_][A-Za-z0-9_.-]{0,127}$ ]]; then @@ -80,12 +84,12 @@ jobs: fi { echo "tags<<__TAGS__" - if [ "${{ github.event_name }}" = "pull_request" ]; then + if [ "$EVENT_NAME" = "pull_request" ]; then # PR dogfood builds publish an isolated, PR-scoped tag — NEVER the # mutable release tags (v0.1.0 / latest), which released action # users consume via alibaba/skill-up@v0.1.0. An unmerged PR must # not be able to replace the production runner image. - echo "${IMAGE}:pr-${{ github.event.pull_request.number }}" + echo "${IMAGE}:pr-${PR_NUMBER}" else # push to main / manual dispatch = release; reusable callers can # opt into latest explicitly, but self-eval uses an isolated tag. diff --git a/.github/workflows/skill-upper-self-eval.yml b/.github/workflows/skill-upper-self-eval.yml index d0eee3c..4314a78 100644 --- a/.github/workflows/skill-upper-self-eval.yml +++ b/.github/workflows/skill-upper-self-eval.yml @@ -30,8 +30,7 @@ concurrency: # Least-privilege default for every job (the image job overrides with # packages: write for the ghcr push). -permissions: - contents: read +permissions: {} jobs: # Rebuild & republish the runner image from THIS commit first, so the dogfood @@ -51,6 +50,8 @@ jobs: needs: image runs-on: [self-hosted, linux, x64, docker, trusted] timeout-minutes: 60 + permissions: + contents: read strategy: fail-fast: false matrix: @@ -72,15 +73,19 @@ jobs: steps: - name: Checkout source uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false # Point the local action at the image the `image` job just built for this # run. The tag is intentionally run-scoped so manual dogfood runs from # feature branches never clobber production runner image tags. - name: Pin action to this run's runner image + env: + RUN_TAG: self-eval-${{ github.run_id }} + IMAGE_REPOSITORY: ghcr.io/${{ github.repository_owner }}/skill-up-runner run: | set -euo pipefail - tag="self-eval-${{ github.run_id }}" - img="ghcr.io/${{ github.repository_owner }}/skill-up-runner:${tag}" + img="${IMAGE_REPOSITORY}:${RUN_TAG}" sed -E -i "s#image: docker://ghcr.io/[^[:space:]]*/skill-up-runner[:@][^[:space:]]+#image: docker://${img}#" action.yml echo "dogfood will use ${img}" grep 'image: docker://' action.yml @@ -94,12 +99,13 @@ jobs: # indexing, which CodeQL flags as exposing the whole secrets context). QODER_ACCESS_TOKEN: ${{ secrets.QODER_ACCESS_TOKEN }} DASHSCOPE_API_KEY: ${{ secrets.DASHSCOPE_API_KEY }} + ENGINE: ${{ matrix.engine }} run: | set -euo pipefail if [ -z "$DASHSCOPE_API_KEY" ]; then - echo "::error::DASHSCOPE_API_KEY is required for self-eval (${{ matrix.engine }})." + echo "::error::DASHSCOPE_API_KEY is required for self-eval ($ENGINE)." exit 1 - elif [ "${{ matrix.engine }}" = "qodercli" ] && [ -z "$QODER_ACCESS_TOKEN" ]; then + elif [ "$ENGINE" = "qodercli" ] && [ -z "$QODER_ACCESS_TOKEN" ]; then echo "::error::QODER_ACCESS_TOKEN is required for qodercli self-eval." exit 1 fi @@ -170,6 +176,7 @@ jobs: if: matrix.engine != 'codex' && steps.secrets.outputs.available == 'true' env: MIN_PASS_PCT: '60' + ENGINE: ${{ matrix.engine }} run: | set -euo pipefail report=$(ls -1 "$GITHUB_WORKSPACE"/skill-up-workspace/iteration-*/report.json 2>/dev/null | tail -1) @@ -179,7 +186,7 @@ jobs: fi total=$(jq '.case_results | length' "$report") passed=$(jq '[.case_results[] | select(.status=="PASS")] | length' "$report") - echo "engine=${{ matrix.engine }}: ${passed}/${total} cases passed (threshold ${MIN_PASS_PCT}%)" + echo "engine=${ENGINE}: ${passed}/${total} cases passed (threshold ${MIN_PASS_PCT}%)" jq -r '.case_results[] | " \(.status)\t\(.case_id)"' "$report" if [ "${total:-0}" -eq 0 ]; then echo "::error::no cases in report"; exit 1; fi pct=$(( passed * 100 / total )) @@ -187,4 +194,4 @@ jobs: echo "::error::pass rate ${pct}% below threshold ${MIN_PASS_PCT}%" exit 1 fi - echo "::notice::${{ matrix.engine }} pass rate ${pct}% meets threshold" + echo "::notice::${ENGINE} pass rate ${pct}% meets threshold" diff --git a/.github/workflows/workflow-security.yml b/.github/workflows/workflow-security.yml new file mode 100644 index 0000000..c5a7a96 --- /dev/null +++ b/.github/workflows/workflow-security.yml @@ -0,0 +1,35 @@ +name: Workflow Security + +on: + push: + branches: [main] + paths: + - '.github/**' + - 'action.yml' + pull_request: + branches: [main] + paths: + - '.github/**' + - 'action.yml' + merge_group: + +permissions: {} + +jobs: + zizmor: + name: Zizmor + runs-on: ubuntu-24.04 + timeout-minutes: 10 + permissions: + contents: read + security-events: write + steps: + - name: Checkout source + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + + # Advanced Security mode uploads SARIF without blocking on the initial + # baseline. Promote Zizmor to a required check after findings are triaged. + - name: Analyze workflows with Zizmor + uses: zizmorcore/zizmor-action@5f14fd08f7cf1cb1609c1e344975f152c7ee938d # v0.5.6 diff --git a/docs/guide/ci-maintenance.md b/docs/guide/ci-maintenance.md index 4a76c11..b1c8f4a 100644 --- a/docs/guide/ci-maintenance.md +++ b/docs/guide/ci-maintenance.md @@ -24,11 +24,16 @@ Configure rulesets using the displayed job names below. Job IDs such as `build` | CodeQL | `push`, `pull_request`, `merge_group`, schedule | `Analyze (actions)`, `Analyze (go)`, `Analyze (python)` | — | | Extended CI | `merge_group`, manual | `E2E (none runtime, Windows)`, `E2E (docker runtime)`, `GoReleaser Check` | `E2E (none runtime)`, `E2E (opensandbox runtime)`, `E2E (docker runtime, full LLM)` | | Docs | docs-related `push` and `pull_request` | Do not make globally required because path filters can leave it absent | `Build` | +| Workflow Security | workflow-related `push`, `pull_request`, `merge_group` | Keep optional while the initial baseline is triaged | `Zizmor` | Model-backed checks depend on external services, credentials, quotas, and non-deterministic output, so they remain optional. Promote one to required only after measuring its reliability and ensuring the merge queue can access its environment and secrets. Every workflow that supplies a required check must listen to `merge_group`; otherwise a merge queue can wait forever for a check that was never created. After renaming a job, update the repository ruleset only after the new check has completed once. +Every workflow must set top-level `permissions: {}` and grant permissions per job. Every `actions/checkout` step must set `persist-credentials: false`. Repository Actions policy should enforce full-length commit SHA references and allow only the action repositories currently used by the workflows. + +Zizmor's three `adhoc-packages` findings in Extended CI are accepted low-severity exceptions: those exact-version global CLI installations are the test subjects, not application dependencies. Any High or Medium Zizmor finding must be fixed or explicitly reviewed before merge. + ## Secrets and environments Repository administrators own the following inventory. Record ownership and rotation dates in the organization's secret manager, not secret values here. diff --git a/docs/zh/guide/ci-maintenance.md b/docs/zh/guide/ci-maintenance.md index e66fdef..5ef7b08 100644 --- a/docs/zh/guide/ci-maintenance.md +++ b/docs/zh/guide/ci-maintenance.md @@ -24,11 +24,16 @@ Ruleset 必须使用下表的 Job 展示名称。`build` 等 Job ID 只是实现 | CodeQL | `push`、`pull_request`、`merge_group`、定时任务 | `Analyze (actions)`、`Analyze (go)`、`Analyze (python)` | — | | Extended CI | `merge_group`、手动触发 | `E2E (none runtime, Windows)`、`E2E (docker runtime)`、`GoReleaser Check` | `E2E (none runtime)`、`E2E (opensandbox runtime)`、`E2E (docker runtime, full LLM)` | | Docs | 文档相关的 `push` 和 `pull_request` | 不要设为全局必需;路径过滤会使非文档 PR 没有该检查 | `Build` | +| Workflow Security | 工作流相关的 `push`、`pull_request`、`merge_group` | 初始基线完成处置前保持可选 | `Zizmor` | 模型检查依赖外部服务、凭据、额度和非确定性输出,因此保持可选。只有在可靠性经过量化,并确认 Merge Queue 能访问所需环境和 Secret 后,才可升级为必需检查。 所有提供必需检查的工作流都必须监听 `merge_group`,否则 Merge Queue 会永久等待一个不会创建的检查。重命名 Job 后,应先让新检查成功运行一次,再修改 Ruleset。 +所有工作流必须在顶层设置 `permissions: {}`,并由各 Job 单独申请权限。所有 `actions/checkout` 步骤必须设置 `persist-credentials: false`。仓库 Actions 策略应强制完整 commit SHA,并只允许当前工作流实际使用的 Action 仓库。 + +Zizmor 在 Extended CI 中报告的三个 `adhoc-packages` 属于已接受的低风险例外:这些固定版本的全局 CLI 正是测试对象,不是应用依赖。任何 High 或 Medium 级别的 Zizmor 发现都必须在合并前修复或完成显式评审。 + ## Secrets 与 Environments 仓库管理员维护下列清单。负责人和轮换日期应记录在组织的密钥管理系统中,本文不记录 Secret 值。 From 9d5143fe8431b98ae8266a531b8ceaa00d2ebc83 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A9=BA=E5=A0=82?= Date: Thu, 23 Jul 2026 15:29:29 +0800 Subject: [PATCH 5/7] ci: improve workflow release guarantees --- .github/workflows/extended-ci.yml | 327 ++---------------- .github/workflows/model-e2e.yml | 355 ++++++++++++++++++++ .github/workflows/release.yml | 100 +++++- .github/workflows/runner-image.yml | 143 +++++--- .github/workflows/skill-upper-self-eval.yml | 15 +- docs/guide/ci-maintenance.md | 39 ++- docs/zh/guide/ci-maintenance.md | 39 ++- 7 files changed, 646 insertions(+), 372 deletions(-) create mode 100644 .github/workflows/model-e2e.yml diff --git a/.github/workflows/extended-ci.yml b/.github/workflows/extended-ci.yml index f114c35..7e517e9 100644 --- a/.github/workflows/extended-ci.yml +++ b/.github/workflows/extended-ci.yml @@ -1,5 +1,7 @@ name: Extended CI +# Deterministic, credential-free checks that are suitable for merge-queue +# gating. Live-model tests live in model-e2e.yml and run only on demand. on: merge_group: workflow_dispatch: @@ -39,247 +41,13 @@ jobs: - name: Upload e2e workspace artifacts if: always() && hashFiles('e2e-artifacts/**') != '' - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: e2e-windows-workspaces path: e2e-artifacts/ if-no-files-found: ignore retention-days: 14 - e2e: - name: E2E (none runtime) - runs-on: [self-hosted, linux, x64, trusted] - timeout-minutes: 35 - permissions: - contents: read - steps: - - name: Checkout source - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - with: - persist-credentials: false - - - name: Check secret availability - id: secrets - env: - DASHSCOPE_API_KEY: ${{ secrets.DASHSCOPE_API_KEY }} - run: | - set -euo pipefail - if [ -z "$DASHSCOPE_API_KEY" ]; then - echo "::error::DASHSCOPE_API_KEY is required for full e2e." - exit 1 - fi - echo "available=true" >> "$GITHUB_OUTPUT" - - - name: Set up Go - uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6 - if: steps.secrets.outputs.available == 'true' - with: - go-version: "1.25.x" - cache: true - - - name: Set up Node.js - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 - if: steps.secrets.outputs.available == 'true' - with: - node-version: "22" - - - name: Install claude-code CLI - if: steps.secrets.outputs.available == 'true' - run: | - set -euo pipefail - npm install -g --include=optional '@anthropic-ai/claude-code@2.1.217' - claude --version - - - name: Install codex CLI - if: steps.secrets.outputs.available == 'true' - run: | - set -euo pipefail - npm install -g --include=optional '@openai/codex@0.80.0' - codex --version - - - name: Install qwen-code CLI - if: steps.secrets.outputs.available == 'true' - run: | - set -euo pipefail - npm install -g '@qwen-code/qwen-code@0.20.1' - qwen --version - - - name: Install qodercli (pinned v1.0.14) - if: steps.secrets.outputs.available == 'true' - env: - QODER_VERSION: "1.0.14" - QODER_SHA256: "4d83ec3d3a0948b73012cfdd6e2757be0a5da5c89a288425a5607fd42be1de7b" - run: | - set -euo pipefail - TARBALL=$(mktemp) - curl -fsSL "https://qoder-ide.oss-accelerate.aliyuncs.com/qodercli/releases/${QODER_VERSION}/qodercli-linux-x64.tar.gz" -o "$TARBALL" - echo "$QODER_SHA256 $TARBALL" | sha256sum -c - - mkdir -p "$HOME/.qoder/bin" - tar -xzf "$TARBALL" -C "$HOME/.qoder/bin" - rm -f "$TARBALL" - echo "$HOME/.qoder/bin" >> "$GITHUB_PATH" - - - name: Verify qodercli on PATH - if: steps.secrets.outputs.available == 'true' - run: | - set -euo pipefail - which qodercli - if ! qodercli --version; then - echo "::warning::qodercli does not support a successful --version probe" - fi - - - name: Run e2e tests (none runtime, full mode) - if: steps.secrets.outputs.available == 'true' - run: go test -tags e2e -timeout 1800s -count=1 -v ./e2e - env: - SKILL_UP_FULL_E2E: "1" - SKILL_UP_E2E_ARTIFACT_DIR: ${{ github.workspace }}/e2e-artifacts - DASHSCOPE_API_KEY: ${{ secrets.DASHSCOPE_API_KEY }} - DASHSCOPE_BASE_URL: https://dashscope.aliyuncs.com/apps/anthropic - ANTHROPIC_API_KEY: ${{ secrets.DASHSCOPE_API_KEY }} - ANTHROPIC_BASE_URL: https://dashscope.aliyuncs.com/apps/anthropic - ANTHROPIC_MODEL: qwen3.6-plus - OPENAI_API_KEY: ${{ secrets.DASHSCOPE_API_KEY }} - OPENAI_BASE_URL: https://dashscope.aliyuncs.com/compatible-mode/v1 - OPENAI_MODEL: qwen3.6-plus - DASHSCOPE_MODEL: qwen3.6-plus - QODER_PERSONAL_ACCESS_TOKEN: ${{ secrets.QODER_ACCESS_TOKEN }} - - - name: Upload e2e workspace artifacts - if: always() && steps.secrets.outputs.available == 'true' && hashFiles('e2e-artifacts/**') != '' - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 - with: - name: e2e-workspaces - path: e2e-artifacts/ - if-no-files-found: ignore - retention-days: 14 - - e2e-opensandbox: - name: E2E (opensandbox runtime) - runs-on: [self-hosted, linux, x64, docker, trusted] - timeout-minutes: 35 - permissions: - contents: read - steps: - - name: Checkout source - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - with: - persist-credentials: false - - - name: Check secret availability - id: secrets - env: - DASHSCOPE_API_KEY: ${{ secrets.DASHSCOPE_API_KEY }} - run: | - set -euo pipefail - if [ -z "$DASHSCOPE_API_KEY" ]; then - echo "::error::DASHSCOPE_API_KEY is required for opensandbox e2e." - exit 1 - fi - echo "available=true" >> "$GITHUB_OUTPUT" - - - name: Set up Go - uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6 - if: steps.secrets.outputs.available == 'true' - with: - go-version: "1.25.x" - cache: true - - - name: Install uv - if: steps.secrets.outputs.available == 'true' - uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2 - - - name: Install OpenSandbox server - if: steps.secrets.outputs.available == 'true' - run: | - set -euo pipefail - uv tool install 'opensandbox-server==0.1.13' - echo "$HOME/.local/bin" >> "$GITHUB_PATH" - - - name: Start OpenSandbox server - if: steps.secrets.outputs.available == 'true' - env: - OPENSANDBOX_IMAGE: node:22@sha256:5647be709086c696ff32edaaf1c70cd26d1da6ab2b39c32f3c7b4c4a31957e37 - OPENSANDBOX_EXECD_IMAGE: opensandbox/execd:v1.0.16@sha256:af7b55c861926c1304371c4578007fbaa424538219154a6a49a5d636217d2a3a - run: | - set -euo pipefail - docker version - api_key="ci-$(openssl rand -hex 16)" - echo "::add-mask::$api_key" - config="$RUNNER_TEMP/sandbox.toml" - cat > "$config" < "$log" 2>&1 & - echo "OPENSANDBOX_SERVER_PID=$!" >> "$GITHUB_ENV" - for i in $(seq 1 60); do - if curl -fsS http://127.0.0.1:8080/health 2>/dev/null | grep -q healthy; then - echo "OpenSandbox server is healthy" - break - fi - if [ "$i" -eq 60 ]; then - echo "::error::OpenSandbox server did not become healthy within 120s" - cat "$log" - exit 1 - fi - sleep 2 - done - { - echo "OPENSANDBOX_API_KEY=$api_key" - echo "OPENSANDBOX_BASE_URL=http://127.0.0.1:8080" - echo "OPENSANDBOX_IMAGE=$OPENSANDBOX_IMAGE" - } >> "$GITHUB_ENV" - - - name: Run e2e tests (opensandbox runtime) - if: steps.secrets.outputs.available == 'true' - run: go test -tags e2e -timeout 1800s -count=1 -v -run TestAgent_Codex_OpenSandboxRuntime ./e2e - env: - OPENAI_API_KEY: ${{ secrets.DASHSCOPE_API_KEY }} - OPENAI_BASE_URL: https://dashscope.aliyuncs.com/compatible-mode/v1 - OPENAI_MODEL: qwen3.6-plus - SKILL_UP_E2E_ARTIFACT_DIR: ${{ github.workspace }}/e2e-opensandbox-artifacts - - - name: Stop OpenSandbox server - if: always() && steps.secrets.outputs.available == 'true' - run: | - set -euo pipefail - if [ -n "${OPENSANDBOX_SERVER_PID:-}" ]; then - if kill "$OPENSANDBOX_SERVER_PID" 2>/dev/null; then - echo "OpenSandbox server stopped" - else - echo "::notice::OpenSandbox server was already stopped" - fi - fi - - - name: Upload OpenSandbox server log - if: always() && steps.secrets.outputs.available == 'true' - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 - with: - name: opensandbox-server-log - path: ${{ runner.temp }}/opensandbox-server.log - if-no-files-found: ignore - retention-days: 14 - - - name: Upload opensandbox e2e workspace artifacts - if: always() && steps.secrets.outputs.available == 'true' && hashFiles('e2e-opensandbox-artifacts/**') != '' - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 - with: - name: e2e-opensandbox-workspaces - path: e2e-opensandbox-artifacts/ - if-no-files-found: ignore - retention-days: 14 - e2e-docker: name: E2E (docker runtime) runs-on: [self-hosted, linux, x64, docker, trusted] @@ -298,7 +66,7 @@ jobs: go-version: "1.25.x" cache: true - - name: Verify docker is available + - name: Verify Docker is available run: | set -euo pipefail docker version @@ -307,63 +75,9 @@ jobs: - name: Pre-pull base image run: docker pull alpine:3.20@sha256:d9e853e87e55526f6b2917df91a2115c36dd7c696a35be12163d44e6e2a4b6bc - - name: Run docker integration tests + - name: Run Docker integration tests run: go test -tags docker_integration -timeout 600s -count=1 -v ./internal/runtime/ - e2e-docker-full: - name: E2E (docker runtime, full LLM) - runs-on: [self-hosted, linux, x64, docker, trusted] - timeout-minutes: 25 - permissions: - contents: read - steps: - - name: Checkout source - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - with: - persist-credentials: false - - - name: Check secret availability - id: secrets - env: - DASHSCOPE_API_KEY: ${{ secrets.DASHSCOPE_API_KEY }} - run: | - set -euo pipefail - if [ -z "$DASHSCOPE_API_KEY" ]; then - echo "::error::DASHSCOPE_API_KEY is required for docker full e2e." - exit 1 - fi - echo "available=true" >> "$GITHUB_OUTPUT" - - - name: Set up Go - uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6 - if: steps.secrets.outputs.available == 'true' - with: - go-version: "1.25.x" - cache: true - - - name: Pre-pull Docker image - if: steps.secrets.outputs.available == 'true' - run: docker pull node:22@sha256:5647be709086c696ff32edaaf1c70cd26d1da6ab2b39c32f3c7b4c4a31957e37 - - - name: Run docker full e2e - if: steps.secrets.outputs.available == 'true' - run: go test -tags e2e -timeout 1800s -count=1 -v -run TestAgent_ClaudeCode_DockerRuntime ./e2e - env: - SKILL_UP_FULL_E2E: "1" - SKILL_UP_E2E_ARTIFACT_DIR: ${{ github.workspace }}/e2e-docker-full-artifacts - ANTHROPIC_API_KEY: ${{ secrets.DASHSCOPE_API_KEY }} - ANTHROPIC_BASE_URL: https://dashscope.aliyuncs.com/apps/anthropic - ANTHROPIC_MODEL: qwen3.6-plus - - - name: Upload docker full e2e artifacts - if: always() && steps.secrets.outputs.available == 'true' && hashFiles('e2e-docker-full-artifacts/**') != '' - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 - with: - name: e2e-docker-full-workspaces - path: e2e-docker-full-artifacts/ - if-no-files-found: ignore - retention-days: 14 - release-dryrun: name: GoReleaser Check runs-on: [self-hosted, linux, x64, trusted] @@ -397,3 +111,34 @@ jobs: distribution: goreleaser version: "v2.17.0" args: release --snapshot --clean --skip=publish + + summary: + name: Extended CI Summary + if: always() + needs: [e2e-windows, e2e-docker, release-dryrun] + runs-on: ubuntu-24.04 + timeout-minutes: 5 + permissions: {} + steps: + - name: Write job summary + env: + WINDOWS_RESULT: ${{ needs.e2e-windows.result }} + DOCKER_RESULT: ${{ needs.e2e-docker.result }} + RELEASE_RESULT: ${{ needs.release-dryrun.result }} + run: | + set -euo pipefail + { + echo "### Extended CI" + echo + echo "| Check | Result |" + echo "|---|---|" + echo "| Windows E2E | ${WINDOWS_RESULT} |" + echo "| Docker integration | ${DOCKER_RESULT} |" + echo "| GoReleaser check | ${RELEASE_RESULT} |" + } >> "$GITHUB_STEP_SUMMARY" + if [ "$WINDOWS_RESULT" != success ] || + [ "$DOCKER_RESULT" != success ] || + [ "$RELEASE_RESULT" != success ]; then + echo "::error::One or more deterministic extended checks failed" + exit 1 + fi diff --git a/.github/workflows/model-e2e.yml b/.github/workflows/model-e2e.yml new file mode 100644 index 0000000..ba51721 --- /dev/null +++ b/.github/workflows/model-e2e.yml @@ -0,0 +1,355 @@ +name: Model E2E + +# Live-model tests are intentionally manual: they consume external API quota, +# require repository secrets, and are non-deterministic. They are observability +# checks, not merge-queue gates. +on: + workflow_dispatch: + inputs: + suite: + description: 'Live-model suite to run' + required: true + type: choice + default: all + options: + - all + - none-runtime + - opensandbox + - docker + +concurrency: + group: model-e2e-${{ github.ref }} + cancel-in-progress: true + +permissions: {} + +jobs: + e2e: + name: E2E (none runtime, live models) + if: inputs.suite == 'all' || inputs.suite == 'none-runtime' + runs-on: [self-hosted, linux, x64, trusted] + timeout-minutes: 35 + permissions: + contents: read + steps: + - name: Checkout source + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + + - name: Check secret availability + id: secrets + env: + DASHSCOPE_API_KEY: ${{ secrets.DASHSCOPE_API_KEY }} + QODER_ACCESS_TOKEN: ${{ secrets.QODER_ACCESS_TOKEN }} + run: | + set -euo pipefail + if [ -z "$DASHSCOPE_API_KEY" ]; then + echo "::error::DASHSCOPE_API_KEY is required for full e2e." + exit 1 + fi + if [ -z "$QODER_ACCESS_TOKEN" ]; then + echo "::error::QODER_ACCESS_TOKEN is required for full e2e." + exit 1 + fi + echo "available=true" >> "$GITHUB_OUTPUT" + + - name: Set up Go + if: steps.secrets.outputs.available == 'true' + uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6 + with: + go-version: "1.25.x" + cache: true + + - name: Set up Node.js + if: steps.secrets.outputs.available == 'true' + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 + with: + node-version: "22" + + - name: Install claude-code CLI + if: steps.secrets.outputs.available == 'true' + run: | + set -euo pipefail + npm install -g --include=optional '@anthropic-ai/claude-code@2.1.217' + claude --version + + - name: Install codex CLI + if: steps.secrets.outputs.available == 'true' + run: | + set -euo pipefail + npm install -g --include=optional '@openai/codex@0.80.0' + codex --version + + - name: Install qwen-code CLI + if: steps.secrets.outputs.available == 'true' + run: | + set -euo pipefail + npm install -g '@qwen-code/qwen-code@0.20.1' + qwen --version + + - name: Install qodercli (pinned v1.0.14) + if: steps.secrets.outputs.available == 'true' + env: + QODER_VERSION: "1.0.14" + QODER_SHA256: "4d83ec3d3a0948b73012cfdd6e2757be0a5da5c89a288425a5607fd42be1de7b" + run: | + set -euo pipefail + tarball=$(mktemp) + curl -fsSL "https://qoder-ide.oss-accelerate.aliyuncs.com/qodercli/releases/${QODER_VERSION}/qodercli-linux-x64.tar.gz" -o "$tarball" + echo "$QODER_SHA256 $tarball" | sha256sum -c - + mkdir -p "$HOME/.qoder/bin" + tar -xzf "$tarball" -C "$HOME/.qoder/bin" + rm -f "$tarball" + echo "$HOME/.qoder/bin" >> "$GITHUB_PATH" + + - name: Verify qodercli on PATH + if: steps.secrets.outputs.available == 'true' + run: | + set -euo pipefail + command -v qodercli + if ! qodercli --version; then + echo "::warning::qodercli does not support a successful --version probe" + fi + + - name: Run e2e tests (none runtime, full mode) + if: steps.secrets.outputs.available == 'true' + run: go test -tags e2e -timeout 1800s -count=1 -v ./e2e + env: + SKILL_UP_FULL_E2E: "1" + SKILL_UP_E2E_ARTIFACT_DIR: ${{ github.workspace }}/e2e-artifacts + DASHSCOPE_API_KEY: ${{ secrets.DASHSCOPE_API_KEY }} + DASHSCOPE_BASE_URL: https://dashscope.aliyuncs.com/apps/anthropic + ANTHROPIC_API_KEY: ${{ secrets.DASHSCOPE_API_KEY }} + ANTHROPIC_BASE_URL: https://dashscope.aliyuncs.com/apps/anthropic + ANTHROPIC_MODEL: qwen3.6-plus + OPENAI_API_KEY: ${{ secrets.DASHSCOPE_API_KEY }} + OPENAI_BASE_URL: https://dashscope.aliyuncs.com/compatible-mode/v1 + OPENAI_MODEL: qwen3.6-plus + DASHSCOPE_MODEL: qwen3.6-plus + QODER_PERSONAL_ACCESS_TOKEN: ${{ secrets.QODER_ACCESS_TOKEN }} + + - name: Upload e2e workspace artifacts + if: always() && steps.secrets.outputs.available == 'true' && hashFiles('e2e-artifacts/**') != '' + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: model-e2e-none-workspaces + path: e2e-artifacts/ + if-no-files-found: ignore + retention-days: 14 + + e2e-opensandbox: + name: E2E (OpenSandbox, live model) + if: inputs.suite == 'all' || inputs.suite == 'opensandbox' + runs-on: [self-hosted, linux, x64, docker, trusted] + timeout-minutes: 35 + permissions: + contents: read + steps: + - name: Checkout source + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + + - name: Check secret availability + id: secrets + env: + DASHSCOPE_API_KEY: ${{ secrets.DASHSCOPE_API_KEY }} + run: | + set -euo pipefail + if [ -z "$DASHSCOPE_API_KEY" ]; then + echo "::error::DASHSCOPE_API_KEY is required for OpenSandbox e2e." + exit 1 + fi + echo "available=true" >> "$GITHUB_OUTPUT" + + - name: Set up Go + if: steps.secrets.outputs.available == 'true' + uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6 + with: + go-version: "1.25.x" + cache: true + + - name: Install uv + if: steps.secrets.outputs.available == 'true' + uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2 + + - name: Install OpenSandbox server + if: steps.secrets.outputs.available == 'true' + run: | + set -euo pipefail + uv tool install 'opensandbox-server==0.1.13' + echo "$HOME/.local/bin" >> "$GITHUB_PATH" + + - name: Start OpenSandbox server + if: steps.secrets.outputs.available == 'true' + env: + OPENSANDBOX_IMAGE: node:22@sha256:5647be709086c696ff32edaaf1c70cd26d1da6ab2b39c32f3c7b4c4a31957e37 + OPENSANDBOX_EXECD_IMAGE: opensandbox/execd:v1.0.16@sha256:af7b55c861926c1304371c4578007fbaa424538219154a6a49a5d636217d2a3a + run: | + set -euo pipefail + docker version + api_key="ci-$(openssl rand -hex 16)" + echo "::add-mask::$api_key" + config="$RUNNER_TEMP/sandbox.toml" + cat > "$config" < "$log" 2>&1 & + echo "OPENSANDBOX_SERVER_PID=$!" >> "$GITHUB_ENV" + for i in $(seq 1 60); do + if curl -fsS http://127.0.0.1:8080/health 2>/dev/null | grep -q healthy; then + echo "OpenSandbox server is healthy" + break + fi + if [ "$i" -eq 60 ]; then + echo "::error::OpenSandbox server did not become healthy within 120s" + cat "$log" + exit 1 + fi + sleep 2 + done + { + echo "OPENSANDBOX_API_KEY=$api_key" + echo "OPENSANDBOX_BASE_URL=http://127.0.0.1:8080" + echo "OPENSANDBOX_IMAGE=$OPENSANDBOX_IMAGE" + } >> "$GITHUB_ENV" + + - name: Run e2e tests (OpenSandbox runtime) + if: steps.secrets.outputs.available == 'true' + run: go test -tags e2e -timeout 1800s -count=1 -v -run TestAgent_Codex_OpenSandboxRuntime ./e2e + env: + OPENAI_API_KEY: ${{ secrets.DASHSCOPE_API_KEY }} + OPENAI_BASE_URL: https://dashscope.aliyuncs.com/compatible-mode/v1 + OPENAI_MODEL: qwen3.6-plus + SKILL_UP_E2E_ARTIFACT_DIR: ${{ github.workspace }}/e2e-opensandbox-artifacts + + - name: Stop OpenSandbox server + if: always() && steps.secrets.outputs.available == 'true' + run: | + set -euo pipefail + if [ -n "${OPENSANDBOX_SERVER_PID:-}" ]; then + if kill "$OPENSANDBOX_SERVER_PID" 2>/dev/null; then + echo "OpenSandbox server stopped" + else + echo "::notice::OpenSandbox server was already stopped" + fi + fi + + - name: Upload OpenSandbox server log + if: always() && steps.secrets.outputs.available == 'true' + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: model-e2e-opensandbox-server-log + path: ${{ runner.temp }}/opensandbox-server.log + if-no-files-found: ignore + retention-days: 14 + + - name: Upload OpenSandbox workspace artifacts + if: always() && steps.secrets.outputs.available == 'true' && hashFiles('e2e-opensandbox-artifacts/**') != '' + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: model-e2e-opensandbox-workspaces + path: e2e-opensandbox-artifacts/ + if-no-files-found: ignore + retention-days: 14 + + e2e-docker: + name: E2E (Docker runtime, live model) + if: inputs.suite == 'all' || inputs.suite == 'docker' + runs-on: [self-hosted, linux, x64, docker, trusted] + timeout-minutes: 25 + permissions: + contents: read + steps: + - name: Checkout source + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + + - name: Check secret availability + id: secrets + env: + DASHSCOPE_API_KEY: ${{ secrets.DASHSCOPE_API_KEY }} + run: | + set -euo pipefail + if [ -z "$DASHSCOPE_API_KEY" ]; then + echo "::error::DASHSCOPE_API_KEY is required for Docker e2e." + exit 1 + fi + echo "available=true" >> "$GITHUB_OUTPUT" + + - name: Set up Go + if: steps.secrets.outputs.available == 'true' + uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6 + with: + go-version: "1.25.x" + cache: true + + - name: Pre-pull Docker image + if: steps.secrets.outputs.available == 'true' + run: docker pull node:22@sha256:5647be709086c696ff32edaaf1c70cd26d1da6ab2b39c32f3c7b4c4a31957e37 + + - name: Run Docker full e2e + if: steps.secrets.outputs.available == 'true' + run: go test -tags e2e -timeout 1800s -count=1 -v -run TestAgent_ClaudeCode_DockerRuntime ./e2e + env: + SKILL_UP_FULL_E2E: "1" + SKILL_UP_E2E_ARTIFACT_DIR: ${{ github.workspace }}/e2e-docker-full-artifacts + ANTHROPIC_API_KEY: ${{ secrets.DASHSCOPE_API_KEY }} + ANTHROPIC_BASE_URL: https://dashscope.aliyuncs.com/apps/anthropic + ANTHROPIC_MODEL: qwen3.6-plus + + - name: Upload Docker e2e workspace artifacts + if: always() && steps.secrets.outputs.available == 'true' && hashFiles('e2e-docker-full-artifacts/**') != '' + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: model-e2e-docker-workspaces + path: e2e-docker-full-artifacts/ + if-no-files-found: ignore + retention-days: 14 + + summary: + name: Model E2E Summary + if: always() + needs: [e2e, e2e-opensandbox, e2e-docker] + runs-on: ubuntu-24.04 + timeout-minutes: 5 + permissions: {} + steps: + - name: Write job summary + env: + NONE_RESULT: ${{ needs.e2e.result }} + OPENSANDBOX_RESULT: ${{ needs.e2e-opensandbox.result }} + DOCKER_RESULT: ${{ needs.e2e-docker.result }} + SELECTED_SUITE: ${{ inputs.suite }} + run: | + set -euo pipefail + { + echo "### Model E2E" + echo + echo "- Selected suite: \`${SELECTED_SUITE}\`" + echo + echo "| Runtime | Result |" + echo "|---|---|" + echo "| none | ${NONE_RESULT} |" + echo "| OpenSandbox | ${OPENSANDBOX_RESULT} |" + echo "| Docker | ${DOCKER_RESULT} |" + } >> "$GITHUB_STEP_SUMMARY" + for result in "$NONE_RESULT" "$OPENSANDBOX_RESULT" "$DOCKER_RESULT"; do + if [ "$result" != success ] && [ "$result" != skipped ]; then + echo "::error::One or more selected live-model suites failed" + exit 1 + fi + done diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b8055f3..69a6d64 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -8,16 +8,18 @@ on: permissions: {} concurrency: - group: release-${{ github.ref }} + # Release tags share one publication lane. Different tags must not mutate + # GitHub Releases concurrently. + group: release cancel-in-progress: false jobs: - goreleaser: + build: + name: Build release artifacts runs-on: [self-hosted, linux, x64, trusted] timeout-minutes: 30 - environment: release permissions: - contents: write + contents: read steps: - name: Checkout source uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 @@ -46,11 +48,97 @@ jobs: # Release artifacts must not consume caches writable by other jobs. cache: false - - name: Run GoReleaser + - name: Build release artifacts with GoReleaser uses: goreleaser/goreleaser-action@f06c13b6b1a9625abc9e6e439d9c05a8f2190e94 # v7 with: distribution: goreleaser version: "v2.17.0" - args: release --clean + # Build once without publishing. The protected job below publishes + # these exact files after approval; it never recompiles them. + args: release --clean --skip=publish env: + # The changelog provider reads repository history through the API. + # This token is read-only in the build job and cannot publish. GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Upload release artifacts for approval + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: release-dist-${{ github.run_id }}-${{ github.run_attempt }} + path: | + dist/*.tar.gz + dist/*.zip + dist/*_checksums.txt + dist/CHANGELOG.md + if-no-files-found: error + retention-days: 1 + + - name: Summarize release candidate + env: + RELEASE_TAG: ${{ github.ref_name }} + run: | + set -euo pipefail + { + echo "### Release candidate built" + echo + echo "- Tag: \`${RELEASE_TAG}\`" + echo "- Commit: \`${GITHUB_SHA}\`" + echo "- Artifact: \`release-dist-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}\`" + echo + echo "The protected publish job will release these exact files." + } >> "$GITHUB_STEP_SUMMARY" + + publish: + name: Approve and publish release + needs: build + runs-on: ubuntu-24.04 + timeout-minutes: 15 + environment: release + permissions: + contents: write + attestations: write + id-token: write + steps: + - name: Download approved release candidate + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + with: + name: release-dist-${{ github.run_id }}-${{ github.run_attempt }} + path: release-dist + + - name: Attest release artifacts + uses: actions/attest@f7c74d28b9d84cb8768d0b8ca14a4bac6ef463e6 # v4 + with: + subject-path: | + release-dist/*.tar.gz + release-dist/*.zip + release-dist/*_checksums.txt + + - name: Publish GitHub Release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + RELEASE_TAG: ${{ github.ref_name }} + run: | + set -euo pipefail + assets=(release-dist/*.tar.gz release-dist/*.zip release-dist/*_checksums.txt) + notes=(--generate-notes) + if [ -s release-dist/CHANGELOG.md ]; then + notes=(--notes-file release-dist/CHANGELOG.md) + fi + gh release create "$RELEASE_TAG" "${assets[@]}" \ + --repo "$GITHUB_REPOSITORY" \ + --title "$RELEASE_TAG" \ + --verify-tag \ + "${notes[@]}" + + - name: Summarize published release + env: + RELEASE_TAG: ${{ github.ref_name }} + run: | + set -euo pipefail + { + echo "### Release published" + echo + echo "- Tag: \`${RELEASE_TAG}\`" + echo "- Commit: \`${GITHUB_SHA}\`" + echo "- Provenance: GitHub artifact attestation generated" + } >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/runner-image.yml b/.github/workflows/runner-image.yml index 9ebd227..01c70d5 100644 --- a/.github/workflows/runner-image.yml +++ b/.github/workflows/runner-image.yml @@ -26,6 +26,10 @@ on: required: false type: boolean default: false + outputs: + digest: + description: 'Immutable digest of the image built by this run' + value: ${{ jobs.publish.outputs.digest }} push: branches: [main] paths: @@ -50,14 +54,19 @@ env: IMAGE: ghcr.io/${{ github.repository_owner }}/skill-up-runner concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true + # Publishing mutable container tags is a global critical section. Queue a + # newer run rather than cancelling a run after it has started publishing. + group: runner-image-publish + cancel-in-progress: false jobs: build: + name: Build immutable image runs-on: [self-hosted, linux, x64, docker, trusted] timeout-minutes: 30 - environment: release-image + outputs: + digest: ${{ steps.build.outputs.digest }} + source: ${{ steps.meta.outputs.source }} permissions: contents: read packages: write @@ -67,46 +76,17 @@ jobs: with: persist-credentials: false - - name: Resolve tags & push policy + - name: Resolve immutable build tag id: meta - env: - # Keep the reusable workflow build-only if a future PR caller is added. - SAME_REPO: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository }} - PUBLISH_LATEST: ${{ github.event_name == 'push' || inputs.publish_latest }} - REQUESTED_TAG: ${{ inputs.tag || 'v0.1.0' }} - EVENT_NAME: ${{ github.event_name }} - PR_NUMBER: ${{ github.event.pull_request.number }} run: | set -euo pipefail - if [[ ! "$REQUESTED_TAG" =~ ^[A-Za-z0-9_][A-Za-z0-9_.-]{0,127}$ ]]; then - echo "::error::Invalid image tag" - exit 1 - fi - { - echo "tags<<__TAGS__" - if [ "$EVENT_NAME" = "pull_request" ]; then - # PR dogfood builds publish an isolated, PR-scoped tag — NEVER the - # mutable release tags (v0.1.0 / latest), which released action - # users consume via alibaba/skill-up@v0.1.0. An unmerged PR must - # not be able to replace the production runner image. - echo "${IMAGE}:pr-${PR_NUMBER}" - else - # push to main / manual dispatch = release; reusable callers can - # opt into latest explicitly, but self-eval uses an isolated tag. - echo "${IMAGE}:${REQUESTED_TAG}" - if [ "$PUBLISH_LATEST" = "true" ]; then - echo "${IMAGE}:latest" - fi - fi - echo "__TAGS__" - } >> "$GITHUB_OUTPUT" - echo "push=${SAME_REPO}" >> "$GITHUB_OUTPUT" + source="${IMAGE}:build-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" + echo "source=$source" >> "$GITHUB_OUTPUT" - name: Set up Docker Buildx uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 - name: Log in to ghcr.io - if: steps.meta.outputs.push == 'true' uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0 with: registry: ghcr.io @@ -124,8 +104,8 @@ jobs: # --version self-check exits 1). Revisit with native arm64 runners # if self-hosted arm consumers show up. platforms: linux/amd64 - push: ${{ steps.meta.outputs.push == 'true' }} - tags: ${{ steps.meta.outputs.tags }} + push: true + tags: ${{ steps.meta.outputs.source }} build-args: | VCS_REF=${{ github.sha }} provenance: mode=max @@ -133,17 +113,94 @@ jobs: # Avoid the GitHub Actions Cache backend here: transient GHA cache # outages fail the whole image build after the registry push starts. - - name: Record image digest - if: steps.meta.outputs.push == 'true' + - name: Record immutable image digest env: IMAGE_DIGEST: ${{ steps.build.outputs.digest }} + IMAGE_SOURCE: ${{ steps.meta.outputs.source }} run: | set -euo pipefail if [ -z "$IMAGE_DIGEST" ]; then echo "::error::Build completed without an image digest" exit 1 fi - echo "### Published runner image" >> "$GITHUB_STEP_SUMMARY" - echo '```text' >> "$GITHUB_STEP_SUMMARY" - echo "${IMAGE}@${IMAGE_DIGEST}" >> "$GITHUB_STEP_SUMMARY" - echo '```' >> "$GITHUB_STEP_SUMMARY" + { + echo "### Immutable runner image built" + echo + echo "- Staging reference: \`${IMAGE_SOURCE}\`" + echo "- Digest: \`${IMAGE_DIGEST}\`" + echo + echo "The protected publish job will promote this digest without rebuilding." + } >> "$GITHUB_STEP_SUMMARY" + + publish: + name: Approve and publish image tags + needs: build + runs-on: [self-hosted, linux, x64, docker, trusted] + timeout-minutes: 15 + environment: release-image + outputs: + digest: ${{ needs.build.outputs.digest }} + permissions: + packages: write + steps: + - name: Resolve publication tags + id: meta + env: + PUBLISH_LATEST: ${{ github.event_name == 'push' || inputs.publish_latest }} + REQUESTED_TAG: ${{ inputs.tag || 'v0.1.0' }} + run: | + set -euo pipefail + if [[ ! "$REQUESTED_TAG" =~ ^[A-Za-z0-9_][A-Za-z0-9_.-]{0,127}$ ]]; then + echo "::error::Invalid image tag" + exit 1 + fi + { + echo "tags<<__TAGS__" + echo "${IMAGE}:${REQUESTED_TAG}" + if [ "$PUBLISH_LATEST" = "true" ]; then + echo "${IMAGE}:latest" + fi + echo "__TAGS__" + } >> "$GITHUB_OUTPUT" + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 + + - name: Log in to ghcr.io + uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Publish approved tags for immutable digest + env: + IMAGE_DIGEST: ${{ needs.build.outputs.digest }} + IMAGE_TAGS: ${{ steps.meta.outputs.tags }} + run: | + set -euo pipefail + if [ -z "$IMAGE_DIGEST" ]; then + echo "::error::Build job did not return an image digest" + exit 1 + fi + args=() + while IFS= read -r tag; do + [ -n "$tag" ] && args+=(--tag "$tag") + done <<< "$IMAGE_TAGS" + docker buildx imagetools create "${args[@]}" "${IMAGE}@${IMAGE_DIGEST}" + + - name: Summarize published runner image + env: + IMAGE_DIGEST: ${{ needs.build.outputs.digest }} + IMAGE_TAGS: ${{ steps.meta.outputs.tags }} + run: | + set -euo pipefail + { + echo "### Runner image published" + echo + echo "- Digest: \`${IMAGE_DIGEST}\`" + echo "- Tags:" + while IFS= read -r tag; do + [ -n "$tag" ] && echo " - \`${tag}\`" + done <<< "$IMAGE_TAGS" + } >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/skill-upper-self-eval.yml b/.github/workflows/skill-upper-self-eval.yml index 4314a78..26b76f3 100644 --- a/.github/workflows/skill-upper-self-eval.yml +++ b/.github/workflows/skill-upper-self-eval.yml @@ -76,16 +76,19 @@ jobs: with: persist-credentials: false - # Point the local action at the image the `image` job just built for this - # run. The tag is intentionally run-scoped so manual dogfood runs from - # feature branches never clobber production runner image tags. - - name: Pin action to this run's runner image + # Consume the immutable digest returned by the reusable image workflow. + # A mutable tag can no longer change the image between build and eval. + - name: Pin action to this run's runner image digest env: - RUN_TAG: self-eval-${{ github.run_id }} + IMAGE_DIGEST: ${{ needs.image.outputs.digest }} IMAGE_REPOSITORY: ghcr.io/${{ github.repository_owner }}/skill-up-runner run: | set -euo pipefail - img="${IMAGE_REPOSITORY}:${RUN_TAG}" + if [[ ! "$IMAGE_DIGEST" =~ ^sha256:[0-9a-f]{64}$ ]]; then + echo "::error::Reusable image workflow returned an invalid digest" + exit 1 + fi + img="${IMAGE_REPOSITORY}@${IMAGE_DIGEST}" sed -E -i "s#image: docker://ghcr.io/[^[:space:]]*/skill-up-runner[:@][^[:space:]]+#image: docker://${img}#" action.yml echo "dogfood will use ${img}" grep 'image: docker://' action.yml diff --git a/docs/guide/ci-maintenance.md b/docs/guide/ci-maintenance.md index b1c8f4a..8b67cd5 100644 --- a/docs/guide/ci-maintenance.md +++ b/docs/guide/ci-maintenance.md @@ -22,17 +22,20 @@ Configure rulesets using the displayed job names below. Job IDs such as `build` | --- | --- | --- | --- | | CI | `push`, `pull_request`, `merge_group` | `Build & Test`, `E2E Smoke`, `Lint` | — | | CodeQL | `push`, `pull_request`, `merge_group`, schedule | `Analyze (actions)`, `Analyze (go)`, `Analyze (python)` | — | -| Extended CI | `merge_group`, manual | `E2E (none runtime, Windows)`, `E2E (docker runtime)`, `GoReleaser Check` | `E2E (none runtime)`, `E2E (opensandbox runtime)`, `E2E (docker runtime, full LLM)` | +| Extended CI | `merge_group`, manual | `Extended CI Summary` | Its component jobs are aggregated by the summary | +| Model E2E | manual | Do not make required | `E2E (none runtime, live models)`, `E2E (OpenSandbox, live model)`, `E2E (Docker runtime, live model)` | | Docs | docs-related `push` and `pull_request` | Do not make globally required because path filters can leave it absent | `Build` | | Workflow Security | workflow-related `push`, `pull_request`, `merge_group` | Keep optional while the initial baseline is triaged | `Zizmor` | -Model-backed checks depend on external services, credentials, quotas, and non-deterministic output, so they remain optional. Promote one to required only after measuring its reliability and ensuring the merge queue can access its environment and secrets. +Model-backed checks depend on external services, credentials, quotas, and non-deterministic output, so `Model E2E` is manual and optional. Its dispatch input can run all suites or one runtime. Promote one to required only after measuring its reliability and ensuring the merge queue can access its environment and secrets. Every workflow that supplies a required check must listen to `merge_group`; otherwise a merge queue can wait forever for a check that was never created. After renaming a job, update the repository ruleset only after the new check has completed once. Every workflow must set top-level `permissions: {}` and grant permissions per job. Every `actions/checkout` step must set `persist-credentials: false`. Repository Actions policy should enforce full-length commit SHA references and allow only the action repositories currently used by the workflows. -Zizmor's three `adhoc-packages` findings in Extended CI are accepted low-severity exceptions: those exact-version global CLI installations are the test subjects, not application dependencies. Any High or Medium Zizmor finding must be fixed or explicitly reviewed before merge. +The release workflow also requires `actions/download-artifact` and `actions/attest`. Add both official repositories to a restricted Actions allowlist before the first tag release; keeping their workflow references pinned to full commit SHAs is still mandatory. + +Zizmor's three `adhoc-packages` findings in Model E2E are accepted low-severity exceptions: those exact-version global CLI installations are the test subjects, not application dependencies. Any High or Medium Zizmor finding must be fixed or explicitly reviewed before merge. ## Secrets and environments @@ -40,28 +43,38 @@ Repository administrators own the following inventory. Record ownership and rota | Name | Scope | Consumer | Rotation trigger | | --- | --- | --- | --- | -| `DASHSCOPE_API_KEY` | Repository Actions secret | Extended model-backed E2E and self-evaluation | Provider policy, maintainer departure, or suspected exposure | +| `DASHSCOPE_API_KEY` | Repository Actions secret | Model E2E and self-evaluation | Provider policy, maintainer departure, or suspected exposure | | `QODER_ACCESS_TOKEN` | Repository Actions secret | Qoder E2E | Provider policy, maintainer departure, or suspected exposure | -The `release` environment gates GitHub Releases. The `release-image` environment gates GHCR publication. Both should require reviewers, prevent self-review where team size permits, and restrict deployment to their intended tags or branches. Workflows should receive secrets only at the job that needs them. +The `release` environment gates the job that publishes already-built GoReleaser artifacts. The `release-image` environment gates promotion of an already-built image digest to user-facing GHCR tags. Compilation happens before approval with read-only source access; approval never triggers a rebuild. Both environments should require reviewers, prevent self-review where team size permits, and restrict deployment to their intended tags or branches. Workflows should receive secrets only at the job that needs them. ## Runner image publication 1. Review changes under `action/`, especially the Dockerfile and downloaded binaries. -2. Dispatch **Runner Image**, approve the `release-image` deployment, and wait for build and scan results. -3. Copy the published immutable `sha256:` digest from the workflow summary. -4. In a pull request, update the runner image reference in `action.yml` to that digest. -5. Run the composite-action smoke test and Extended CI before merging. +2. Dispatch **Runner Image** and inspect the build job's immutable digest. +3. Approve the `release-image` deployment. The publish job promotes that exact digest to the requested tag without rebuilding it. +4. Copy the published immutable `sha256:` digest from the workflow summary. +5. In a pull request, update the runner image reference in `action.yml` to that digest. +6. Run the composite-action smoke test and Extended CI before merging. -Never silently move the image tag used by the action. The digest change must remain reviewable and reversible. +The build job pushes a run-scoped `build--` staging tag so the protected job can promote the exact registry object. Periodically remove unreferenced staging versions according to the package retention policy. Never silently move the image tag used by the action. The digest change must remain reviewable and reversible. Reusable callers consume the workflow's digest output, not the mutable tag. ## Release procedure 1. Ensure the release commit is on `main` and CI is green. 2. Create a signed `v*` semantic-version tag covered by the tag ruleset. -3. Approve the `release` environment deployment after confirming the tag and changelog. -4. Verify the GitHub Release assets and checksums produced by GoReleaser. -5. If publication fails, fix forward and create a new version; do not rewrite a published tag. +3. Let the unprivileged build job generate the GoReleaser files and upload the one-day release-candidate artifact. +4. Inspect the tag, commit, changelog, and build summary, then approve the `release` environment deployment. +5. Verify that the publish job attested and uploaded the exact release-candidate archives and checksums without rebuilding them. +6. If publication fails, fix forward and create a new version; do not rewrite a published tag. + +## Cache policy + +- Go module caches are enabled only for deterministic validation jobs. Pull-request caches are treated as untrusted performance hints: jobs must still verify and compile all inputs, and they receive no secrets or write token. +- GoReleaser snapshot and production builds disable the Actions cache so release output does not consume cache entries writable by other jobs. +- Runner-image builds do not use the GitHub Actions cache backend; a cache service outage must not fail a publication after registry writes begin. +- Caches are disposable and must never carry reports, release files, credentials, or state required by a later job. Use uploaded artifacts for job-to-job transfer and pin retention explicitly. +- When changing a cache key, dependency manager, lockfile, or trust boundary, review both restore sources and write permissions before enabling it. ## Artifacts and incidents diff --git a/docs/zh/guide/ci-maintenance.md b/docs/zh/guide/ci-maintenance.md index 5ef7b08..215bb43 100644 --- a/docs/zh/guide/ci-maintenance.md +++ b/docs/zh/guide/ci-maintenance.md @@ -22,17 +22,20 @@ Ruleset 必须使用下表的 Job 展示名称。`build` 等 Job ID 只是实现 | --- | --- | --- | --- | | CI | `push`、`pull_request`、`merge_group` | `Build & Test`、`E2E Smoke`、`Lint` | — | | CodeQL | `push`、`pull_request`、`merge_group`、定时任务 | `Analyze (actions)`、`Analyze (go)`、`Analyze (python)` | — | -| Extended CI | `merge_group`、手动触发 | `E2E (none runtime, Windows)`、`E2E (docker runtime)`、`GoReleaser Check` | `E2E (none runtime)`、`E2E (opensandbox runtime)`、`E2E (docker runtime, full LLM)` | +| Extended CI | `merge_group`、手动触发 | `Extended CI Summary` | 其余组件 Job 由 Summary 汇总 | +| Model E2E | 手动触发 | 不得设为必需检查 | `E2E (none runtime, live models)`、`E2E (OpenSandbox, live model)`、`E2E (Docker runtime, live model)` | | Docs | 文档相关的 `push` 和 `pull_request` | 不要设为全局必需;路径过滤会使非文档 PR 没有该检查 | `Build` | | Workflow Security | 工作流相关的 `push`、`pull_request`、`merge_group` | 初始基线完成处置前保持可选 | `Zizmor` | -模型检查依赖外部服务、凭据、额度和非确定性输出,因此保持可选。只有在可靠性经过量化,并确认 Merge Queue 能访问所需环境和 Secret 后,才可升级为必需检查。 +模型检查依赖外部服务、凭据、额度和非确定性输出,因此 `Model E2E` 仅允许手动触发且保持可选;触发时可以运行全部套件或只选择一个 runtime。只有在可靠性经过量化,并确认 Merge Queue 能访问所需环境和 Secret 后,才可升级为必需检查。 所有提供必需检查的工作流都必须监听 `merge_group`,否则 Merge Queue 会永久等待一个不会创建的检查。重命名 Job 后,应先让新检查成功运行一次,再修改 Ruleset。 所有工作流必须在顶层设置 `permissions: {}`,并由各 Job 单独申请权限。所有 `actions/checkout` 步骤必须设置 `persist-credentials: false`。仓库 Actions 策略应强制完整 commit SHA,并只允许当前工作流实际使用的 Action 仓库。 -Zizmor 在 Extended CI 中报告的三个 `adhoc-packages` 属于已接受的低风险例外:这些固定版本的全局 CLI 正是测试对象,不是应用依赖。任何 High 或 Medium 级别的 Zizmor 发现都必须在合并前修复或完成显式评审。 +Release 工作流还需要 `actions/download-artifact` 和 `actions/attest`。如果仓库使用受限 Action 白名单,必须在第一次 tag 发布前允许这两个官方仓库;工作流中的引用仍必须固定到完整 commit SHA。 + +Zizmor 在 Model E2E 中报告的三个 `adhoc-packages` 属于已接受的低风险例外:这些固定版本的全局 CLI 正是测试对象,不是应用依赖。任何 High 或 Medium 级别的 Zizmor 发现都必须在合并前修复或完成显式评审。 ## Secrets 与 Environments @@ -40,28 +43,38 @@ Zizmor 在 Extended CI 中报告的三个 `adhoc-packages` 属于已接受的低 | 名称 | 范围 | 使用方 | 轮换时机 | | --- | --- | --- | --- | -| `DASHSCOPE_API_KEY` | 仓库 Actions Secret | Extended 模型 E2E、自评测 | 按供应商策略、维护者离职或疑似泄露时 | +| `DASHSCOPE_API_KEY` | 仓库 Actions Secret | Model E2E、自评测 | 按供应商策略、维护者离职或疑似泄露时 | | `QODER_ACCESS_TOKEN` | 仓库 Actions Secret | Qoder E2E | 按供应商策略、维护者离职或疑似泄露时 | -`release` Environment 控制 GitHub Release,`release-image` Environment 控制 GHCR 发布。两者都应配置审核者;团队人数允许时开启禁止自审,并把部署来源限制到预期 tag 或分支。Secret 只传给确实需要它的 Job。 +`release` Environment 控制“发布已构建 GoReleaser 制品”的 Job,`release-image` Environment 控制“把已构建镜像 digest 提升为用户可见 GHCR tag”的 Job。编译发生在审批前且只有源码只读权限;审批不会触发重新构建。两者都应配置审核者;团队人数允许时开启禁止自审,并把部署来源限制到预期 tag 或分支。Secret 只传给确实需要它的 Job。 ## Runner 镜像发布 1. 审查 `action/` 下的变更,重点检查 Dockerfile 和下载的二进制文件。 -2. 手动触发 **Runner Image**,批准 `release-image` 部署并等待构建与扫描完成。 -3. 从工作流摘要复制已发布的不可变 `sha256:` digest。 -4. 通过 Pull Request 把 `action.yml` 中的 Runner 镜像引用更新为该 digest。 -5. 合并前运行复合 Action 冒烟测试和 Extended CI。 +2. 手动触发 **Runner Image**,检查构建 Job 输出的不可变 digest。 +3. 批准 `release-image` 部署;发布 Job 只把该 digest 提升为目标 tag,不会重新构建。 +4. 从工作流摘要复制已发布的不可变 `sha256:` digest。 +5. 通过 Pull Request 把 `action.yml` 中的 Runner 镜像引用更新为该 digest。 +6. 合并前运行复合 Action 冒烟测试和 Extended CI。 -禁止静默移动 Action 使用的镜像标签;digest 变更必须可审查、可回滚。 +构建 Job 会推送 `build--` 形式的流水线暂存 tag,使受保护发布 Job 能够提升同一个 Registry 对象;应按 Package 保留策略定期清理不再引用的暂存版本。禁止静默移动 Action 使用的镜像标签;digest 变更必须可审查、可回滚。可复用工作流的调用方必须消费 digest 输出,不得依赖可变 tag。 ## Release 发布 1. 确认发布 commit 位于 `main` 且 CI 全部通过。 2. 创建受 tag Ruleset 保护、已签名的 `v*` 语义化版本 tag。 -3. 核对 tag 和变更日志后,批准 `release` Environment 部署。 -4. 验证 GoReleaser 生成的 GitHub Release 资产与校验和。 -5. 发布失败时修复后发布新版本,不要改写已经发布的 tag。 +3. 等待无发布权限的构建 Job 生成 GoReleaser 文件,并上传保留一天的候选发布 Artifact。 +4. 检查 tag、commit、变更日志和构建摘要后,批准 `release` Environment 部署。 +5. 验证发布 Job 对同一批候选归档和校验和生成了 Attestation 并完成上传,期间没有重新构建。 +6. 发布失败时修复后发布新版本,不要改写已经发布的 tag。 + +## 缓存策略 + +- Go Module 缓存只用于确定性校验 Job。Pull Request 缓存应视为不可信的性能提示:Job 仍需验证并编译全部输入,且不能获得 Secret 或写 Token。 +- GoReleaser 快照和正式发布构建禁用 Actions 缓存,避免发布结果消费其他 Job 可写入的缓存条目。 +- Runner 镜像构建不使用 GitHub Actions Cache 后端;Registry 写入开始后,不能让缓存服务故障导致发布失败。 +- 缓存是可丢弃数据,不得承载报告、发布文件、凭据或后续 Job 必需的状态。跨 Job 传递必须使用 Artifact,并明确设置保留期。 +- 修改缓存 key、依赖管理器、锁文件或信任边界时,必须先复审缓存恢复来源与写权限。 ## 产物与事件响应 From 60c27a2b158acd32636681b4a57bc7132b7a418d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A9=BA=E5=A0=82?= Date: Thu, 23 Jul 2026 15:41:19 +0800 Subject: [PATCH 6/7] ci: address workflow review feedback --- .github/dependabot.yml | 10 ++++++++++ .github/workflows/docs.yml | 9 ++++----- .github/workflows/model-e2e.yml | 2 +- action/main.py | 13 +++++++++++-- 4 files changed, 26 insertions(+), 8 deletions(-) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 72a9461..ff41539 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -6,6 +6,10 @@ updates: schedule: interval: "weekly" day: "monday" + # Delay newly published versions long enough for early regressions or + # compromised releases to be detected. Security updates are unaffected. + cooldown: + default-days: 7 open-pull-requests-limit: 5 labels: - "dependencies" @@ -18,6 +22,8 @@ updates: schedule: interval: "weekly" day: "monday" + cooldown: + default-days: 7 open-pull-requests-limit: 1 groups: github-actions: @@ -35,6 +41,8 @@ updates: schedule: interval: "weekly" day: "monday" + cooldown: + default-days: 7 open-pull-requests-limit: 3 groups: documentation: @@ -51,6 +59,8 @@ updates: schedule: interval: "weekly" day: "monday" + cooldown: + default-days: 7 open-pull-requests-limit: 2 labels: - "dependencies" diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 0e24b3e..f59d0ee 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -43,7 +43,7 @@ jobs: persist-credentials: false - name: Set up Node.js - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: node-version: '20' cache: 'npm' @@ -54,10 +54,6 @@ jobs: - name: Build VitePress site run: npm run docs:build - - name: Configure GitHub Pages - if: github.event_name != 'pull_request' - uses: actions/configure-pages@45bfe0192ca1faeb007ade9deae92b16b8254a0d # v6 - - name: Upload GitHub Pages artifact if: github.event_name != 'pull_request' uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5 @@ -77,6 +73,9 @@ jobs: name: github-pages url: ${{ steps.deployment.outputs.page_url }} steps: + - name: Configure GitHub Pages + uses: actions/configure-pages@45bfe0192ca1faeb007ade9deae92b16b8254a0d # v6 + - name: Deploy to GitHub Pages id: deployment uses: actions/deploy-pages@cd2ce8fcbc39b97be8ca5fce6e763baed58fa128 # v5 diff --git a/.github/workflows/model-e2e.yml b/.github/workflows/model-e2e.yml index ba51721..993cf79 100644 --- a/.github/workflows/model-e2e.yml +++ b/.github/workflows/model-e2e.yml @@ -63,7 +63,7 @@ jobs: - name: Set up Node.js if: steps.secrets.outputs.available == 'true' - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: node-version: "22" diff --git a/action/main.py b/action/main.py index 7d3e2c7..feb6304 100644 --- a/action/main.py +++ b/action/main.py @@ -31,6 +31,7 @@ import subprocess import sys import tempfile +import urllib.error import urllib.request @@ -345,8 +346,16 @@ def install_skill_up(bin_dir, version): if version and version != "latest": install_env["SKILL_UP_VERSION"] = version - with urllib.request.urlopen(SKILL_UP_INSTALL_URL, timeout=120) as response: - installer = response.read() + try: + with urllib.request.urlopen(SKILL_UP_INSTALL_URL, timeout=120) as response: + installer = response.read() + except (urllib.error.URLError, TimeoutError, OSError) as exc: + print( + f"error: failed to download skill-up installer from " + f"{SKILL_UP_INSTALL_URL}: {exc}", + file=sys.stderr, + ) + sys.exit(1) actual_sha256 = hashlib.sha256(installer).hexdigest() if actual_sha256 != SKILL_UP_INSTALL_SHA256: print( From 0907932ec421f7453b96dc0e19b3452a8defc1e3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A9=BA=E5=A0=82?= Date: Thu, 23 Jul 2026 17:47:13 +0800 Subject: [PATCH 7/7] docs: document runner image version sync --- README.md | 29 ++++++++++++++++++ action.yml | 2 +- docs/guide/ci-maintenance.md | 53 +++++++++++++++++++++++++++++++++ docs/zh/guide/ci-maintenance.md | 53 +++++++++++++++++++++++++++++++++ 4 files changed, 136 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index e955460..9a30970 100644 --- a/README.md +++ b/README.md @@ -328,6 +328,27 @@ Key inputs: `engine`, `model`, `provider`, `api-key`, `base-url`, `skill-target` runner image, so a run is just "pull image, eval". See [`action.yml`](action.yml) for the full input/output reference. +### Bundled skill-up version + +The container image includes a deliberately pinned skill-up CLI version. The +version is not resolved from `latest` when a workflow starts, so a given image +digest always runs the same CLI. + +The `skill-up-version` input is only a fallback for a custom image that does not +already contain the `skill-up` binary. The official image contains the binary, +so this input cannot override its bundled version. To find the effective +version, check the `skill-up --version` line in the Action log. + +Publishing a new skill-up CLI release does not automatically update the GitHub +Action image. Maintainers must synchronize the pinned version, publish and test +a new runner image, and update the image digest in `action.yml`. The complete +maintainer procedure is documented in the +[CI maintenance runbook](docs/guide/ci-maintenance.md#manual-skill-up-version-synchronization). + +The production Action must use an immutable `sha256:` image digest. Do not +replace it with `skill-up-runner:latest`; a mutable tag would allow existing +Action references to change behavior without a repository commit. + ### Versioning `uses:` points at any git ref that contains `action.yml`. Pin a **release tag** @@ -335,6 +356,14 @@ for the full input/output reference. `@main` always tracks the latest. Release tags published **before** the action was added do not contain `action.yml` and cannot be used as the ref. +A CLI release tag captures the `action.yml` and runner-image digest that existed +when that tag was created. Because the current runner image is refreshed +manually after CLI release assets become available, do not assume that a CLI tag +automatically contains an Action image with the same CLI version. Until a +separate Action release tag process is introduced, use a post-refresh commit SHA +for an immutable reference or `@main` when intentionally following Action +updates. + ## License Apache License 2.0 — see [LICENSE](LICENSE). diff --git a/action.yml b/action.yml index 350f161..9708927 100644 --- a/action.yml +++ b/action.yml @@ -41,7 +41,7 @@ inputs: required: false default: '.' skill-up-version: - description: 'skill-up version. Ignored when the image already ships skill-up (the default).' + description: 'Fallback skill-up version for custom images without skill-up. The official image preinstalls a pinned version and ignores this input.' required: false default: '0.7.0' skill-up-command: diff --git a/docs/guide/ci-maintenance.md b/docs/guide/ci-maintenance.md index 8b67cd5..4cb5e07 100644 --- a/docs/guide/ci-maintenance.md +++ b/docs/guide/ci-maintenance.md @@ -59,6 +59,57 @@ The `release` environment gates the job that publishes already-built GoReleaser The build job pushes a run-scoped `build--` staging tag so the protected job can promote the exact registry object. Periodically remove unreferenced staging versions according to the package retention policy. Never silently move the image tag used by the action. The digest change must remain reviewable and reversible. Reusable callers consume the workflow's digest output, not the mutable tag. +## Manual skill-up version synchronization + +The official Action image does not track `latest`. A CLI release and an Action image refresh are two separate operations. Publishing a CLI release alone leaves the existing Action image unchanged. + +The image build reads its CLI version from `ARG SKILL_UP_VERSION` in `action/Dockerfile`; the **Runner Image** dispatch `tag` controls only the GHCR tag and does not select the CLI version. + +After publishing a new CLI release such as `v0.8.0`, perform the following procedure: + +1. Verify that the GitHub Release exists and contains the expected archives and checksum file. The image installer downloads these release assets, so the image cannot be built before they exist. +2. Create a version-synchronization pull request that updates all three defaults to the version without the `v` prefix: + - `action/Dockerfile`: `ARG SKILL_UP_VERSION=0.8.0` + - `action.yml`: the `skill-up-version` fallback default + - `action/main.py`: the `--skill-up-version` fallback default +3. If `install.sh` changed, also update the pinned installer commit and SHA-256 in both `action/Dockerfile` and `action/main.py`. Do not change these pins when the installer content is unchanged. +4. Run CI, CodeQL, Workflow Security, the Dockerfile build review, and the relevant deterministic E2E checks. Merge the synchronization pull request into `main`. +5. Dispatch **Runner Image** from `main`: + - set `tag` to the intended image tag, normally the CLI release tag such as `v0.8.0`; + - leave `publish_latest` disabled unless maintainers explicitly need the convenience tag; + - remember that neither value changes `SKILL_UP_VERSION`. +6. Inspect the build log and require `skill-up --version` to report exactly `0.8.0`. Reject the `release-image` deployment if the version differs. +7. Inspect the immutable build digest, then approve the `release-image` environment. Confirm that the publish job promotes the same digest without rebuilding it. +8. Copy the published digest and create a second pull request that updates the `image: docker://...@sha256:...` reference and its version comment in `action.yml`. +9. Run the composite Action smoke test, **Extended CI**, and **Skill Upper Self-Eval** against that digest. Merge only after the expected engines complete successfully or a documented waiver is reviewed. +10. Verify the merged `action.yml`, GHCR digest, image label, and `skill-up --version` all describe the same release. + +The `skill-up-version` Action input does not override the official image because `action/main.py` skips installation when the bundled binary is present. It exists only as a fallback for custom images without skill-up. Do not tell users to select a newer official CLI version through this input. + +The current repository does not automatically publish a separate immutable Action release tag after the image digest pull request. Until that process exists: + +- `@main` follows the latest merged Action image; +- a post-refresh commit SHA is the stable immutable Action reference; +- an existing CLI release tag must never be moved to include a later digest; +- a CLI release tag may contain the Action image that existed when the tag was created, not necessarily the CLI version named by that tag. + +### Runner image release checklist + +- [ ] The CLI GitHub Release and checksum file exist. +- [ ] The three skill-up version defaults match. +- [ ] Installer commit and checksum pins match the intended `install.sh` content. +- [ ] The image build log reports the expected `skill-up --version`. +- [ ] Agent CLI versions remain compatible with the new skill-up release. +- [ ] SBOM and provenance generation succeed. +- [ ] Self-Eval consumes the immutable digest, not a tag. +- [ ] `action.yml` is updated to the verified digest. +- [ ] Production `action.yml` does not reference `latest`. +- [ ] The image version, GHCR digest, Action comment, and maintenance record agree. + +### Rollback + +Do not rebuild or move an old digest. To roll back, create a pull request that restores the last known-good `action.yml` digest, rerun the Action smoke test, and merge through the normal ruleset. If the CLI release itself is faulty, publish a new patch release and repeat the synchronization procedure; never rewrite a published CLI tag. + ## Release procedure 1. Ensure the release commit is on `main` and CI is green. @@ -68,6 +119,8 @@ The build job pushes a run-scoped `build--` staging tag so the 5. Verify that the publish job attested and uploaded the exact release-candidate archives and checksums without rebuilding them. 6. If publication fails, fix forward and create a new version; do not rewrite a published tag. +After the CLI Release succeeds, continue with **Manual skill-up version synchronization**. The CLI release is not considered available through the official GitHub Action until the runner-image digest update has been tested and merged. + ## Cache policy - Go module caches are enabled only for deterministic validation jobs. Pull-request caches are treated as untrusted performance hints: jobs must still verify and compile all inputs, and they receive no secrets or write token. diff --git a/docs/zh/guide/ci-maintenance.md b/docs/zh/guide/ci-maintenance.md index 215bb43..7983ecf 100644 --- a/docs/zh/guide/ci-maintenance.md +++ b/docs/zh/guide/ci-maintenance.md @@ -59,6 +59,57 @@ Zizmor 在 Model E2E 中报告的三个 `adhoc-packages` 属于已接受的低 构建 Job 会推送 `build--` 形式的流水线暂存 tag,使受保护发布 Job 能够提升同一个 Registry 对象;应按 Package 保留策略定期清理不再引用的暂存版本。禁止静默移动 Action 使用的镜像标签;digest 变更必须可审查、可回滚。可复用工作流的调用方必须消费 digest 输出,不得依赖可变 tag。 +## 人工同步 skill-up 版本 + +官方 Action 镜像不会跟踪 `latest`。CLI Release 和 Action 镜像刷新是两个独立操作;只发布 CLI Release 不会改变现有 Action 镜像。 + +镜像构建从 `action/Dockerfile` 的 `ARG SKILL_UP_VERSION` 读取 CLI 版本;手动触发 **Runner Image** 时填写的 `tag` 只控制 GHCR tag,不会选择 CLI 版本。 + +发布新的 CLI Release(例如 `v0.8.0`)后,按照以下流程操作: + +1. 确认 GitHub Release 已存在,并包含预期归档和校验和文件。镜像安装器会下载这些 Release 资产,因此资产存在之前不能构建该版本镜像。 +2. 创建版本同步 Pull Request,把以下三个默认值统一更新为不带 `v` 前缀的版本: + - `action/Dockerfile`:`ARG SKILL_UP_VERSION=0.8.0` + - `action.yml`:`skill-up-version` fallback 默认值 + - `action/main.py`:`--skill-up-version` fallback 默认值 +3. 如果 `install.sh` 内容发生变化,还要同步更新 `action/Dockerfile` 和 `action/main.py` 中固定的安装脚本 commit 与 SHA-256;安装脚本内容没有变化时不要无意义更新。 +4. 运行 CI、CodeQL、Workflow Security、Dockerfile 评审和相关确定性 E2E,合并版本同步 PR 到 `main`。 +5. 从 `main` 手动触发 **Runner Image**: + - `tag` 填写目标镜像 tag,通常与 CLI Release 一致,例如 `v0.8.0`; + - 除非维护者明确需要便利 tag,否则保持 `publish_latest` 关闭; + - 两个输入都不会改变 `SKILL_UP_VERSION`。 +6. 检查构建日志,必须确认 `skill-up --version` 精确输出 `0.8.0`;不一致时拒绝 `release-image` 部署。 +7. 检查不可变构建 digest,批准 `release-image` Environment;确认发布 Job 提升的是同一 digest,期间没有重新构建。 +8. 复制已发布 digest,创建第二个 Pull Request,更新 `action.yml` 中的 `image: docker://...@sha256:...` 引用及其版本注释。 +9. 使用该 digest 运行复合 Action 冒烟测试、**Extended CI** 和 **Skill Upper Self-Eval**;预期引擎全部完成,或经过评审的 waiver 有明确记录后,才能合并。 +10. 确认合并后的 `action.yml`、GHCR digest、镜像 label 和 `skill-up --version` 指向同一个版本。 + +官方镜像已经包含 skill-up,因此 `action/main.py` 会跳过安装,`skill-up-version` Action input 不能覆盖官方镜像内版本。该 input 只用于没有预装 skill-up 的自定义镜像。不得指导用户通过它选择更新的官方 CLI 版本。 + +仓库当前不会在镜像 digest PR 合并后自动发布独立、不可变的 Action Release tag。在该机制建立前: + +- `@main` 跟踪最新合并的 Action 镜像; +- digest 更新后的 commit SHA 是稳定、不可变的 Action 引用; +- 禁止移动已经发布的 CLI tag 来包含后续 digest; +- CLI Release tag 可能包含创建 tag 时已有的 Action 镜像,不保证镜像 CLI 版本与 tag 名称一致。 + +### Runner 镜像发布检查清单 + +- [ ] CLI GitHub Release 和校验和文件已经存在。 +- [ ] 三处 skill-up 版本默认值保持一致。 +- [ ] 安装脚本 commit 与 checksum pin 对应预期 `install.sh` 内容。 +- [ ] 镜像构建日志中的 `skill-up --version` 符合预期。 +- [ ] Agent CLI 版本与新版 skill-up 兼容。 +- [ ] SBOM 和 provenance 生成成功。 +- [ ] Self-Eval 消费不可变 digest,而不是 tag。 +- [ ] `action.yml` 已更新为验证通过的 digest。 +- [ ] 生产 `action.yml` 未引用 `latest`。 +- [ ] 镜像版本、GHCR digest、Action 注释和维护记录一致。 + +### 回滚 + +禁止重新构建或移动旧 digest。需要回滚时,通过 Pull Request 恢复 `action.yml` 上一个已知正常的 digest,重新运行 Action 冒烟测试并按正常 Ruleset 合并。如果 CLI Release 本身有问题,应发布新的 patch 版本并重新执行同步流程,不得改写已发布 CLI tag。 + ## Release 发布 1. 确认发布 commit 位于 `main` 且 CI 全部通过。 @@ -68,6 +119,8 @@ Zizmor 在 Model E2E 中报告的三个 `adhoc-packages` 属于已接受的低 5. 验证发布 Job 对同一批候选归档和校验和生成了 Attestation 并完成上传,期间没有重新构建。 6. 发布失败时修复后发布新版本,不要改写已经发布的 tag。 +CLI Release 成功后,继续执行“人工同步 skill-up 版本”。只有 Runner Image digest 更新完成测试并合并后,才能认为该 CLI 版本已经可通过官方 GitHub Action 使用。 + ## 缓存策略 - Go Module 缓存只用于确定性校验 Job。Pull Request 缓存应视为不可信的性能提示:Job 仍需验证并编译全部输入,且不能获得 Secret 或写 Token。