diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..a3bf2be --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,101 @@ +name: CI + +on: + push: + branches: [master] + tags: ['v*'] + pull_request: + workflow_dispatch: + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +permissions: + contents: read + +jobs: + go: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-go@v7 + with: + go-version-file: go.mod + - run: go run github.com/sqlc-dev/sqlc/cmd/sqlc@v1.31.1 diff + - run: go vet ./... + - run: go run ./cmd/protocolgen -check + - run: sudo apt-get update && sudo apt-get install --yes --no-install-recommends ffmpeg + timeout-minutes: 30 + - run: go test -race ./... + - uses: golangci/golangci-lint-action@v9 + with: + version: v2.15.0 + + web: + runs-on: ubuntu-latest + defaults: + run: + working-directory: web + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-node@v7 + with: + node-version: 24 + cache: npm + cache-dependency-path: web/package-lock.json + - run: npm ci + - run: npm run type-check + - run: npm run lint + - run: npm test + - run: npm run build + + images: + needs: [go, web] + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + strategy: + matrix: + include: + - image: server + file: Dockerfile + target: alisten + - image: worker + file: worker.Dockerfile + target: alisten-worker + - image: web + file: web/Dockerfile + target: "" + steps: + - uses: actions/checkout@v7 + - uses: docker/setup-qemu-action@v4 + - uses: docker/setup-buildx-action@v4 + - uses: docker/login-action@v4 + if: github.event_name != 'pull_request' + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - id: meta + uses: docker/metadata-action@v6 + with: + images: ghcr.io/${{ github.repository }}/${{ matrix.image }} + flavor: latest=false + tags: | + type=raw,value=latest,enable={{is_default_branch}} + type=semver,pattern={{version}} + type=sha,prefix= + - uses: docker/build-push-action@v7 + with: + context: . + file: ${{ matrix.file }} + target: ${{ matrix.target }} + platforms: linux/amd64,linux/arm64 + push: ${{ github.event_name != 'pull_request' }} + build-args: VERSION=${{ github.sha }} + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha,scope=${{ matrix.image }} + cache-to: type=gha,mode=max,scope=${{ matrix.image }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ca25126 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +/alisten +/web/node_modules/ +/web/dist/ +.env diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 0000000..733275c --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,203 @@ +version: "2" + +linters: + default: standard + enable: + - depguard + - forbidigo + exclusions: + rules: + # 这两个文件中未检查的错误不影响结果:decode.go 跳过 fmt 块的对齐填充失败时,读取下一个块头会报错;loudness_bench_test.go 的 binary.Write 写入 bytes.Buffer,不会失败。 + - path: internal/lib/wav/decode\.go|internal/lib/loudness/loudness_bench_test\.go + linters: + - errcheck + # forbidigo 只检查 domain,其他目录可以读系统时钟。 + - path-except: ^internal/domain/ + linters: + - forbidigo + settings: + depguard: + # 不以 $ 结尾的条目按前缀匹配。depguard 只拿排序后紧挨在导入路径之前的一项比较,已有前缀的子包不要再单列: + # deny 中同时有 os 和 os/exec 时,os/signal 就不再被禁止。 + rules: + domain: + list-mode: strict + files: + - "**/internal/domain/**" + - "!$test" + allow: + - $gostd + - github.com/dihemoe/alisten/internal/domain + - github.com/dihemoe/alisten/internal/lib + deny: + - pkg: os + desc: domain 不做 I/O + - pkg: net$ + desc: domain 不做 I/O + - pkg: net/http + desc: domain 不做 I/O + - pkg: database/sql + desc: domain 不做 I/O + - pkg: io/fs + desc: domain 不做 I/O + - pkg: path/filepath + desc: domain 不做 I/O + - pkg: syscall + desc: domain 不做 I/O + - pkg: net/textproto + desc: domain 不做 I/O + - pkg: net/smtp + desc: domain 不做 I/O + - pkg: net/rpc + desc: domain 不做 I/O + - pkg: crypto/tls + desc: domain 不做 I/O + - pkg: log/syslog + desc: domain 不做 I/O + - pkg: plugin + desc: domain 不做 I/O + - pkg: sync + desc: domain 不加锁 + domain-test: + list-mode: strict + files: + - "**/internal/domain/**/*_test.go" + allow: + - $gostd + - github.com/dihemoe/alisten/internal/domain + - github.com/dihemoe/alisten/internal/lib + - golang.org/x/tools/txtar + app: + list-mode: strict + files: + - "**/internal/app/**" + - "!$test" + allow: + - $gostd + - github.com/dihemoe/alisten/internal/domain + - github.com/dihemoe/alisten/internal/lib + - github.com/dihemoe/alisten/internal/app + # 用于房间密码的 argon2id 哈希;纯计算,不做 I/O。 + - golang.org/x/crypto/argon2 + # 用于合并同一首歌的并发播放地址解析;只协调 goroutine,不做 I/O。 + - golang.org/x/sync/singleflight + deny: + - pkg: os + desc: app 的 I/O 经由出站端口 + - pkg: net$ + desc: app 的 I/O 经由出站端口 + - pkg: net/http + desc: app 的 I/O 经由出站端口 + - pkg: database/sql + desc: app 的 I/O 经由出站端口 + - pkg: io/fs + desc: app 的 I/O 经由出站端口 + - pkg: path/filepath + desc: app 的 I/O 经由出站端口 + - pkg: syscall + desc: app 的 I/O 经由出站端口 + - pkg: net/textproto + desc: app 的 I/O 经由出站端口 + - pkg: net/smtp + desc: app 的 I/O 经由出站端口 + - pkg: net/rpc + desc: app 的 I/O 经由出站端口 + - pkg: crypto/tls + desc: app 的 I/O 经由出站端口 + - pkg: log/syslog + desc: app 的 I/O 经由出站端口 + - pkg: plugin + desc: app 的 I/O 经由出站端口 + app-test: + list-mode: strict + files: + - "**/internal/app/**/*_test.go" + allow: + - $gostd + - github.com/dihemoe/alisten/internal/domain + - github.com/dihemoe/alisten/internal/lib + - github.com/dihemoe/alisten/internal/app + - golang.org/x/tools/txtar + porttest: + files: + - "!$test" + deny: + - pkg: github.com/dihemoe/alisten/internal/app/port/porttest + desc: 端口的内存实现只供测试使用 + lib: + files: + - "**/internal/lib/**" + deny: + - pkg: github.com/dihemoe/alisten/internal/domain + desc: lib 与业务无关,不能依赖 domain + - pkg: github.com/dihemoe/alisten/internal/app + desc: lib 与业务无关,不能依赖 app + - pkg: github.com/dihemoe/alisten/internal/adapter + desc: lib 与业务无关,不能依赖 adapter + - pkg: github.com/dihemoe/alisten/internal/bootstrap + desc: lib 与业务无关,不能依赖 bootstrap + - pkg: github.com/dihemoe/alisten/protocol + desc: protocol 只能被 internal/adapter/in 使用 + # app 通过 logctx 记录日志的关联字段;logctx 只依赖标准库,app 才不会经由它依赖 OpenTelemetry。 + logctx: + list-mode: strict + files: + - "**/internal/lib/logctx/**" + allow: + - $gostd + adapter-in: + files: + - "**/internal/adapter/in/**" + deny: + - pkg: github.com/dihemoe/alisten/internal/adapter + desc: 只有 bootstrap 可以 import adapter + - pkg: github.com/dihemoe/alisten/internal/bootstrap + desc: adapter 不能依赖 bootstrap + adapter-out: + list-mode: lax + files: + - "**/internal/adapter/out/**" + # sqlc 把查询代码生成在 postgres 适配器的 internal 目录下;lax 模式让这个更长的 allow 前缀优先于 deny。 + allow: + - github.com/dihemoe/alisten/internal/adapter/out/postgres/internal/query + deny: + - pkg: github.com/dihemoe/alisten/internal/adapter + desc: 只有 bootstrap 可以 import adapter + - pkg: github.com/dihemoe/alisten/internal/bootstrap + desc: adapter 不能依赖 bootstrap + - pkg: github.com/dihemoe/alisten/protocol + desc: protocol 只能被 internal/adapter/in 使用 + bootstrap: + files: + - "**/internal/bootstrap/**" + deny: + - pkg: github.com/dihemoe/alisten/protocol + desc: protocol 只能被 internal/adapter/in 使用 + cmd: + files: + - "**/cmd/**" + deny: + - pkg: github.com/dihemoe/alisten/internal/adapter + desc: 只有 bootstrap 可以 import adapter + - pkg: github.com/dihemoe/alisten/protocol + desc: protocol 只能被 internal/adapter/in 使用 + protocol: + list-mode: strict + files: + - "**/protocol/**" + allow: + - $gostd + forbidigo: + analyze-types: true + forbid: + - pattern: ^time\.(Now|Since|Until)$ + msg: domain 不读系统时钟,时间作为输入传入 + +formatters: + enable: + - gofmt + - goimports + settings: + goimports: + local-prefixes: + - github.com/dihemoe/alisten diff --git a/web/eslint.config.mjs b/web/eslint.config.mjs new file mode 100644 index 0000000..e95d746 --- /dev/null +++ b/web/eslint.config.mjs @@ -0,0 +1,59 @@ +import antfu from '@antfu/eslint-config' + +// 一个文件匹配到多个设置 no-restricted-imports 的配置对象时,只有最后一个生效:features 因此要带上这一条;core 和 shared 禁止引用 features 的任何路径,已经包含它。 +const featureInternals = { + pattern: /^(?:@\/|\.\.?\/(?:.*\/)?)features\/[^/]+\/[^/]/i, + message: '功能之间只能通过对方的 index.ts 引用。', +} + +const appImports = { + pattern: /^(?:@\/|\.\.\/(?:.*\/)?)app(?:\/|$)/i, + message: 'features、core 和 shared 不能依赖 app。', +} + +// no-restricted-imports 不检查 import(),动态导入由 no-restricted-syntax 用同一个正则检查。 +// no-restricted-syntax 同样只有最后一个配置对象生效,所以要带上 @antfu/eslint-config 设置的两项,升级 antfu 时要与它保持一致。 +function restrictImports(...restrictions) { + return { + 'no-restricted-imports': ['error', { patterns: restrictions.map(({ pattern, message }) => ({ regex: pattern.source, message })) }], + 'no-restricted-syntax': ['error', 'TSEnumDeclaration[const=true]', 'TSExportAssignment', ...restrictions.map(({ pattern, message }) => ({ selector: `ImportExpression[source.value=${pattern}]`, message }))], + } +} + +export default antfu( + { + react: true, + // lint:fix 会改写生成物,之后 protocolgen -check 就会失败。 + ignores: ['src/**/*.gen.ts'], + }, + { + files: ['src/**/*.{ts,tsx}'], + rules: restrictImports(featureInternals), + }, + { + files: ['src/features/**/*.{ts,tsx}'], + rules: restrictImports(featureInternals, { + pattern: /^\.\.\//, + message: '功能内部使用相对路径,功能之外的模块用 @/ 引用。', + }, appImports), + }, + { + files: ['src/core/**/*.{ts,tsx}'], + rules: restrictImports({ + pattern: /^(?:@\/|\.\.\/(?:.*\/)?)features(?:\/|$)/i, + message: 'core 不能依赖 features。', + }, appImports), + }, + { + files: ['src/shared/**/*.{ts,tsx}'], + rules: restrictImports({ + pattern: /^(?:@\/|\.\.\/(?:.*\/)?)(?:core|features)(?:\/|$)/i, + message: 'shared 不能依赖 core 和 features。', + }, appImports), + }, + { + // @antfu/eslint-config 对声明文件关闭了 no-restricted-syntax,上面的配置对象会把它重新打开。 + files: ['**/*.d.?([cm])ts'], + rules: { 'no-restricted-syntax': 'off' }, + }, +)