Skip to content

Bump actions/checkout from 6 to 7 in the all-actions group #111

Bump actions/checkout from 6 to 7 in the all-actions group

Bump actions/checkout from 6 to 7 in the all-actions group #111

Workflow file for this run

name: CI
on:
push:
branches: [main]
tags: ["v*"]
pull_request:
branches: [main]
workflow_dispatch:
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs:
test:
name: Test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-go@v6
with:
go-version: stable
cache: true
- name: Build
run: go build ./...
- name: gofmt
run: |
out="$(gofmt -l .)"
if [ -n "$out" ]; then
echo "gofmt needed on:" && echo "$out" && exit 1
fi
- name: Test (with race)
run: go test -race -count=1 -timeout 120s ./...
- name: Fuzz (short)
run: |
go test -fuzz=^FuzzParsePattern$ -fuzztime=10s ./internal/pattern/
go test -fuzz=^FuzzReflectSchema$ -fuzztime=10s -parallel 1 ./internal/schema/
- name: Vet
run: go vet ./...
# The supported floor is the go directive in go.mod. This job
# computes every stable patch release >= that floor from go.dev and
# fans the full suite out over all of them, so every version we
# advertise compatibility with is actually tested — including
# patches released after this file was written.
versions:
name: Supported Go versions
runs-on: ubuntu-latest
outputs:
matrix: ${{ steps.list.outputs.matrix }}
steps:
- uses: actions/checkout@v7
- id: list
name: Compute versions from the go.mod floor
run: |
floor="$(awk '/^go /{print $2}' go.mod)"
curl -fsSL 'https://go.dev/dl/?mode=json&include=all' -o releases.json
python3 - "$floor" <<'EOF' > versions.json
import json, sys
floor = tuple(int(x) for x in sys.argv[1].split("."))
out = []
for rel in json.load(open("releases.json")):
if not rel.get("stable"):
continue
v = rel["version"].removeprefix("go")
parts = v.split(".")
if len(parts) == 2:
parts.append("0")
try:
t = tuple(int(x) for x in parts)
except ValueError:
continue
if t >= floor:
out.append((t, v))
out.sort()
print(json.dumps([v for _, v in out]))
EOF
echo "Supported versions: $(cat versions.json)"
echo "matrix=$(cat versions.json)" >> "$GITHUB_OUTPUT"
compat:
name: Go ${{ matrix.go }}
needs: versions
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
go: ${{ fromJSON(needs.versions.outputs.matrix) }}
env:
# Force the exact toolchain under test; if go.mod ever demands
# a newer one, this fails loudly instead of silently upgrading.
GOTOOLCHAIN: local
steps:
- uses: actions/checkout@v7
- uses: actions/setup-go@v6
with:
go-version: ${{ matrix.go }}
cache: true
- name: Toolchain
run: go version
- name: Build
run: go build ./...
- name: Vet
run: go vet ./...
- name: Test (with race)
run: go test -race -count=1 -timeout 120s ./...
- name: YAML roundtrip module
working-directory: internal/spec/yaml/roundtrip_test
run: |
go vet ./...
go test -race -count=1 -timeout 60s ./...
# The generated documents are validated with external validators:
# openapi-spec-validator for 3.0.4/3.1.2 and the official OpenAPI
# 3.2 JSON Schema for 3.2.0 — backing the documentation's claim
# that all three outputs are externally validated in CI.
validate:
name: Spec validation
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-go@v6
with:
go-version: stable
cache: true
- name: Generate specs for all three versions
run: |
mkdir -p /tmp/specgen && cd /tmp/specgen
cat > go.mod <<'EOF'
module specgen
go 1.24.0
require github.com/FumingPower3925/stdocs v0.0.0
replace github.com/FumingPower3925/stdocs => ${{ github.workspace }}
EOF
cat > main.go <<'EOF'
package main
import (
"net/http"
"os"
"github.com/FumingPower3925/stdocs"
)
type CreateTask struct {
Title string `json:"title" doc:"Short title" minLength:"1" maxLength:"200" pattern:"^.+$"`
Priority int `json:"priority" minimum:"1" maximum:"5" default:"3" example:"2"`
Ratio float64 `json:"ratio" exclusiveMinimum:"0" exclusiveMaximum:"1.5"`
Status *string `json:"status" enum:"pending,active,done"`
Tags []string `json:"tags" minItems:"1" maxItems:"10" uniqueItems:"true"`
Email string `json:"email" format:"email"`
}
type Task struct{ ID string `json:"id"` }
type APIError struct{ Message string `json:"message"` }
type ListParams struct {
Cursor string `query:"cursor" doc:"Opaque cursor"`
Limit int `query:"limit" default:"20" minimum:"1" maximum:"100"`
}
// GenTask is CreateTask without exclusive bounds: Go client
// generators (ogen, oapi-codegen) reject the numeric 2020-12
// exclusiveMinimum/exclusiveMaximum form that 3.1 documents
// correctly emit, so the generator corpus omits them (Lint
// documents the same limitation to users). Window pairs
// nullability with a default — the anyOf form ogen accepts
// since v1.17.0 — so the pin proves it stays consumable.
type GenTask struct {
Title string `json:"title" doc:"Short title" minLength:"1" maxLength:"200" pattern:"^.+$"`
Priority int `json:"priority" minimum:"1" maximum:"5" default:"3" example:"2"`
Status *string `json:"status" enum:"pending,active,done"`
Tags []string `json:"tags" minItems:"1" maxItems:"10" uniqueItems:"true"`
Window *int `json:"window" default:"30"`
}
func build(v stdocs.SpecVersion, body any) []byte {
mux := stdocs.New(
stdocs.WithTitle("Validation API"),
stdocs.WithVersion(v),
stdocs.WithBearerAuth("bearerAuth", "JWT"),
stdocs.WithDefaultResponse(500, APIError{}),
)
mux.HandleFunc("GET /tasks", func(w http.ResponseWriter, r *http.Request) {},
stdocs.WithParams(ListParams{}))
mux.HandleFunc("POST /tasks", func(w http.ResponseWriter, r *http.Request) {},
stdocs.WithBody(body),
stdocs.WithResponse(201, Task{}),
stdocs.WithSecurity("bearerAuth"))
raw, err := mux.JSON()
if err != nil {
panic(err)
}
return raw
}
func main() {
for _, v := range []stdocs.SpecVersion{stdocs.OpenAPI30, stdocs.OpenAPI31, stdocs.OpenAPI32} {
if err := os.WriteFile("/tmp/spec-"+string(v)+".json", build(v, CreateTask{}), 0o644); err != nil {
panic(err)
}
}
if err := os.WriteFile("/tmp/spec-generators.json", build(stdocs.OpenAPI31, GenTask{}), 0o644); err != nil {
panic(err)
}
}
EOF
go mod tidy && go run .
ls -la /tmp/spec-*.json
- uses: actions/setup-python@v6
with:
python-version: "3.x"
- name: Validate 3.0 and 3.1 with openapi-spec-validator
run: |
pip install --quiet 'openapi-spec-validator~=0.7.1'
python -m openapi_spec_validator /tmp/spec-3.0.4.json
python -m openapi_spec_validator /tmp/spec-3.1.2.json
- name: Consumability — generate typed clients with ogen
run: |
mkdir -p /tmp/ogencheck && cd /tmp/ogencheck
# The full-corpus 3.0.4 document (generators accept the
# draft-4 boolean exclusive bounds) and the generator-corpus
# 3.1 document (anyOf nullables; no exclusive bounds, which
# generators reject in their numeric 2020-12 form).
go run github.com/ogen-go/ogen/cmd/ogen@v1.20.3 --target ./out30 --clean /tmp/spec-3.0.4.json
test -s out30/oas_client_gen.go
go run github.com/ogen-go/ogen/cmd/ogen@v1.20.3 --target ./out31 --clean /tmp/spec-generators.json
test -s out31/oas_client_gen.go
echo "ogen generated typed clients from both documents"
- name: Style-lint the 3.0.4 document with Spectral
run: |
cat > /tmp/.spectral.yaml <<'EOF'
extends: ["spectral:oas"]
# Spectral's JSONPath engine crashes evaluating enum rules
# over arrays containing a null member, which 3.0 nullable
# enums legally require (upstream bug) — the two enum rules
# stay off until that is fixed. Spectral is run on the 3.0.4
# document only: v6 misvalidates 3.1 keywords against the
# 3.0 schema and does not know 3.2; the official validators
# above cover those versions structurally.
rules:
typed-enum: off
duplicated-entry-in-enum: off
EOF
npx -y @stoplight/spectral-cli@6.15.0 lint --ruleset /tmp/.spectral.yaml --fail-severity=error /tmp/spec-3.0.4.json
- name: Consumability — type-check the generated TypeScript
run: |
# The committed golden is generation-equal by test; here the
# pinned compiler proves it is valid strict TypeScript and
# that real payloads assign to the generated types.
mkdir -p /tmp/tscheck
cp tsgen/testdata/corpus.ts /tmp/tscheck/corpus.ts
cat > /tmp/tscheck/harness.ts <<'EOF'
import type { CorpusTask, components, operations, webhooks } from "./corpus";
const task: CorpusTask = {
id: "t1",
title: "Fix the gate",
status: "pending",
level: 2,
priority: 3,
ratio: 0.5,
due_at: null,
note: null,
count: "12",
labels: { a: 1 },
tags: ["one"],
links: [{ kind: "k" }, null],
parent: null,
};
const created: operations["post_tasks"]["responses"][201] = task;
const page: operations["get_tasks"]["responses"][200] = { items: [task, created] };
const query: NonNullable<operations["get_tasks"]["parameters"]["query"]> = { cursor: "c", limit: 10 };
const path: operations["get_tasks_by_id"]["parameters"]["path"] = { id: "t1" };
const gone: operations["delete_tasks_by_id"]["responses"][204] = undefined;
const csv: operations["get_export"]["responses"][200] = "a,b\n1,2\n";
const event: webhooks["task-event"]["requestBody"] = { kind: "created" };
const err: components["CorpusError"] = { code: 1, message: "m" };
export { created, page, query, path, gone, csv, event, err };
EOF
TS_PIN="$(node -e "console.log(require('./package.json').devDependencies.typescript)")"
npx -y -p "typescript@${TS_PIN}" tsc --strict --noEmit /tmp/tscheck/harness.ts
echo "generated TypeScript type-checks under tsc ${TS_PIN} --strict"
- name: Validate 3.2 against the official OpenAPI 3.2 JSON Schema
run: |
pip install --quiet 'jsonschema[format]~=4.23' 'requests~=2.32'
python - <<'EOF'
import json, jsonschema, requests
resp = requests.get(
"https://spec.openapis.org/oas/3.2/schema/2025-09-17",
headers={"Accept": "application/schema+json"},
timeout=30,
)
resp.raise_for_status()
schema = resp.json()
assert schema.get("$id", "").startswith("https://spec.openapis.org/oas/3.2/schema"), schema.get("$id")
doc = json.load(open("/tmp/spec-3.2.0.json"))
jsonschema.validate(doc, schema)
print("3.2.0 valid against", schema.get("$id", "official schema"))
EOF
# Headless rendering smoke tests for all nine bundled UIs. Needs a
# browser and (for the CDN variants) the network, so it runs on
# manual dispatch rather than gating every push; the same command
# runs locally with any Chrome/Chromium.
ui-smoke:
name: UI smoke
if: ${{ github.event_name == 'workflow_dispatch' }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-go@v6
with:
go-version: stable
cache: true
- name: Render all UIs headlessly
env:
CHROME_BIN: /usr/bin/google-chrome
run: go test -tags uismoke -run TestUISmoke -v -timeout 15m .
# Single fan-in check for branch protection: required status checks
# are matched by name, and the compat matrix's job names change with
# every Go patch release — so protection requires only this job,
# which fails unless every job it needs succeeded.
ci-ok:
name: CI OK
runs-on: ubuntu-latest
needs: [test, versions, compat, lint, coverage, validate]
if: always()
steps:
- name: All required jobs succeeded
run: |
results='${{ join(needs.*.result, ' ') }}'
echo "job results: $results"
for r in $results; do
if [ "$r" != "success" ]; then
echo "a required job finished with: $r" && exit 1
fi
done
lint:
name: Lint
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-go@v6
with:
go-version: stable
cache: true
- name: golangci-lint
uses: golangci/golangci-lint-action@v9
with:
version: v2.12.2
# The YAML round-trip module is a separate go module, so the
# root invocation above does not reach it; lint it on its own.
- name: golangci-lint (round-trip module)
uses: golangci/golangci-lint-action@v9
with:
version: v2.12.2
working-directory: internal/spec/yaml/roundtrip_test
coverage:
name: Coverage
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-go@v6
with:
go-version: stable
cache: true
- name: Generate coverage
run: go test -coverprofile=coverage.out -covermode=atomic ./...
- name: Enforce the coverage floor
run: |
total="$(go tool cover -func=coverage.out | awk '/^total:/{sub("%","",$3); print $3}')"
floor=80.0
echo "total statement coverage: ${total}% (floor: ${floor}%)"
awk -v t="$total" -v f="$floor" 'BEGIN { exit (t+0 >= f+0) ? 0 : 1 }' || {
echo "coverage ${total}% fell below the ${floor}% floor"; exit 1; }
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v7
with:
files: ./coverage.out
token: ${{ secrets.CODECOV_TOKEN }}
fail_ci_if_error: false