detached 16b1719

#5 ci(release): ermite releases with AUR bin and git packages

state
merged · 16b1719 · fast-forward
branch
ci/release-pipeline → main
opened
bnema ·
updated

Release and check CI on ermite:

  • ci.yml: golangci-lint, vet, race tests, build (main + MRs, reusable)
  • release.yml: tag v* -> checks -> GoReleaser (linux amd64/arm64) on ermite, bnetctl-bin to AUR, bnetctl-git to AUR (Arch container)
  • github-release.yml: link-only GitHub release (disabled on ermite)

Checks: goreleaser check, snapshot, actionlint, shellcheck, publish.sh dry run.

CommitSubjectAuthorDateSignatureStatus
16b1719ci(release): gate publishing on checks and harden AUR buildsbrice GPG Verified
11bef0aci(release): publish bnetctl-git to AURbrice GPG Verified
03f21c3ci(check): lint vet race-test and build on pushes and pull requestsbrice GPG Verified
5a1f67bci(release): publish ermite binaries and AUR package with GitHub link mirrorbrice GPG Verified
5fceeb7fix(tests): make Wine process fixtures independent of system coreutilsbrice GPG Verified

Checks 9

7 files · +412 -35
9/9
A .github/workflows/ci.yml +45 -0
@@ -0,0 +1,45 @@
1+name: ci
2+
3+on:
4+ push:
5+ branches: [main]
6+ pull_request:
7+ workflow_call:
8+
9+concurrency:
10+ group: ci-${{ github.workflow }}-${{ github.ref }}
11+ cancel-in-progress: true
12+
13+permissions:
14+ contents: read
15+
16+jobs:
17+ lint:
18+ runs-on: ubuntu-latest
19+ timeout-minutes: 15
20+ steps:
21+ - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
22+ with:
23+ persist-credentials: false
24+ - uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
25+ with:
26+ go-version-file: go.mod
27+ - uses: golangci/golangci-lint-action@ba0d7d2ec06a0ea1cb5fa41b2e4a3ab91d21278a # v9
28+ with:
29+ version: v2.15.0
30+ test:
31+ runs-on: ubuntu-latest
32+ timeout-minutes: 15
33+ steps:
34+ - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
35+ with:
36+ persist-credentials: false
37+ - uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
38+ with:
39+ go-version-file: go.mod
40+ - name: Vet
41+ run: go vet ./...
42+ - name: Test
43+ run: go test -race ./...
44+ - name: Build
45+ run: go build -trimpath ./...
@@ -0,0 +1,45 @@
1+name: ci
2+
3+on:
4+ push:
5+ branches: [main]
6+ pull_request:
7+ workflow_call:
8+
9+concurrency:
10+ group: ci-${{ github.workflow }}-${{ github.ref }}
11+ cancel-in-progress: true
12+
13+permissions:
14+ contents: read
15+
16+jobs:
17+ lint:
18+ runs-on: ubuntu-latest
19+ timeout-minutes: 15
20+ steps:
21+ - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
22+ with:
23+ persist-credentials: false
24+ - uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
25+ with:
26+ go-version-file: go.mod
27+ - uses: golangci/golangci-lint-action@ba0d7d2ec06a0ea1cb5fa41b2e4a3ab91d21278a # v9
28+ with:
29+ version: v2.15.0
30+ test:
31+ runs-on: ubuntu-latest
32+ timeout-minutes: 15
33+ steps:
34+ - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
35+ with:
36+ persist-credentials: false
37+ - uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
38+ with:
39+ go-version-file: go.mod
40+ - name: Vet
41+ run: go vet ./...
42+ - name: Test
43+ run: go test -race ./...
44+ - name: Build
45+ run: go build -trimpath ./...
A .github/workflows/github-release.yml +26 -0
@@ -0,0 +1,26 @@
1+# Publishes a link-only GitHub release on the mirror when a tag arrives: the
2+# notes of the ermite release and links to its files on git.bnema.dev. Release
3+# files are only hosted on ermite. The job is skipped on ermite.
4+name: GitHub release
5+
6+on:
7+ push:
8+ tags: ['v*']
9+ workflow_dispatch:
10+ inputs:
11+ tag:
12+ description: 'Tag to mirror'
13+ required: true
14+ type: string
15+
16+permissions:
17+ contents: read
18+
19+jobs:
20+ release:
21+ if: github.server_url == 'https://github.com'
22+ permissions:
23+ contents: write
24+ uses: bnema/gh-actions/.github/workflows/ermite-release-mirror.yml@main
25+ with:
26+ tag: ${{ inputs.tag }}
@@ -0,0 +1,26 @@
1+# Publishes a link-only GitHub release on the mirror when a tag arrives: the
2+# notes of the ermite release and links to its files on git.bnema.dev. Release
3+# files are only hosted on ermite. The job is skipped on ermite.
4+name: GitHub release
5+
6+on:
7+ push:
8+ tags: ['v*']
9+ workflow_dispatch:
10+ inputs:
11+ tag:
12+ description: 'Tag to mirror'
13+ required: true
14+ type: string
15+
16+permissions:
17+ contents: read
18+
19+jobs:
20+ release:
21+ if: github.server_url == 'https://github.com'
22+ permissions:
23+ contents: write
24+ uses: bnema/gh-actions/.github/workflows/ermite-release-mirror.yml@main
25+ with:
26+ tag: ${{ inputs.tag }}
A .github/workflows/release.yml +74 -0
@@ -0,0 +1,74 @@
1+name: release
2+
3+on:
4+ push:
5+ tags: ['v*']
6+
7+# Default to no token permissions; release alone needs release writes.
8+permissions: {}
9+
10+concurrency:
11+ group: release-${{ github.ref }}
12+ cancel-in-progress: false
13+
14+jobs:
15+ check:
16+ if: github.server_url != 'https://github.com'
17+ permissions:
18+ contents: read
19+ uses: ./.github/workflows/ci.yml
20+
21+ release:
22+ needs: check
23+ # Releases are published by ermite (git.bnema.dev); the GitHub mirror
24+ # receives the tags but must not publish a second release or AUR push.
25+ if: github.server_url != 'https://github.com'
26+ runs-on: ubuntu-latest
27+ timeout-minutes: 30
28+ permissions:
29+ contents: write
30+ steps:
31+ - uses: actions/checkout@v7
32+ with:
33+ fetch-depth: 0
34+ fetch-tags: true
35+ persist-credentials: false
36+ - uses: actions/setup-go@v7
37+ with:
38+ go-version-file: go.mod
39+ - uses: goreleaser/goreleaser-action@v7
40+ with:
41+ distribution: goreleaser
42+ version: '~> v2'
43+ args: release --clean
44+ env:
45+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
46+ # SSH key for the bnetctl-bin AUR push.
47+ AUR_KEY: ${{ secrets.AUR_KEY }}
48+
49+ aur:
50+ needs: release
51+ if: github.server_url != 'https://github.com' && !contains(github.ref_name, '-')
52+ runs-on: ubuntu-latest
53+ timeout-minutes: 30
54+ permissions:
55+ contents: read
56+ container:
57+ image: archlinux:latest
58+ steps:
59+ - name: Install build tools
60+ run: pacman -Syu --noconfirm --needed base-devel go git openssh sudo
61+ - uses: actions/checkout@v7
62+ with:
63+ persist-credentials: false
64+ - name: Create the build user
65+ run: |
66+ useradd -m builder
67+ echo 'builder ALL=(root) NOPASSWD: /usr/bin/pacman' >/etc/sudoers.d/builder
68+ - name: Build and publish bnetctl-git
69+ env:
70+ AUR_KEY: ${{ secrets.AUR_KEY }}
71+ run: |
72+ chmod -R a+rX "$GITHUB_WORKSPACE"
73+ sudo -u builder --preserve-env=AUR_KEY \
74+ packaging/aur/publish.sh packaging/aur/bnetctl-git/PKGBUILD
@@ -0,0 +1,74 @@
1+name: release
2+
3+on:
4+ push:
5+ tags: ['v*']
6+
7+# Default to no token permissions; release alone needs release writes.
8+permissions: {}
9+
10+concurrency:
11+ group: release-${{ github.ref }}
12+ cancel-in-progress: false
13+
14+jobs:
15+ check:
16+ if: github.server_url != 'https://github.com'
17+ permissions:
18+ contents: read
19+ uses: ./.github/workflows/ci.yml
20+
21+ release:
22+ needs: check
23+ # Releases are published by ermite (git.bnema.dev); the GitHub mirror
24+ # receives the tags but must not publish a second release or AUR push.
25+ if: github.server_url != 'https://github.com'
26+ runs-on: ubuntu-latest
27+ timeout-minutes: 30
28+ permissions:
29+ contents: write
30+ steps:
31+ - uses: actions/checkout@v7
32+ with:
33+ fetch-depth: 0
34+ fetch-tags: true
35+ persist-credentials: false
36+ - uses: actions/setup-go@v7
37+ with:
38+ go-version-file: go.mod
39+ - uses: goreleaser/goreleaser-action@v7
40+ with:
41+ distribution: goreleaser
42+ version: '~> v2'
43+ args: release --clean
44+ env:
45+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
46+ # SSH key for the bnetctl-bin AUR push.
47+ AUR_KEY: ${{ secrets.AUR_KEY }}
48+
49+ aur:
50+ needs: release
51+ if: github.server_url != 'https://github.com' && !contains(github.ref_name, '-')
52+ runs-on: ubuntu-latest
53+ timeout-minutes: 30
54+ permissions:
55+ contents: read
56+ container:
57+ image: archlinux:latest
58+ steps:
59+ - name: Install build tools
60+ run: pacman -Syu --noconfirm --needed base-devel go git openssh sudo
61+ - uses: actions/checkout@v7
62+ with:
63+ persist-credentials: false
64+ - name: Create the build user
65+ run: |
66+ useradd -m builder
67+ echo 'builder ALL=(root) NOPASSWD: /usr/bin/pacman' >/etc/sudoers.d/builder
68+ - name: Build and publish bnetctl-git
69+ env:
70+ AUR_KEY: ${{ secrets.AUR_KEY }}
71+ run: |
72+ chmod -R a+rX "$GITHUB_WORKSPACE"
73+ sudo -u builder --preserve-env=AUR_KEY \
74+ packaging/aur/publish.sh packaging/aur/bnetctl-git/PKGBUILD
A .goreleaser.yaml +124 -0
@@ -0,0 +1,124 @@
1+version: 2
2+
3+project_name: bnetctl
4+
5+# Releases are published on ermite. In CI the runner sets GITHUB_API_URL and
6+# GITHUB_SERVER_URL to an address the job can reach; the public URLs are the
7+# fallback for a local run.
8+github_urls:
9+ api: '{{ or (index .Env "GITHUB_API_URL") "https://git.bnema.dev/-/github/api" }}/'
10+ upload: '{{ or (index .Env "GITHUB_API_URL") "https://git.bnema.dev/-/github/api" }}/uploads/'
11+ download: '{{ or (index .Env "GITHUB_SERVER_URL") "https://git.bnema.dev" }}/'
12+
13+builds:
14+ - id: bnetctl
15+ main: .
16+ binary: bnetctl
17+ env:
18+ - CGO_ENABLED=0
19+ goos:
20+ - linux
21+ goarch:
22+ - amd64
23+ - arm64
24+ flags:
25+ - -trimpath
26+ ldflags:
27+ - -s -w
28+ - -X github.com/bnema/bnetctl/cmd.version={{.Version}}
29+ - -X github.com/bnema/bnetctl/cmd.commit={{.ShortCommit}}
30+
31+archives:
32+ - id: default
33+ name_template: >-
34+ {{ .ProjectName }}_{{ .Os }}_{{ if eq .Arch "amd64" }}x86_64{{ else }}{{ .Arch }}{{ end }}
35+ formats: ['tar.gz']
36+ files:
37+ - LICENSE
38+ - README.md
39+
40+checksum:
41+ name_template: checksums.txt
42+ algorithm: sha256
43+
44+changelog:
45+ sort: asc
46+ use: git
47+ filters:
48+ exclude:
49+ - '^docs(\(.+\))?:'
50+ - '^test(\(.+\))?:'
51+ - '^ci(\(.+\))?:'
52+ - '^chore(\(.+\))?:'
53+ - '^build\(deps.*\):'
54+ - Merge pull request
55+ - Merge branch
56+ groups:
57+ - title: Features
58+ regexp: '^feat(\(.+\))?:'
59+ order: 0
60+ - title: Bug Fixes
61+ regexp: '^fix(\(.+\))?:'
62+ order: 1
63+ - title: Performance
64+ regexp: '^perf(\(.+\))?:'
65+ order: 2
66+ - title: Refactoring
67+ regexp: '^refactor(\(.+\))?:'
68+ order: 3
69+ - title: Other Changes
70+ order: 999
71+
72+release:
73+ github:
74+ owner: bnema
75+ name: bnetctl
76+ draft: false
77+ replace_existing_draft: true
78+ replace_existing_artifacts: true
79+ prerelease: auto
80+ make_latest: true
81+ footer: |
82+ ## Arch Linux
83+ ```sh
84+ yay -S bnetctl-bin
85+ # Or build the latest Git source:
86+ yay -S bnetctl-git
87+ ```
88+
89+git:
90+ tag_sort: -version:refname
91+
92+aurs:
93+ - name: bnetctl-bin
94+ ids: ['default']
95+ homepage: https://git.bnema.dev/bnetctl
96+ # Public URL: github_urls.download is the CI-only relay address.
97+ url_template: 'https://git.bnema.dev/bnetctl/releases/download/{{ .Tag }}/{{ .ArtifactName }}'
98+ description: CLI to install, manage, and run Battle.net on Linux via Wine
99+ maintainers:
100+ - bnema <b at bnema dot dev>
101+ license: MIT
102+ optdepends:
103+ - 'wine-staging: Wine runtime (>= 11.19 for Wayland)'
104+ - 'wine: alternative Wine runtime'
105+ - 'wine-cachyos-opt: fallback Wine runtime'
106+ - 'dxvk-mingw-git: Direct3D 8/9/10/11 translation to Vulkan'
107+ - 'vkd3d-proton-mingw-git: Direct3D 12 translation to Vulkan'
108+ - 'icoutils: Battle.net icon for the .desktop entry'
109+ commit_author:
110+ name: bnema
111+ email: b@bnema.dev
112+ private_key: '{{ .Env.AUR_KEY }}'
113+ git_url: ssh://aur@aur.archlinux.org/bnetctl-bin.git
114+ # auto: prerelease tags (v1.2.3-rc.1) are not pushed to AUR.
115+ skip_upload: auto
116+ provides:
117+ - bnetctl
118+ conflicts:
119+ - bnetctl
120+ - bnetctl-git
121+ package: |-
122+ install -Dm755 "./bnetctl" "${pkgdir}/usr/bin/bnetctl"
123+ install -Dm644 "./LICENSE" "${pkgdir}/usr/share/licenses/bnetctl-bin/LICENSE"
124+ commit_msg_template: 'Update to {{ .Tag }}'
@@ -0,0 +1,124 @@
1+version: 2
2+
3+project_name: bnetctl
4+
5+# Releases are published on ermite. In CI the runner sets GITHUB_API_URL and
6+# GITHUB_SERVER_URL to an address the job can reach; the public URLs are the
7+# fallback for a local run.
8+github_urls:
9+ api: '{{ or (index .Env "GITHUB_API_URL") "https://git.bnema.dev/-/github/api" }}/'
10+ upload: '{{ or (index .Env "GITHUB_API_URL") "https://git.bnema.dev/-/github/api" }}/uploads/'
11+ download: '{{ or (index .Env "GITHUB_SERVER_URL") "https://git.bnema.dev" }}/'
12+
13+builds:
14+ - id: bnetctl
15+ main: .
16+ binary: bnetctl
17+ env:
18+ - CGO_ENABLED=0
19+ goos:
20+ - linux
21+ goarch:
22+ - amd64
23+ - arm64
24+ flags:
25+ - -trimpath
26+ ldflags:
27+ - -s -w
28+ - -X github.com/bnema/bnetctl/cmd.version={{.Version}}
29+ - -X github.com/bnema/bnetctl/cmd.commit={{.ShortCommit}}
30+
31+archives:
32+ - id: default
33+ name_template: >-
34+ {{ .ProjectName }}_{{ .Os }}_{{ if eq .Arch "amd64" }}x86_64{{ else }}{{ .Arch }}{{ end }}
35+ formats: ['tar.gz']
36+ files:
37+ - LICENSE
38+ - README.md
39+
40+checksum:
41+ name_template: checksums.txt
42+ algorithm: sha256
43+
44+changelog:
45+ sort: asc
46+ use: git
47+ filters:
48+ exclude:
49+ - '^docs(\(.+\))?:'
50+ - '^test(\(.+\))?:'
51+ - '^ci(\(.+\))?:'
52+ - '^chore(\(.+\))?:'
53+ - '^build\(deps.*\):'
54+ - Merge pull request
55+ - Merge branch
56+ groups:
57+ - title: Features
58+ regexp: '^feat(\(.+\))?:'
59+ order: 0
60+ - title: Bug Fixes
61+ regexp: '^fix(\(.+\))?:'
62+ order: 1
63+ - title: Performance
64+ regexp: '^perf(\(.+\))?:'
65+ order: 2
66+ - title: Refactoring
67+ regexp: '^refactor(\(.+\))?:'
68+ order: 3
69+ - title: Other Changes
70+ order: 999
71+
72+release:
73+ github:
74+ owner: bnema
75+ name: bnetctl
76+ draft: false
77+ replace_existing_draft: true
78+ replace_existing_artifacts: true
79+ prerelease: auto
80+ make_latest: true
81+ footer: |
82+ ## Arch Linux
83+ ```sh
84+ yay -S bnetctl-bin
85+ # Or build the latest Git source:
86+ yay -S bnetctl-git
87+ ```
88+
89+git:
90+ tag_sort: -version:refname
91+
92+aurs:
93+ - name: bnetctl-bin
94+ ids: ['default']
95+ homepage: https://git.bnema.dev/bnetctl
96+ # Public URL: github_urls.download is the CI-only relay address.
97+ url_template: 'https://git.bnema.dev/bnetctl/releases/download/{{ .Tag }}/{{ .ArtifactName }}'
98+ description: CLI to install, manage, and run Battle.net on Linux via Wine
99+ maintainers:
100+ - bnema <b at bnema dot dev>
101+ license: MIT
102+ optdepends:
103+ - 'wine-staging: Wine runtime (>= 11.19 for Wayland)'
104+ - 'wine: alternative Wine runtime'
105+ - 'wine-cachyos-opt: fallback Wine runtime'
106+ - 'dxvk-mingw-git: Direct3D 8/9/10/11 translation to Vulkan'
107+ - 'vkd3d-proton-mingw-git: Direct3D 12 translation to Vulkan'
108+ - 'icoutils: Battle.net icon for the .desktop entry'
109+ commit_author:
110+ name: bnema
111+ email: b@bnema.dev
112+ private_key: '{{ .Env.AUR_KEY }}'
113+ git_url: ssh://aur@aur.archlinux.org/bnetctl-bin.git
114+ # auto: prerelease tags (v1.2.3-rc.1) are not pushed to AUR.
115+ skip_upload: auto
116+ provides:
117+ - bnetctl
118+ conflicts:
119+ - bnetctl
120+ - bnetctl-git
121+ package: |-
122+ install -Dm755 "./bnetctl" "${pkgdir}/usr/bin/bnetctl"
123+ install -Dm644 "./LICENSE" "${pkgdir}/usr/share/licenses/bnetctl-bin/LICENSE"
124+ commit_msg_template: 'Update to {{ .Tag }}'
M internal/adapters/wine/runtime_test.go +56 -35
@@ -1,6 +1,7 @@
11 package wine
22
33 import (
4+ "bufio"
45 "fmt"
56 "io"
67 "os"
@@ -241,45 +242,67 @@ func winePrefixPath(prefixPath string) string {
241242 return filepath.Join(prefixPath, "pfx")
242243 }
243244
244-// startFakeWineProcess starts a long-running process that the prefix scan accepts as
245-// part of a wine session, either through wine's environment (env) or through a
246-// Windows executable name. A name other than "sleep" runs a copy of the sleep
247-// binary, so /proc/<pid>/comm matches it.
248-func startFakeWineProcess(t *testing.T, name string, env ...string) *exec.Cmd {
249- t.Helper()
245+// TestWineProcessHelper runs only in a child test binary. Unlike a renamed
246+// system sleep, this also works with multicall coreutils that reject aliases.
247+func TestWineProcessHelper(t *testing.T) {
248+ name := os.Getenv("BNETCTL_TEST_PROCESS_NAME")
249+ if name == "" {
250+ return
251+ }
252+ if err := os.WriteFile("/proc/self/comm", []byte(name), 0600); err != nil {
253+ t.Fatal(err)
254+ }
255+ fmt.Println("ready")
256+ for {
257+ time.Sleep(time.Hour)
258+ }
259+}
250260
261+type fakeWineProcess struct {
262+ *exec.Cmd
263+ done chan struct{}
264+ exitErr error
265+}
266+
267+// startFakeWineProcess waits until comm has been set before allowing a scan.
268+// Every child has exactly one Wait, including clients killed by stub Wine.
269+func startFakeWineProcess(t *testing.T, name string, env ...string) *fakeWineProcess {
270+ t.Helper()
251271 if len(name) > 15 {
252- t.Fatalf("process name %q exceeds the 15 bytes the kernel keeps in comm", name)
272+ t.Fatalf("process name %q exceeds comm limit", name)
253273 }
254-
255- sleep, err := exec.LookPath("sleep")
274+ exe, err := os.Executable()
256275 if err != nil {
257- t.Skipf("sleep is unavailable: %v", err)
276+ t.Fatal(err)
258277 }
259-
260- exe := sleep
261- if name != filepath.Base(sleep) {
262- data, err := os.ReadFile(sleep)
263- if err != nil {
264- t.Fatal(err)
265- }
266- exe = filepath.Join(t.TempDir(), name)
267- if err := os.WriteFile(exe, data, 0o755); err != nil {
268- t.Fatal(err)
269- }
278+ cmd := exec.Command(exe, "-test.run=^TestWineProcessHelper$")
279+ cmd.Env = append(os.Environ(), append(env, "BNETCTL_TEST_PROCESS_NAME="+name)...)
280+ stdout, err := cmd.StdoutPipe()
281+ if err != nil {
282+ t.Fatal(err)
270283 }
271-
272- cmd := exec.Command(exe, "60")
273- cmd.Env = append(os.Environ(), env...)
284+ cmd.Stderr = os.Stderr
274285 if err := cmd.Start(); err != nil {
275- t.Fatalf("start %s: %v", name, err)
286+ t.Fatal(err)
287+ }
288+ child := &fakeWineProcess{Cmd: cmd, done: make(chan struct{})}
289+ go func() { child.exitErr = cmd.Wait(); close(child.done) }()
290+ t.Cleanup(func() { _ = cmd.Process.Kill(); <-child.done })
291+ ready := make(chan string, 1)
292+ go func() { line, _ := bufio.NewReader(stdout).ReadString('\n'); ready <- line }()
293+ select {
294+ case line := <-ready:
295+ if line != "ready\n" {
296+ t.Fatalf("child did not become ready: %q", line)
297+ }
298+ case <-time.After(5 * time.Second):
299+ t.Fatal("child readiness timed out")
276300 }
277- t.Cleanup(func() { _ = cmd.Process.Kill() })
278- return cmd
301+ return child
279302 }
280303
281304 // requireStopped fails the test unless the result reports exactly the given process.
282-func requireStopped(t *testing.T, result domain.StopResult, cmd *exec.Cmd, name string) {
305+func requireStopped(t *testing.T, result domain.StopResult, cmd *fakeWineProcess, name string) {
283306 t.Helper()
284307
285308 if result.Wineserver != nil || len(result.Session) != 0 {
@@ -392,15 +415,13 @@ func TestGracefulKillReportsWineserverWithoutSnapshot(t *testing.T) {
392415 }
393416
394417 // waitForExit fails the test unless the process exits on its own.
395-func waitForExit(t *testing.T, cmd *exec.Cmd) {
418+func waitForExit(t *testing.T, cmd *fakeWineProcess) {
396419 t.Helper()
397420
398- done := make(chan error, 1)
399- go func() { done <- cmd.Wait() }()
400421 select {
401- case err := <-done:
402- if err != nil {
403- t.Logf("process exited: %v", err)
422+ case <-cmd.done:
423+ if cmd.exitErr != nil {
424+ t.Logf("process exited: %v", cmd.exitErr)
404425 }
405426 case <-time.After(2 * time.Second):
406427 t.Fatalf("process %d survived the kill", cmd.Process.Pid)
@@ -408,7 +429,7 @@ func waitForExit(t *testing.T, cmd *exec.Cmd) {
408429 }
409430
410431 // requireAlive fails the test when a process that had to be left alone is gone.
411-func requireAlive(t *testing.T, cmd *exec.Cmd) {
432+func requireAlive(t *testing.T, cmd *fakeWineProcess) {
412433 t.Helper()
413434
414435 status, err := os.ReadFile(fmt.Sprintf("/proc/%d/status", cmd.Process.Pid))
@@ -1,6 +1,7 @@
1 package wine1 package wine
2 2
3 import (3 import (
4+ "bufio"
4 "fmt"5 "fmt"
5 "io"6 "io"
6 "os"7 "os"
@@ -241,45 +242,67 @@ func winePrefixPath(prefixPath string) string {
241 return filepath.Join(prefixPath, "pfx")242 return filepath.Join(prefixPath, "pfx")
242 }243 }
243 244
244-// startFakeWineProcess starts a long-running process that the prefix scan accepts as245+// TestWineProcessHelper runs only in a child test binary. Unlike a renamed
245-// part of a wine session, either through wine's environment (env) or through a246+// system sleep, this also works with multicall coreutils that reject aliases.
246-// Windows executable name. A name other than "sleep" runs a copy of the sleep247+func TestWineProcessHelper(t *testing.T) {
247-// binary, so /proc/<pid>/comm matches it.248+ name := os.Getenv("BNETCTL_TEST_PROCESS_NAME")
248-func startFakeWineProcess(t *testing.T, name string, env ...string) *exec.Cmd {249+ if name == "" {
249- t.Helper()250+ return
251+ }
252+ if err := os.WriteFile("/proc/self/comm", []byte(name), 0600); err != nil {
253+ t.Fatal(err)
254+ }
255+ fmt.Println("ready")
256+ for {
257+ time.Sleep(time.Hour)
258+ }
259+}
250 260
261+type fakeWineProcess struct {
262+ *exec.Cmd
263+ done chan struct{}
264+ exitErr error
265+}
266+
267+// startFakeWineProcess waits until comm has been set before allowing a scan.
268+// Every child has exactly one Wait, including clients killed by stub Wine.
269+func startFakeWineProcess(t *testing.T, name string, env ...string) *fakeWineProcess {
270+ t.Helper()
251 if len(name) > 15 {271 if len(name) > 15 {
252- t.Fatalf("process name %q exceeds the 15 bytes the kernel keeps in comm", name)272+ t.Fatalf("process name %q exceeds comm limit", name)
253 }273 }
254-274+ exe, err := os.Executable()
255- sleep, err := exec.LookPath("sleep")
256 if err != nil {275 if err != nil {
257- t.Skipf("sleep is unavailable: %v", err)276+ t.Fatal(err)
258 }277 }
259-278+ cmd := exec.Command(exe, "-test.run=^TestWineProcessHelper$")
260- exe := sleep279+ cmd.Env = append(os.Environ(), append(env, "BNETCTL_TEST_PROCESS_NAME="+name)...)
261- if name != filepath.Base(sleep) {280+ stdout, err := cmd.StdoutPipe()
262- data, err := os.ReadFile(sleep)281+ if err != nil {
263- if err != nil {282+ t.Fatal(err)
264- t.Fatal(err)
265- }
266- exe = filepath.Join(t.TempDir(), name)
267- if err := os.WriteFile(exe, data, 0o755); err != nil {
268- t.Fatal(err)
269- }
270 }283 }
271-284+ cmd.Stderr = os.Stderr
272- cmd := exec.Command(exe, "60")
273- cmd.Env = append(os.Environ(), env...)
274 if err := cmd.Start(); err != nil {285 if err := cmd.Start(); err != nil {
275- t.Fatalf("start %s: %v", name, err)286+ t.Fatal(err)
287+ }
288+ child := &fakeWineProcess{Cmd: cmd, done: make(chan struct{})}
289+ go func() { child.exitErr = cmd.Wait(); close(child.done) }()
290+ t.Cleanup(func() { _ = cmd.Process.Kill(); <-child.done })
291+ ready := make(chan string, 1)
292+ go func() { line, _ := bufio.NewReader(stdout).ReadString('\n'); ready <- line }()
293+ select {
294+ case line := <-ready:
295+ if line != "ready\n" {
296+ t.Fatalf("child did not become ready: %q", line)
297+ }
298+ case <-time.After(5 * time.Second):
299+ t.Fatal("child readiness timed out")
276 }300 }
277- t.Cleanup(func() { _ = cmd.Process.Kill() })301+ return child
278- return cmd
279 }302 }
280 303
281 // requireStopped fails the test unless the result reports exactly the given process.304 // requireStopped fails the test unless the result reports exactly the given process.
282-func requireStopped(t *testing.T, result domain.StopResult, cmd *exec.Cmd, name string) {305+func requireStopped(t *testing.T, result domain.StopResult, cmd *fakeWineProcess, name string) {
283 t.Helper()306 t.Helper()
284 307
285 if result.Wineserver != nil || len(result.Session) != 0 {308 if result.Wineserver != nil || len(result.Session) != 0 {
@@ -392,15 +415,13 @@ func TestGracefulKillReportsWineserverWithoutSnapshot(t *testing.T) {
392 }415 }
393 416
394 // waitForExit fails the test unless the process exits on its own.417 // waitForExit fails the test unless the process exits on its own.
395-func waitForExit(t *testing.T, cmd *exec.Cmd) {418+func waitForExit(t *testing.T, cmd *fakeWineProcess) {
396 t.Helper()419 t.Helper()
397 420
398- done := make(chan error, 1)
399- go func() { done <- cmd.Wait() }()
400 select {421 select {
401- case err := <-done:422+ case <-cmd.done:
402- if err != nil {423+ if cmd.exitErr != nil {
403- t.Logf("process exited: %v", err)424+ t.Logf("process exited: %v", cmd.exitErr)
404 }425 }
405 case <-time.After(2 * time.Second):426 case <-time.After(2 * time.Second):
406 t.Fatalf("process %d survived the kill", cmd.Process.Pid)427 t.Fatalf("process %d survived the kill", cmd.Process.Pid)
@@ -408,7 +429,7 @@ func waitForExit(t *testing.T, cmd *exec.Cmd) {
408 }429 }
409 430
410 // requireAlive fails the test when a process that had to be left alone is gone.431 // requireAlive fails the test when a process that had to be left alone is gone.
411-func requireAlive(t *testing.T, cmd *exec.Cmd) {432+func requireAlive(t *testing.T, cmd *fakeWineProcess) {
412 t.Helper()433 t.Helper()
413 434
414 status, err := os.ReadFile(fmt.Sprintf("/proc/%d/status", cmd.Process.Pid))435 status, err := os.ReadFile(fmt.Sprintf("/proc/%d/status", cmd.Process.Pid))
A packaging/aur/bnetctl-git/PKGBUILD +49 -0
@@ -0,0 +1,49 @@
1+# Maintainer: bnema <b at bnema dot dev>
2+pkgname=bnetctl-git
3+pkgver=1.0.0
4+pkgrel=1
5+pkgdesc='CLI to install, manage, and run Battle.net on Linux via Wine (git version)'
6+arch=('x86_64' 'aarch64')
7+url='https://git.bnema.dev/bnetctl'
8+license=('MIT')
9+makedepends=('go>=2:1.26.0' 'git')
10+optdepends=('wine-staging: Wine runtime (>= 11.19 for Wayland)'
11+ 'wine: alternative Wine runtime'
12+ 'wine-cachyos-opt: fallback Wine runtime'
13+ 'dxvk-mingw-git: Direct3D 8/9/10/11 translation to Vulkan'
14+ 'vkd3d-proton-mingw-git: Direct3D 12 translation to Vulkan'
15+ 'icoutils: Battle.net icon for the .desktop entry')
16+provides=('bnetctl')
17+conflicts=('bnetctl' 'bnetctl-bin')
18+source=('git+https://git.bnema.dev/bnetctl.git')
19+sha256sums=('SKIP')
20+
21+pkgver() {
22+ cd bnetctl
23+ git describe --long --tags --abbrev=7 --exclude='*-*' | sed 's/^v//;s/\([^-]*-g\)/r\1/;s/-/./g'
24+}
25+
26+prepare() {
27+ cd bnetctl
28+ export GOPATH="$srcdir/gopath" GOTOOLCHAIN=local
29+ export GOFLAGS='-mod=readonly -modcacherw'
30+ go mod download
31+}
32+
33+build() {
34+ cd bnetctl
35+ export GOPATH="$srcdir/gopath" CGO_ENABLED=0 GOTOOLCHAIN=local
36+ export GOFLAGS='-buildmode=pie -trimpath -mod=readonly -modcacherw'
37+ go build -ldflags "-X github.com/bnema/bnetctl/cmd.version=$pkgver -X github.com/bnema/bnetctl/cmd.commit=$(git rev-parse --short HEAD)" -o bnetctl .
38+}
39+
40+check() {
41+ cd bnetctl
42+ ./bnetctl --version
43+}
44+
45+package() {
46+ cd bnetctl
47+ install -Dm755 bnetctl "$pkgdir/usr/bin/bnetctl"
48+ install -Dm644 LICENSE "$pkgdir/usr/share/licenses/$pkgname/LICENSE"
49+}
@@ -0,0 +1,49 @@
1+# Maintainer: bnema <b at bnema dot dev>
2+pkgname=bnetctl-git
3+pkgver=1.0.0
4+pkgrel=1
5+pkgdesc='CLI to install, manage, and run Battle.net on Linux via Wine (git version)'
6+arch=('x86_64' 'aarch64')
7+url='https://git.bnema.dev/bnetctl'
8+license=('MIT')
9+makedepends=('go>=2:1.26.0' 'git')
10+optdepends=('wine-staging: Wine runtime (>= 11.19 for Wayland)'
11+ 'wine: alternative Wine runtime'
12+ 'wine-cachyos-opt: fallback Wine runtime'
13+ 'dxvk-mingw-git: Direct3D 8/9/10/11 translation to Vulkan'
14+ 'vkd3d-proton-mingw-git: Direct3D 12 translation to Vulkan'
15+ 'icoutils: Battle.net icon for the .desktop entry')
16+provides=('bnetctl')
17+conflicts=('bnetctl' 'bnetctl-bin')
18+source=('git+https://git.bnema.dev/bnetctl.git')
19+sha256sums=('SKIP')
20+
21+pkgver() {
22+ cd bnetctl
23+ git describe --long --tags --abbrev=7 --exclude='*-*' | sed 's/^v//;s/\([^-]*-g\)/r\1/;s/-/./g'
24+}
25+
26+prepare() {
27+ cd bnetctl
28+ export GOPATH="$srcdir/gopath" GOTOOLCHAIN=local
29+ export GOFLAGS='-mod=readonly -modcacherw'
30+ go mod download
31+}
32+
33+build() {
34+ cd bnetctl
35+ export GOPATH="$srcdir/gopath" CGO_ENABLED=0 GOTOOLCHAIN=local
36+ export GOFLAGS='-buildmode=pie -trimpath -mod=readonly -modcacherw'
37+ go build -ldflags "-X github.com/bnema/bnetctl/cmd.version=$pkgver -X github.com/bnema/bnetctl/cmd.commit=$(git rev-parse --short HEAD)" -o bnetctl .
38+}
39+
40+check() {
41+ cd bnetctl
42+ ./bnetctl --version
43+}
44+
45+package() {
46+ cd bnetctl
47+ install -Dm755 bnetctl "$pkgdir/usr/bin/bnetctl"
48+ install -Dm644 LICENSE "$pkgdir/usr/share/licenses/$pkgname/LICENSE"
49+}
A packaging/aur/publish.sh +38 -0
@@ -0,0 +1,38 @@
1+#!/usr/bin/env bash
2+# Build and publish bnetctl-git. Run as a non-root user with makepkg available.
3+# DRY_RUN=1 builds and prints metadata without contacting the AUR.
4+set -euo pipefail
5+aur_key=${AUR_KEY:?}
6+unset AUR_KEY
7+work=$(mktemp -d)
8+trap 'rm -rf "$work"' EXIT
9+cp "$1" "$work/PKGBUILD"
10+cd "$work"
11+makepkg --syncdeps --force --noconfirm
12+# pkgver() updates PKGBUILD from the source's latest stable tag.
13+makepkg --printsrcinfo >.SRCINFO
14+pkgver=$(sed -n 's/^\tpkgver = //p' .SRCINFO)
15+if [[ ${DRY_RUN:-} == 1 ]]; then
16+ cat PKGBUILD .SRCINFO
17+ exit 0
18+fi
19+
20+key=$work/aur-key
21+(umask 077 && printf '%s\n' "$aur_key" >"$key")
22+# Pinned AUR host key, as in neferwl; do not trust an unauthenticated keyscan.
23+# SHA256:RFzBCUItH9LZS0cKB5UE6ceAYhBD5C8GeOBip8Z11+4
24+echo 'aur.archlinux.org ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIEuBKrPzbawxA/k2g6NcyV5jmqwJ2s+zpgZGZ7tpLIcN' >"$work/known_hosts"
25+export GIT_SSH_COMMAND="ssh -i $key -o IdentitiesOnly=yes -o StrictHostKeyChecking=yes -o UserKnownHostsFile=$work/known_hosts"
26+git clone ssh://aur@aur.archlinux.org/bnetctl-git.git aur
27+cp PKGBUILD .SRCINFO aur/
28+cd aur
29+git add PKGBUILD .SRCINFO
30+# Works for an unborn HEAD too: staged files differ from the empty tree.
31+if git diff --cached --quiet; then
32+ echo "bnetctl-git $pkgver is already on the AUR"
33+ exit 0
34+fi
35+git -c user.name=bnema -c user.email=b@bnema.dev \
36+ -c gpg.format=ssh -c user.signingkey="$key" \
37+ commit -S -m "Update to $pkgver"
38+git push origin HEAD:master
@@ -0,0 +1,38 @@
1+#!/usr/bin/env bash
2+# Build and publish bnetctl-git. Run as a non-root user with makepkg available.
3+# DRY_RUN=1 builds and prints metadata without contacting the AUR.
4+set -euo pipefail
5+aur_key=${AUR_KEY:?}
6+unset AUR_KEY
7+work=$(mktemp -d)
8+trap 'rm -rf "$work"' EXIT
9+cp "$1" "$work/PKGBUILD"
10+cd "$work"
11+makepkg --syncdeps --force --noconfirm
12+# pkgver() updates PKGBUILD from the source's latest stable tag.
13+makepkg --printsrcinfo >.SRCINFO
14+pkgver=$(sed -n 's/^\tpkgver = //p' .SRCINFO)
15+if [[ ${DRY_RUN:-} == 1 ]]; then
16+ cat PKGBUILD .SRCINFO
17+ exit 0
18+fi
19+
20+key=$work/aur-key
21+(umask 077 && printf '%s\n' "$aur_key" >"$key")
22+# Pinned AUR host key, as in neferwl; do not trust an unauthenticated keyscan.
23+# SHA256:RFzBCUItH9LZS0cKB5UE6ceAYhBD5C8GeOBip8Z11+4
24+echo 'aur.archlinux.org ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIEuBKrPzbawxA/k2g6NcyV5jmqwJ2s+zpgZGZ7tpLIcN' >"$work/known_hosts"
25+export GIT_SSH_COMMAND="ssh -i $key -o IdentitiesOnly=yes -o StrictHostKeyChecking=yes -o UserKnownHostsFile=$work/known_hosts"
26+git clone ssh://aur@aur.archlinux.org/bnetctl-git.git aur
27+cp PKGBUILD .SRCINFO aur/
28+cd aur
29+git add PKGBUILD .SRCINFO
30+# Works for an unborn HEAD too: staged files differ from the empty tree.
31+if git diff --cached --quiet; then
32+ echo "bnetctl-git $pkgver is already on the AUR"
33+ exit 0
34+fi
35+git -c user.name=bnema -c user.email=b@bnema.dev \
36+ -c gpg.format=ssh -c user.signingkey="$key" \
37+ commit -S -m "Update to $pkgver"
38+git push origin HEAD:master

Comments00 unresolved

    no comments yet.

    Keyboard shortcuts

    Single-key shortcuts are on. ctrl k and esc work whatever this says.

    ctrl k :
    go to a section, branch, tag, file or repository
    ?
    this list
    j k
    move the row cursor down / up
    enter
    open the row under the cursor; on Files, a directory opens or closes in place
    ↑ ↓ ← → home end
    in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
    l h
    on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. On pages with a side panel: into the panel (on a merge request, its files) / back to the content; there j k move between its controls
    g g G
    first / last row
    /
    filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
    b
    switch branch or tag
    g s g f g c g b g m g i g r g a
    tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
    g h
    home
    w
    wide mode: use the whole width of the screen, or not (remembered)
    y
    copy a permalink pinned to the commit; in a file, click a line number to select it, shift-click for a range
    d u x
    diff: split / unified / collapse or expand all
    e E
    diff: the whole file in view / every file, with the lines the hunks hide
    n
    merge request: next note in the diff
    l
    merge request: the list of files (j k move, enter opens, esc back)
    M
    merge request: the merge menu; there 1–3 pick the mode, D deletes the branch, enter merges
    R
    merge request: mark the head read
    esc
    close a dialog, leave a field

    Branches 2

    Tags 2

    ↑↓ moveenter openesc close