diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dca58bd..ac5e29e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,23 +15,16 @@ jobs: steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - name: Resolve server OpenAPI ref + - name: Read pinned server commit id: server-openapi-ref - run: | - candidate="${SERVER_OPENAPI_REF:-${GITHUB_HEAD_REF:-${GITHUB_REF_NAME}}}" - if git ls-remote --exit-code --heads https://github.com/Life-USTC/server.git "$candidate" >/dev/null 2>&1; then - echo "ref=$candidate" >> "$GITHUB_OUTPUT" - else - echo "::warning::Server OpenAPI branch '$candidate' not found; falling back to main" - echo "ref=main" >> "$GITHUB_OUTPUT" - fi + run: echo "ref=$(./scripts/openapi-contract pinned-sha)" >> "$GITHUB_OUTPUT" - name: Checkout server OpenAPI source uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: repository: Life-USTC/server ref: ${{ steps.server-openapi-ref.outputs.ref }} - path: server + path: .openapi-server - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 with: @@ -45,7 +38,8 @@ jobs: - name: Check generated code is up-to-date run: | - make sync-openapi generate OPENAPI_SOURCE=server/public/openapi.generated.json + make check-openapi-sync OPENAPI_SERVER_DIR=.openapi-server + make generate git diff --exit-code lint: diff --git a/.github/workflows/openapi-sync.yml b/.github/workflows/openapi-sync.yml new file mode 100644 index 0000000..2bc0c4f --- /dev/null +++ b/.github/workflows/openapi-sync.yml @@ -0,0 +1,93 @@ +name: Sync OpenAPI contract + +on: + schedule: + - cron: "23 4 * * *" + workflow_dispatch: + inputs: + server_sha: + description: Exact Life-USTC/server commit SHA (defaults to current main) + required: false + type: string + repository_dispatch: + types: [openapi-updated] + +permissions: + contents: write + pull-requests: write + +concurrency: + group: openapi-contract-sync + cancel-in-progress: false + +jobs: + sync: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Resolve immutable server commit + id: server + env: + DISPATCH_SHA: ${{ github.event.client_payload.server_sha }} + INPUT_SHA: ${{ inputs.server_sha }} + run: | + explicit="${INPUT_SHA:-${DISPATCH_SHA:-}}" + if [[ -n "$explicit" ]]; then + if [[ ! "$explicit" =~ ^[0-9a-f]{40}$ ]]; then + echo "Explicit server_sha must be an exact 40-character lowercase commit SHA" >&2 + exit 1 + fi + sha="$explicit" + else + sha="$(git ls-remote https://github.com/Life-USTC/server.git refs/heads/main | awk '{print $1}')" + if [[ ! "$sha" =~ ^[0-9a-f]{40}$ ]]; then + echo "Could not resolve Life-USTC/server main to an immutable commit" >&2 + exit 1 + fi + fi + echo "sha=$sha" >> "$GITHUB_OUTPUT" + + - name: Checkout exact server commit + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + repository: Life-USTC/server + ref: ${{ steps.server.outputs.sha }} + path: .openapi-server + + - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 + with: + go-version-file: go.mod + + - name: Sync contract and generated client + run: | + make sync-openapi OPENAPI_SERVER_DIR=.openapi-server SERVER_COMMIT=${{ steps.server.outputs.sha }} + ./scripts/openapi-contract verify + cmp -s .openapi-server/public/openapi.generated.json api/openapi.json + make generate + + - name: Build, test, and vet + run: | + make build + make test + make vet + + - name: golangci-lint + uses: golangci/golangci-lint-action@ba0d7d2ec06a0ea1cb5fa41b2e4a3ab91d21278a # v9.3.0 + with: + version: v2.12.2 + + # Set OPENAPI_SYNC_TOKEN to a PAT or GitHub App token if automated PRs + # should trigger the repository's normal pull_request workflows. + - name: Open or update contract PR + uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1 + with: + token: ${{ secrets.OPENAPI_SYNC_TOKEN || github.token }} + branch: automation/openapi-contract + delete-branch: true + commit-message: "chore: sync OpenAPI contract" + title: "chore: sync OpenAPI contract" + body: | + Synchronizes the vendored OpenAPI contract and generated CLI client with an immutable `Life-USTC/server` commit. + + The sync workflow completed build, race-enabled tests, vet, and lint before opening this PR. When it uses the default `GITHUB_TOKEN`, GitHub will not trigger a second pull-request CI run. diff --git a/.gitignore b/.gitignore index 43b226a..048336e 100644 --- a/.gitignore +++ b/.gitignore @@ -14,6 +14,7 @@ # Build artifacts dist/ +/.openapi-server/ # IDE .vscode/ diff --git a/.goreleaser.yml b/.goreleaser.yml index cff489a..20c8197 100644 --- a/.goreleaser.yml +++ b/.goreleaser.yml @@ -1,5 +1,10 @@ version: 2 +before: + hooks: + - make check-openapi-provenance generate + - git diff --exit-code + builds: - main: ./cmd/life-ustc binary: life-ustc diff --git a/Makefile b/Makefile index ecddc8c..665b04b 100644 --- a/Makefile +++ b/Makefile @@ -1,10 +1,11 @@ VERSION ?= dev -OPENAPI_SOURCE ?= ../server/public/openapi.generated.json +OPENAPI_SERVER_DIR ?= ../server +SERVER_COMMIT ?= LDFLAGS := -ldflags "-X github.com/Life-USTC/CLI/internal/cmd/root.version=$(VERSION)" -.PHONY: build clean test lint install generate sync-openapi check-openapi-sync +.PHONY: build clean test lint vet install generate sync-openapi check-openapi-provenance check-openapi-sync -build: +build: check-openapi-provenance generate go build $(LDFLAGS) -o life-ustc ./cmd/life-ustc clean: @@ -17,7 +18,10 @@ test: lint: golangci-lint run ./... -install: +vet: + go vet ./... + +install: check-openapi-provenance generate go install $(LDFLAGS) ./cmd/life-ustc generate: @@ -25,7 +29,10 @@ generate: go run ./internal/cmd/apicmd/genpaths sync-openapi: - cp $(OPENAPI_SOURCE) api/openapi.json + ./scripts/openapi-contract sync "$(OPENAPI_SERVER_DIR)" "$(SERVER_COMMIT)" + +check-openapi-provenance: + ./scripts/openapi-contract verify check-openapi-sync: - cmp -s $(OPENAPI_SOURCE) api/openapi.json + ./scripts/openapi-contract verify-source "$(OPENAPI_SERVER_DIR)" diff --git a/README.md b/README.md index 84d2328..a9f679b 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,22 @@ 登录支持浏览器 OAuth(PKCE)与设备码;默认 server 为生产站点,也可用 `--server` / `LIFE_USTC_SERVER` 指向其它实例。 +## OpenAPI 契约 + +CLI 从仓库内的 `api/openapi.json` 生成。`api/openapi.provenance.json` 记录 +对应的 server 提交和 SHA-256;`make build` 会先验证来源并重新生成客户端, +再开始编译。 + +更新契约时,先检出确定的 `Life-USTC/server` 提交,再运行: + +```sh +make sync-openapi OPENAPI_SERVER_DIR=/path/to/server SERVER_COMMIT=<40-character-sha> +make generate +``` + +CI 会核对固定的 server 提交,不再猜测同名分支。定时同步工作流也会在 +server 契约发生变化时创建更新 PR。 + ## 安装 发布包:[GitHub Releases](https://github.com/Life-USTC/CLI/releases)。或: diff --git a/api/openapi.json b/api/openapi.json index 6227099..61cae46 100644 --- a/api/openapi.json +++ b/api/openapi.json @@ -1348,7 +1348,9 @@ "in": "query", "name": "search", "schema": { - "type": "string" + "type": "string", + "minLength": 2, + "maxLength": 200 } }, { @@ -1380,7 +1382,9 @@ "name": "page", "schema": { "type": "integer", - "format": "int64" + "format": "int64", + "minimum": 1, + "maximum": 100 } }, { @@ -1591,7 +1595,9 @@ "name": "page", "schema": { "type": "integer", - "format": "int64" + "format": "int64", + "minimum": 1, + "maximum": 100 } }, { @@ -1729,7 +1735,9 @@ "in": "query", "name": "search", "schema": { - "type": "string" + "type": "string", + "minLength": 2, + "maxLength": 200 } }, { @@ -1751,7 +1759,9 @@ "name": "page", "schema": { "type": "integer", - "format": "int64" + "format": "int64", + "minimum": 1, + "maximum": 100 } }, { @@ -1815,7 +1825,9 @@ "name": "page", "schema": { "type": "integer", - "format": "int64" + "format": "int64", + "minimum": 1, + "maximum": 100 } }, { @@ -1897,7 +1909,9 @@ "in": "query", "name": "search", "schema": { - "type": "string" + "type": "string", + "minLength": 2, + "maxLength": 200 } }, { @@ -1905,7 +1919,9 @@ "name": "page", "schema": { "type": "integer", - "format": "int64" + "format": "int64", + "minimum": 1, + "maximum": 100 } }, { diff --git a/api/openapi.provenance.json b/api/openapi.provenance.json new file mode 100644 index 0000000..8c91383 --- /dev/null +++ b/api/openapi.provenance.json @@ -0,0 +1,5 @@ +{ + "repository": "Life-USTC/server", + "commit": "e32d01a31d2e3b79765448aed8665eb197b93977", + "sha256": "d56af5ae5da8dc89e5c3ef618bc39cd0ea9b64e93a8e0808e4e2ab6ffabd58f4" +} diff --git a/scripts/openapi-contract b/scripts/openapi-contract new file mode 100755 index 0000000..157b4b2 --- /dev/null +++ b/scripts/openapi-contract @@ -0,0 +1,130 @@ +#!/usr/bin/env bash + +set -euo pipefail + +readonly repository="Life-USTC/server" +readonly spec="api/openapi.json" +readonly provenance="api/openapi.provenance.json" + +usage() { + echo "usage: $0 {pinned-sha|verify|verify-source SERVER_DIR|sync SERVER_DIR SERVER_COMMIT}" >&2 + exit 2 +} + +sha256_file() { + if command -v sha256sum >/dev/null 2>&1; then + sha256sum "$1" | awk '{print $1}' + else + shasum -a 256 "$1" | awk '{print $1}' + fi +} + +json_value() { + local key="$1" + sed -nE 's/^[[:space:]]*"'"$key"'"[[:space:]]*:[[:space:]]*"([^"]*)"[,]?[[:space:]]*$/\1/p' "$provenance" +} + +pinned_sha() { + local commit + commit="$(json_value commit)" + [[ "$commit" =~ ^[0-9a-f]{40}$ ]] || { + echo "invalid or missing server commit in $provenance" >&2 + exit 1 + } + printf '%s\n' "$commit" +} + +verify() { + [[ -f "$spec" && -f "$provenance" ]] || { + echo "OpenAPI spec or provenance is missing" >&2 + exit 1 + } + + local recorded_repository recorded_hash actual_hash + recorded_repository="$(json_value repository)" + recorded_hash="$(json_value sha256)" + actual_hash="$(sha256_file "$spec")" + + [[ "$recorded_repository" == "$repository" ]] || { + echo "unexpected OpenAPI repository: $recorded_repository" >&2 + exit 1 + } + pinned_sha >/dev/null + [[ "$recorded_hash" =~ ^[0-9a-f]{64}$ && "$recorded_hash" == "$actual_hash" ]] || { + echo "OpenAPI SHA-256 does not match $provenance" >&2 + exit 1 + } +} + +verify_source() { + local server_dir="$1" + verify + + local commit head source_spec + commit="$(pinned_sha)" + head="$(git -C "$server_dir" rev-parse HEAD)" + source_spec="$server_dir/public/openapi.generated.json" + + [[ "$head" == "$commit" ]] || { + echo "server checkout is $head, expected pinned commit $commit" >&2 + exit 1 + } + cmp -s "$source_spec" "$spec" || { + echo "vendored OpenAPI does not match server commit $commit" >&2 + exit 1 + } +} + +sync() { + local server_dir="$1" + local commit="$2" + local head source_spec hash + + [[ "$commit" =~ ^[0-9a-f]{40}$ ]] || { + echo "SERVER_COMMIT must be an exact 40-character lowercase commit SHA" >&2 + exit 1 + } + head="$(git -C "$server_dir" rev-parse HEAD)" + [[ "$head" == "$commit" ]] || { + echo "server checkout is $head, requested $commit; refusing to fall back" >&2 + exit 1 + } + source_spec="$server_dir/public/openapi.generated.json" + [[ -f "$source_spec" ]] || { + echo "server OpenAPI source not found: $source_spec" >&2 + exit 1 + } + + # Keep the existing immutable pin when an unrelated server commit leaves the + # contract unchanged. This avoids provenance-only update PRs. + if cmp -s "$source_spec" "$spec" && verify >/dev/null 2>&1; then + echo "OpenAPI contract is unchanged at pinned commit $(pinned_sha)" + return + fi + + cp "$source_spec" "$spec" + hash="$(sha256_file "$spec")" + printf '{\n "repository": "%s",\n "commit": "%s",\n "sha256": "%s"\n}\n' \ + "$repository" "$commit" "$hash" >"$provenance" + verify +} + +case "${1:-}" in + pinned-sha) + [[ $# -eq 1 ]] || usage + pinned_sha + ;; + verify) + [[ $# -eq 1 ]] || usage + verify + ;; + verify-source) + [[ $# -eq 2 ]] || usage + verify_source "$2" + ;; + sync) + [[ $# -eq 3 ]] || usage + sync "$2" "$3" + ;; + *) usage ;; +esac