detached 5ad4636

fix(ci): isolate write token, recover missed releases and reject wayland release candidates

commit
5ad463652b25d1f6ac49d88a0a6d633bacae1138
author
brice · · 2026-10-01 03:58 UTC
parent
8dd5ed7
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
10 files · +397 -73
M .github/workflows/ci.yml +6 -4
@@ -12,16 +12,18 @@ jobs:
1212 test:
1313 runs-on: ubuntu-latest
1414 steps:
15- - uses: actions/checkout@v4
16- - uses: actions/setup-go@v5
15+ - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
16+ with:
17+ persist-credentials: false
18+ - uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
1719 with:
1820 go-version-file: go.mod
1921 - name: Install Wayland libraries
20- run: sudo apt-get install -y libwayland-server0 libwayland-client0 wayland-utils
22+ run: sudo apt-get update && sudo apt-get install -y --no-install-recommends libwayland-server0 libwayland-client0 wayland-utils
2123 - name: Vet
2224 run: go vet ./...
2325 - name: Staticcheck
24- run: go run honnef.co/go/tools/cmd/staticcheck@latest ./...
26+ run: go run honnef.co/go/tools/cmd/staticcheck@2026.2.1 ./...
2527 - name: Test (CGO disabled)
2628 run: CGO_ENABLED=0 go test ./...
2729 - name: Test (race)
@@ -12,16 +12,18 @@ jobs:
12 test:12 test:
13 runs-on: ubuntu-latest13 runs-on: ubuntu-latest
14 steps:14 steps:
15- - uses: actions/checkout@v415+ - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
16- - uses: actions/setup-go@v516+ with:
17+ persist-credentials: false
18+ - uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
17 with:19 with:
18 go-version-file: go.mod20 go-version-file: go.mod
19 - name: Install Wayland libraries21 - name: Install Wayland libraries
20- run: sudo apt-get install -y libwayland-server0 libwayland-client0 wayland-utils22+ run: sudo apt-get update && sudo apt-get install -y --no-install-recommends libwayland-server0 libwayland-client0 wayland-utils
21 - name: Vet23 - name: Vet
22 run: go vet ./...24 run: go vet ./...
23 - name: Staticcheck25 - name: Staticcheck
24- run: go run honnef.co/go/tools/cmd/staticcheck@latest ./...26+ run: go run honnef.co/go/tools/cmd/staticcheck@2026.2.1 ./...
25 - name: Test (CGO disabled)27 - name: Test (CGO disabled)
26 run: CGO_ENABLED=0 go test ./...28 run: CGO_ENABLED=0 go test ./...
27 - name: Test (race)29 - name: Test (race)
M .github/workflows/sync.yml +114 -38
@@ -6,85 +6,161 @@ on:
66 workflow_dispatch:
77
88 permissions:
9- contents: write
9+ contents: read
1010
1111 concurrency:
1212 group: sync
1313 cancel-in-progress: false
1414
1515 jobs:
16- sync:
16+ # prepare runs all third-party code (go modules, staticcheck, tests) with a
17+ # read-only token and hands the result over as a patch.
18+ prepare:
1719 runs-on: ubuntu-latest
20+ permissions:
21+ contents: read
22+ outputs:
23+ changed: ${{ steps.sync.outputs.changed }}
1824 steps:
19- - uses: actions/checkout@v4
25+ - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
2026 with:
21- fetch-depth: 0
22- - uses: actions/setup-go@v5
27+ persist-credentials: false
28+ - uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
2329 with:
2430 go-version-file: go.mod
25- - name: Configure git
26- run: |
27- git config --global --add safe.directory "$GITHUB_WORKSPACE"
28- git config --global core.autocrlf false
2931 - name: Install Wayland libraries
30- run: sudo apt-get install -y libwayland-server0 libwayland-client0 wayland-utils
32+ run: sudo apt-get update && sudo apt-get install -y --no-install-recommends libwayland-server0 libwayland-client0 wayland-utils
3133 - name: Sync upstream protocols
3234 id: sync
3335 run: |
34- go run ./cmd/wlbsync -notes "$RUNNER_TEMP/notes.md"
35- if [ -z "$(git status --porcelain)" ]; then
36- echo "up to date"
37- echo "changed=false" >> "$GITHUB_OUTPUT"
38- else
39- echo "changed=true" >> "$GITHUB_OUTPUT"
40- fi
36+ mkdir -p "$RUNNER_TEMP/out"
37+ result="$(go run ./cmd/wlbsync -notes "$RUNNER_TEMP/out/notes.md")"
38+ echo "$result"
39+ printf '%s\n' "$result" | grep -E '^changed=(true|false)$' >> "$GITHUB_OUTPUT"
4140 - name: Generate
4241 if: steps.sync.outputs.changed == 'true'
4342 run: go generate ./...
4443 - name: Vet
4544 if: steps.sync.outputs.changed == 'true'
4645 run: go vet ./...
46+ - name: Staticcheck
47+ if: steps.sync.outputs.changed == 'true'
48+ run: go run honnef.co/go/tools/cmd/staticcheck@2026.2.1 ./...
4749 - name: Test (CGO disabled)
4850 if: steps.sync.outputs.changed == 'true'
4951 run: CGO_ENABLED=0 go test ./...
5052 - name: Test (race)
5153 if: steps.sync.outputs.changed == 'true'
5254 run: go test -race ./...
53- - name: Compute next version
54- id: version
55- if: steps.sync.outputs.changed == 'true'
55+ - name: Create patch
56+ env:
57+ CHANGED: ${{ steps.sync.outputs.changed }}
5658 run: |
57- latest="$(git tag -l 'v0.*' --sort=-v:refname | grep -E '^v0\.[0-9]+\.[0-9]+$' | sed -n 1p || true)"
58- if [ -z "$latest" ]; then
59- tag="v0.1.0"
60- else
61- minor="${latest#v0.}"
62- minor="${minor%%.*}"
63- tag="v0.$((minor + 1)).0"
59+ : > "$RUNNER_TEMP/out/changes.patch"
60+ if [ "$CHANGED" = 'true' ]; then
61+ git add -A
62+ git diff --cached --binary > "$RUNNER_TEMP/out/changes.patch"
63+ test -s "$RUNNER_TEMP/out/changes.patch"
6464 fi
65- echo "latest tag: ${latest:-none}; next tag: $tag"
66- echo "tag=$tag" >> "$GITHUB_OUTPUT"
65+ - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
66+ with:
67+ name: sync
68+ path: ${{ runner.temp }}/out
69+ if-no-files-found: error
70+ retention-days: 1
71+
72+ # publish has write access and runs no third-party code: it applies the
73+ # patch, commits through the API and cuts the release.
74+ publish:
75+ needs: prepare
76+ if: github.ref == 'refs/heads/main'
77+ runs-on: ubuntu-latest
78+ permissions:
79+ contents: write
80+ steps:
81+ - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
82+ with:
83+ fetch-depth: 0
84+ persist-credentials: false
85+ - uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
86+ with:
87+ go-version-file: go.mod
88+ cache: false
89+ - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
90+ with:
91+ name: sync
92+ path: ${{ runner.temp }}/sync
93+ - name: Apply patch
94+ if: needs.prepare.outputs.changed == 'true'
95+ run: |
96+ test -s "$RUNNER_TEMP/sync/changes.patch"
97+ git apply --index "$RUNNER_TEMP/sync/changes.patch"
6798 - name: Commit via GitHub API
6899 id: commit
69- if: steps.sync.outputs.changed == 'true'
100+ if: needs.prepare.outputs.changed == 'true'
70101 env:
71102 GITHUB_TOKEN: ${{ github.token }}
72103 run: |
73- message="$(printf 'feat(spec): sync upstream protocols\n\n'; head -n 20 "$RUNNER_TEMP/notes.md")"
104+ message="$(printf 'feat(spec): sync upstream protocols\n\n'; head -n 20 "$RUNNER_TEMP/sync/notes.md")"
74105 sha="$(go run ./cmd/wlbcommit -repo "$GITHUB_REPOSITORY" -branch main -message "$message")"
75106 echo "commit: $sha"
76107 echo "sha=$sha" >> "$GITHUB_OUTPUT"
77- - name: Create release
78- if: steps.sync.outputs.changed == 'true'
108+ - name: Release
109+ id: release
110+ if: success()
79111 env:
80112 GH_TOKEN: ${{ github.token }}
81- TAG: ${{ steps.version.outputs.tag }}
82- SHA: ${{ steps.commit.outputs.sha }}
113+ COMMITTED_SHA: ${{ steps.commit.outputs.sha }}
83114 run: |
84- gh release create "$TAG" --target "$SHA" --title "$TAG" --notes-file "$RUNNER_TEMP/notes.md"
115+ set -euo pipefail
116+ if [ -n "$COMMITTED_SHA" ]; then
117+ target="$COMMITTED_SHA"
118+ auth="$(printf 'x-access-token:%s' "$GH_TOKEN" | base64 -w0)"
119+ GIT_CONFIG_COUNT=1 \
120+ GIT_CONFIG_KEY_0="http.https://github.com/.extraheader" \
121+ GIT_CONFIG_VALUE_0="AUTHORIZATION: basic $auth" \
122+ git fetch --no-tags origin main
123+ git cat-file -e "$target^{commit}"
124+ else
125+ target="$(git rev-parse HEAD)"
126+ fi
127+ echo "release target: $target"
128+
129+ # Already released?
130+ if git tag --points-at "$target" | grep -qE '^v0\.[0-9]+\.[0-9]+$'; then
131+ echo "$target is already tagged; nothing to release"
132+ exit 0
133+ fi
134+
135+ latest="$(git tag -l 'v0.*' --sort=-v:refname | grep -E '^v0\.[0-9]+\.[0-9]+$' | sed -n 1p || true)"
136+ if [ -z "$latest" ]; then
137+ # The first release is a maintainer-signed tag; automation only
138+ # continues an existing v0 series.
139+ echo "no v0 tag yet; create the first release manually"
140+ exit 0
141+ else
142+ rc=0
143+ git diff --quiet "$latest" "$target" -- spec/xml client server || rc=$?
144+ case "$rc" in
145+ 0) echo "no changes in spec/xml, client, server since $latest; nothing to release"; exit 0 ;;
146+ 1) ;;
147+ *) echo "git diff failed ($rc)" >&2; exit "$rc" ;;
148+ esac
149+ minor="${latest#v0.}"
150+ minor="${minor%%.*}"
151+ tag="v0.$((minor + 1)).0"
152+ fi
153+ echo "latest tag: ${latest:-none}; next tag: $tag"
154+
155+ if [ -n "$COMMITTED_SHA" ]; then
156+ gh release create "$tag" --target "$target" --title "$tag" --notes-file "$RUNNER_TEMP/sync/notes.md"
157+ else
158+ gh release create "$tag" --target "$target" --title "$tag" --generate-notes
159+ fi
160+ echo "tag=$tag" >> "$GITHUB_OUTPUT"
85161 - name: Warm Go module proxy
86- if: steps.sync.outputs.changed == 'true'
162+ if: steps.release.outputs.tag != ''
87163 env:
88- TAG: ${{ steps.version.outputs.tag }}
164+ TAG: ${{ steps.release.outputs.tag }}
89165 run: |
90166 curl -fsS "https://proxy.golang.org/github.com/bnema/go-wayland-bindings/@v/$TAG.info" || true
@@ -6,85 +6,161 @@ on:
6 workflow_dispatch:6 workflow_dispatch:
7 7
8 permissions:8 permissions:
9- contents: write9+ contents: read
10 10
11 concurrency:11 concurrency:
12 group: sync12 group: sync
13 cancel-in-progress: false13 cancel-in-progress: false
14 14
15 jobs:15 jobs:
16- sync:16+ # prepare runs all third-party code (go modules, staticcheck, tests) with a
17+ # read-only token and hands the result over as a patch.
18+ prepare:
17 runs-on: ubuntu-latest19 runs-on: ubuntu-latest
20+ permissions:
21+ contents: read
22+ outputs:
23+ changed: ${{ steps.sync.outputs.changed }}
18 steps:24 steps:
19- - uses: actions/checkout@v425+ - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
20 with:26 with:
21- fetch-depth: 027+ persist-credentials: false
22- - uses: actions/setup-go@v528+ - uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
23 with:29 with:
24 go-version-file: go.mod30 go-version-file: go.mod
25- - name: Configure git
26- run: |
27- git config --global --add safe.directory "$GITHUB_WORKSPACE"
28- git config --global core.autocrlf false
29 - name: Install Wayland libraries31 - name: Install Wayland libraries
30- run: sudo apt-get install -y libwayland-server0 libwayland-client0 wayland-utils32+ run: sudo apt-get update && sudo apt-get install -y --no-install-recommends libwayland-server0 libwayland-client0 wayland-utils
31 - name: Sync upstream protocols33 - name: Sync upstream protocols
32 id: sync34 id: sync
33 run: |35 run: |
34- go run ./cmd/wlbsync -notes "$RUNNER_TEMP/notes.md"36+ mkdir -p "$RUNNER_TEMP/out"
35- if [ -z "$(git status --porcelain)" ]; then37+ result="$(go run ./cmd/wlbsync -notes "$RUNNER_TEMP/out/notes.md")"
36- echo "up to date"38+ echo "$result"
37- echo "changed=false" >> "$GITHUB_OUTPUT"39+ printf '%s\n' "$result" | grep -E '^changed=(true|false)$' >> "$GITHUB_OUTPUT"
38- else
39- echo "changed=true" >> "$GITHUB_OUTPUT"
40- fi
41 - name: Generate40 - name: Generate
42 if: steps.sync.outputs.changed == 'true'41 if: steps.sync.outputs.changed == 'true'
43 run: go generate ./...42 run: go generate ./...
44 - name: Vet43 - name: Vet
45 if: steps.sync.outputs.changed == 'true'44 if: steps.sync.outputs.changed == 'true'
46 run: go vet ./...45 run: go vet ./...
46+ - name: Staticcheck
47+ if: steps.sync.outputs.changed == 'true'
48+ run: go run honnef.co/go/tools/cmd/staticcheck@2026.2.1 ./...
47 - name: Test (CGO disabled)49 - name: Test (CGO disabled)
48 if: steps.sync.outputs.changed == 'true'50 if: steps.sync.outputs.changed == 'true'
49 run: CGO_ENABLED=0 go test ./...51 run: CGO_ENABLED=0 go test ./...
50 - name: Test (race)52 - name: Test (race)
51 if: steps.sync.outputs.changed == 'true'53 if: steps.sync.outputs.changed == 'true'
52 run: go test -race ./...54 run: go test -race ./...
53- - name: Compute next version55+ - name: Create patch
54- id: version56+ env:
55- if: steps.sync.outputs.changed == 'true'57+ CHANGED: ${{ steps.sync.outputs.changed }}
56 run: |58 run: |
57- latest="$(git tag -l 'v0.*' --sort=-v:refname | grep -E '^v0\.[0-9]+\.[0-9]+$' | sed -n 1p || true)"59+ : > "$RUNNER_TEMP/out/changes.patch"
58- if [ -z "$latest" ]; then60+ if [ "$CHANGED" = 'true' ]; then
59- tag="v0.1.0"61+ git add -A
60- else62+ git diff --cached --binary > "$RUNNER_TEMP/out/changes.patch"
61- minor="${latest#v0.}"63+ test -s "$RUNNER_TEMP/out/changes.patch"
62- minor="${minor%%.*}"
63- tag="v0.$((minor + 1)).0"
64 fi64 fi
65- echo "latest tag: ${latest:-none}; next tag: $tag"65+ - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
66- echo "tag=$tag" >> "$GITHUB_OUTPUT"66+ with:
67+ name: sync
68+ path: ${{ runner.temp }}/out
69+ if-no-files-found: error
70+ retention-days: 1
71+
72+ # publish has write access and runs no third-party code: it applies the
73+ # patch, commits through the API and cuts the release.
74+ publish:
75+ needs: prepare
76+ if: github.ref == 'refs/heads/main'
77+ runs-on: ubuntu-latest
78+ permissions:
79+ contents: write
80+ steps:
81+ - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
82+ with:
83+ fetch-depth: 0
84+ persist-credentials: false
85+ - uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
86+ with:
87+ go-version-file: go.mod
88+ cache: false
89+ - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
90+ with:
91+ name: sync
92+ path: ${{ runner.temp }}/sync
93+ - name: Apply patch
94+ if: needs.prepare.outputs.changed == 'true'
95+ run: |
96+ test -s "$RUNNER_TEMP/sync/changes.patch"
97+ git apply --index "$RUNNER_TEMP/sync/changes.patch"
67 - name: Commit via GitHub API98 - name: Commit via GitHub API
68 id: commit99 id: commit
69- if: steps.sync.outputs.changed == 'true'100+ if: needs.prepare.outputs.changed == 'true'
70 env:101 env:
71 GITHUB_TOKEN: ${{ github.token }}102 GITHUB_TOKEN: ${{ github.token }}
72 run: |103 run: |
73- message="$(printf 'feat(spec): sync upstream protocols\n\n'; head -n 20 "$RUNNER_TEMP/notes.md")"104+ message="$(printf 'feat(spec): sync upstream protocols\n\n'; head -n 20 "$RUNNER_TEMP/sync/notes.md")"
74 sha="$(go run ./cmd/wlbcommit -repo "$GITHUB_REPOSITORY" -branch main -message "$message")"105 sha="$(go run ./cmd/wlbcommit -repo "$GITHUB_REPOSITORY" -branch main -message "$message")"
75 echo "commit: $sha"106 echo "commit: $sha"
76 echo "sha=$sha" >> "$GITHUB_OUTPUT"107 echo "sha=$sha" >> "$GITHUB_OUTPUT"
77- - name: Create release108+ - name: Release
78- if: steps.sync.outputs.changed == 'true'109+ id: release
110+ if: success()
79 env:111 env:
80 GH_TOKEN: ${{ github.token }}112 GH_TOKEN: ${{ github.token }}
81- TAG: ${{ steps.version.outputs.tag }}113+ COMMITTED_SHA: ${{ steps.commit.outputs.sha }}
82- SHA: ${{ steps.commit.outputs.sha }}
83 run: |114 run: |
84- gh release create "$TAG" --target "$SHA" --title "$TAG" --notes-file "$RUNNER_TEMP/notes.md"115+ set -euo pipefail
116+ if [ -n "$COMMITTED_SHA" ]; then
117+ target="$COMMITTED_SHA"
118+ auth="$(printf 'x-access-token:%s' "$GH_TOKEN" | base64 -w0)"
119+ GIT_CONFIG_COUNT=1 \
120+ GIT_CONFIG_KEY_0="http.https://github.com/.extraheader" \
121+ GIT_CONFIG_VALUE_0="AUTHORIZATION: basic $auth" \
122+ git fetch --no-tags origin main
123+ git cat-file -e "$target^{commit}"
124+ else
125+ target="$(git rev-parse HEAD)"
126+ fi
127+ echo "release target: $target"
128+
129+ # Already released?
130+ if git tag --points-at "$target" | grep -qE '^v0\.[0-9]+\.[0-9]+$'; then
131+ echo "$target is already tagged; nothing to release"
132+ exit 0
133+ fi
134+
135+ latest="$(git tag -l 'v0.*' --sort=-v:refname | grep -E '^v0\.[0-9]+\.[0-9]+$' | sed -n 1p || true)"
136+ if [ -z "$latest" ]; then
137+ # The first release is a maintainer-signed tag; automation only
138+ # continues an existing v0 series.
139+ echo "no v0 tag yet; create the first release manually"
140+ exit 0
141+ else
142+ rc=0
143+ git diff --quiet "$latest" "$target" -- spec/xml client server || rc=$?
144+ case "$rc" in
145+ 0) echo "no changes in spec/xml, client, server since $latest; nothing to release"; exit 0 ;;
146+ 1) ;;
147+ *) echo "git diff failed ($rc)" >&2; exit "$rc" ;;
148+ esac
149+ minor="${latest#v0.}"
150+ minor="${minor%%.*}"
151+ tag="v0.$((minor + 1)).0"
152+ fi
153+ echo "latest tag: ${latest:-none}; next tag: $tag"
154+
155+ if [ -n "$COMMITTED_SHA" ]; then
156+ gh release create "$tag" --target "$target" --title "$tag" --notes-file "$RUNNER_TEMP/sync/notes.md"
157+ else
158+ gh release create "$tag" --target "$target" --title "$tag" --generate-notes
159+ fi
160+ echo "tag=$tag" >> "$GITHUB_OUTPUT"
85 - name: Warm Go module proxy161 - name: Warm Go module proxy
86- if: steps.sync.outputs.changed == 'true'162+ if: steps.release.outputs.tag != ''
87 env:163 env:
88- TAG: ${{ steps.version.outputs.tag }}164+ TAG: ${{ steps.release.outputs.tag }}
89 run: |165 run: |
90 curl -fsS "https://proxy.golang.org/github.com/bnema/go-wayland-bindings/@v/$TAG.info" || true166 curl -fsS "https://proxy.golang.org/github.com/bnema/go-wayland-bindings/@v/$TAG.info" || true
M cmd/wlbcommit/main.go +3 -5
@@ -200,6 +200,9 @@ func collectChanges(root, status string) (fileChanges, error) {
200200 }
201201 return fileChanges{}, err
202202 }
203+ if info.Mode()&os.ModeSymlink != 0 {
204+ return fileChanges{}, fmt.Errorf("%s is a symbolic link, which cannot be committed through the API", p)
205+ }
203206 if info.IsDir() {
204207 continue // e.g. a submodule; not representable
205208 }
@@ -211,11 +214,6 @@ func collectChanges(root, status string) (fileChanges, error) {
211214 changes.Additions = append(changes.Additions, addition{Path: p, Contents: base64.StdEncoding.EncodeToString(data)})
212215 }
213216 for _, p := range sortedKeys(dels) {
214- if adds[p] {
215- if _, err := os.Lstat(filepath.Join(root, filepath.FromSlash(p))); err == nil {
216- continue
217- }
218- }
219217 changes.Deletions = append(changes.Deletions, deletion{Path: p})
220218 }
221219 return changes, nil
@@ -200,6 +200,9 @@ func collectChanges(root, status string) (fileChanges, error) {
200 }200 }
201 return fileChanges{}, err201 return fileChanges{}, err
202 }202 }
203+ if info.Mode()&os.ModeSymlink != 0 {
204+ return fileChanges{}, fmt.Errorf("%s is a symbolic link, which cannot be committed through the API", p)
205+ }
203 if info.IsDir() {206 if info.IsDir() {
204 continue // e.g. a submodule; not representable207 continue // e.g. a submodule; not representable
205 }208 }
@@ -211,11 +214,6 @@ func collectChanges(root, status string) (fileChanges, error) {
211 changes.Additions = append(changes.Additions, addition{Path: p, Contents: base64.StdEncoding.EncodeToString(data)})214 changes.Additions = append(changes.Additions, addition{Path: p, Contents: base64.StdEncoding.EncodeToString(data)})
212 }215 }
213 for _, p := range sortedKeys(dels) {216 for _, p := range sortedKeys(dels) {
214- if adds[p] {
215- if _, err := os.Lstat(filepath.Join(root, filepath.FromSlash(p))); err == nil {
216- continue
217- }
218- }
219 changes.Deletions = append(changes.Deletions, deletion{Path: p})217 changes.Deletions = append(changes.Deletions, deletion{Path: p})
220 }218 }
221 return changes, nil219 return changes, nil
M cmd/wlbcommit/main_test.go +36 -1
@@ -8,15 +8,33 @@ import (
88 "net/http"
99 "net/http/httptest"
1010 "os"
11+ "os/exec"
1112 "path/filepath"
1213 "strings"
1314 "testing"
1415 )
1516
17+// isolateGit makes git invocations hermetic: inherited GIT_* variables are
18+// dropped and no user or system configuration is read.
19+func isolateGit(t *testing.T) {
20+ t.Helper()
21+ if _, err := exec.LookPath("git"); err != nil {
22+ t.Skip("git not installed")
23+ }
24+ for _, kv := range os.Environ() {
25+ if k, _, _ := strings.Cut(kv, "="); strings.HasPrefix(k, "GIT_") {
26+ t.Setenv(k, "") // registers restoration of the original value
27+ os.Unsetenv(k)
28+ }
29+ }
30+ t.Setenv("GIT_CONFIG_GLOBAL", "/dev/null")
31+ t.Setenv("GIT_CONFIG_NOSYSTEM", "1")
32+}
33+
1634 func gitT(t *testing.T, dir string, args ...string) string {
1735 t.Helper()
1836 full := append([]string{
19- "-c", "commit.gpgsign=false",
37+ "-c", "commit.gpgsign=false", "-c", "tag.gpgsign=false",
2038 "-c", "user.name=test", "-c", "user.email=test@example.com",
2139 }, args...)
2240 out, err := git(context.Background(), dir, full...)
@@ -39,6 +57,7 @@ func writeFile(t *testing.T, dir, name, content string) {
3957
4058 func newRepo(t *testing.T) (dir, head string) {
4159 t.Helper()
60+ isolateGit(t)
4261 dir = t.TempDir()
4362 gitT(t, dir, "init", "-q")
4463 writeFile(t, dir, "keep.txt", "keep\n")
@@ -195,6 +214,22 @@ func TestRunErrors(t *testing.T) {
195214 t.Error("API called despite no changes")
196215 }
197216 })
217+ t.Run("symlink", func(t *testing.T) {
218+ dir, _ := newRepo(t)
219+ if err := os.Symlink("keep.txt", filepath.Join(dir, "link")); err != nil {
220+ t.Skipf("symlinks unsupported: %v", err)
221+ }
222+ called := false
223+ srv := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { called = true }))
224+ defer srv.Close()
225+ err := run(context.Background(), base(srv.URL), ok, &bytes.Buffer{}, dir)
226+ if err == nil || !strings.Contains(err.Error(), "symbolic link") {
227+ t.Errorf("err = %v", err)
228+ }
229+ if called {
230+ t.Error("API called despite symlink")
231+ }
232+ })
198233 t.Run("non-200", func(t *testing.T) {
199234 dir, _ := newRepo(t)
200235 writeFile(t, dir, "x.txt", "x")
@@ -8,15 +8,33 @@ import (
8 "net/http"8 "net/http"
9 "net/http/httptest"9 "net/http/httptest"
10 "os"10 "os"
11+ "os/exec"
11 "path/filepath"12 "path/filepath"
12 "strings"13 "strings"
13 "testing"14 "testing"
14 )15 )
15 16
17+// isolateGit makes git invocations hermetic: inherited GIT_* variables are
18+// dropped and no user or system configuration is read.
19+func isolateGit(t *testing.T) {
20+ t.Helper()
21+ if _, err := exec.LookPath("git"); err != nil {
22+ t.Skip("git not installed")
23+ }
24+ for _, kv := range os.Environ() {
25+ if k, _, _ := strings.Cut(kv, "="); strings.HasPrefix(k, "GIT_") {
26+ t.Setenv(k, "") // registers restoration of the original value
27+ os.Unsetenv(k)
28+ }
29+ }
30+ t.Setenv("GIT_CONFIG_GLOBAL", "/dev/null")
31+ t.Setenv("GIT_CONFIG_NOSYSTEM", "1")
32+}
33+
16 func gitT(t *testing.T, dir string, args ...string) string {34 func gitT(t *testing.T, dir string, args ...string) string {
17 t.Helper()35 t.Helper()
18 full := append([]string{36 full := append([]string{
19- "-c", "commit.gpgsign=false",37+ "-c", "commit.gpgsign=false", "-c", "tag.gpgsign=false",
20 "-c", "user.name=test", "-c", "user.email=test@example.com",38 "-c", "user.name=test", "-c", "user.email=test@example.com",
21 }, args...)39 }, args...)
22 out, err := git(context.Background(), dir, full...)40 out, err := git(context.Background(), dir, full...)
@@ -39,6 +57,7 @@ func writeFile(t *testing.T, dir, name, content string) {
39 57
40 func newRepo(t *testing.T) (dir, head string) {58 func newRepo(t *testing.T) (dir, head string) {
41 t.Helper()59 t.Helper()
60+ isolateGit(t)
42 dir = t.TempDir()61 dir = t.TempDir()
43 gitT(t, dir, "init", "-q")62 gitT(t, dir, "init", "-q")
44 writeFile(t, dir, "keep.txt", "keep\n")63 writeFile(t, dir, "keep.txt", "keep\n")
@@ -195,6 +214,22 @@ func TestRunErrors(t *testing.T) {
195 t.Error("API called despite no changes")214 t.Error("API called despite no changes")
196 }215 }
197 })216 })
217+ t.Run("symlink", func(t *testing.T) {
218+ dir, _ := newRepo(t)
219+ if err := os.Symlink("keep.txt", filepath.Join(dir, "link")); err != nil {
220+ t.Skipf("symlinks unsupported: %v", err)
221+ }
222+ called := false
223+ srv := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { called = true }))
224+ defer srv.Close()
225+ err := run(context.Background(), base(srv.URL), ok, &bytes.Buffer{}, dir)
226+ if err == nil || !strings.Contains(err.Error(), "symbolic link") {
227+ t.Errorf("err = %v", err)
228+ }
229+ if called {
230+ t.Error("API called despite symlink")
231+ }
232+ })
198 t.Run("non-200", func(t *testing.T) {233 t.Run("non-200", func(t *testing.T) {
199 dir, _ := newRepo(t)234 dir, _ := newRepo(t)
200 writeFile(t, dir, "x.txt", "x")235 writeFile(t, dir, "x.txt", "x")
M cmd/wlbsync/git_test.go +24 -6
@@ -4,15 +4,31 @@ import (
44 "os"
55 "os/exec"
66 "path/filepath"
7+ "strings"
78 "testing"
89 )
910
10-// TestGitFetcherLocal exercises the real git implementation against a local
11-// repository; no network is involved.
12-func TestGitFetcherLocal(t *testing.T) {
11+// isolateGit makes git invocations hermetic: inherited GIT_* variables are
12+// dropped and no user or system configuration is read.
13+func isolateGit(t *testing.T) {
14+ t.Helper()
1315 if _, err := exec.LookPath("git"); err != nil {
1416 t.Skip("git not installed")
1517 }
18+ for _, kv := range os.Environ() {
19+ if k, _, _ := strings.Cut(kv, "="); strings.HasPrefix(k, "GIT_") {
20+ t.Setenv(k, "") // registers restoration of the original value
21+ os.Unsetenv(k)
22+ }
23+ }
24+ t.Setenv("GIT_CONFIG_GLOBAL", "/dev/null")
25+ t.Setenv("GIT_CONFIG_NOSYSTEM", "1")
26+}
27+
28+// TestGitFetcherLocal exercises the real git implementation against a local
29+// repository; no network is involved.
30+func TestGitFetcherLocal(t *testing.T) {
31+ isolateGit(t)
1632 repo := t.TempDir()
1733 run := func(args ...string) string {
1834 t.Helper()
@@ -41,9 +57,11 @@ func TestGitFetcherLocal(t *testing.T) {
4157 write("two")
4258 run("commit", "-q", "-am", "two")
4359 second := run("rev-parse", "HEAD")
44- run("tag", "1.2.0") // lightweight
45- run("tag", "1.3.1") // odd minor, patch < 90: accepted
46- run("tag", "1.5.90")
60+ run("tag", "1.2.0") // lightweight
61+ run("tag", "1.3.1") // odd minor, patch < 90: accepted
62+ run("tag", "1.5.90") // release candidate: rejected
63+ run("tag", "1.4.91") // release candidate on an even minor: rejected
64+ run("tag", "1.26.91")
4765 run("tag", "not-a-version")
4866
4967 src := upstream{
@@ -4,15 +4,31 @@ import (
4 "os"4 "os"
5 "os/exec"5 "os/exec"
6 "path/filepath"6 "path/filepath"
7+ "strings"
7 "testing"8 "testing"
8 )9 )
9 10
10-// TestGitFetcherLocal exercises the real git implementation against a local11+// isolateGit makes git invocations hermetic: inherited GIT_* variables are
11-// repository; no network is involved.12+// dropped and no user or system configuration is read.
12-func TestGitFetcherLocal(t *testing.T) {13+func isolateGit(t *testing.T) {
14+ t.Helper()
13 if _, err := exec.LookPath("git"); err != nil {15 if _, err := exec.LookPath("git"); err != nil {
14 t.Skip("git not installed")16 t.Skip("git not installed")
15 }17 }
18+ for _, kv := range os.Environ() {
19+ if k, _, _ := strings.Cut(kv, "="); strings.HasPrefix(k, "GIT_") {
20+ t.Setenv(k, "") // registers restoration of the original value
21+ os.Unsetenv(k)
22+ }
23+ }
24+ t.Setenv("GIT_CONFIG_GLOBAL", "/dev/null")
25+ t.Setenv("GIT_CONFIG_NOSYSTEM", "1")
26+}
27+
28+// TestGitFetcherLocal exercises the real git implementation against a local
29+// repository; no network is involved.
30+func TestGitFetcherLocal(t *testing.T) {
31+ isolateGit(t)
16 repo := t.TempDir()32 repo := t.TempDir()
17 run := func(args ...string) string {33 run := func(args ...string) string {
18 t.Helper()34 t.Helper()
@@ -41,9 +57,11 @@ func TestGitFetcherLocal(t *testing.T) {
41 write("two")57 write("two")
42 run("commit", "-q", "-am", "two")58 run("commit", "-q", "-am", "two")
43 second := run("rev-parse", "HEAD")59 second := run("rev-parse", "HEAD")
44- run("tag", "1.2.0") // lightweight60+ run("tag", "1.2.0") // lightweight
45- run("tag", "1.3.1") // odd minor, patch < 90: accepted61+ run("tag", "1.3.1") // odd minor, patch < 90: accepted
46- run("tag", "1.5.90")62+ run("tag", "1.5.90") // release candidate: rejected
63+ run("tag", "1.4.91") // release candidate on an even minor: rejected
64+ run("tag", "1.26.91")
47 run("tag", "not-a-version")65 run("tag", "not-a-version")
48 66
49 src := upstream{67 src := upstream{
M cmd/wlbsync/main.go +4 -1
@@ -93,7 +93,10 @@ func run(o options, srcs []upstream, f Fetcher, stdout io.Writer) error {
9393 if err != nil {
9494 return err
9595 }
96- changed := cur.differs(t, manifest)
96+ changed, err := cur.differs(t)
97+ if err != nil {
98+ return err
99+ }
97100
98101 if o.notes != "" {
99102 n, err := diff(cur, curParsed, t)
@@ -93,7 +93,10 @@ func run(o options, srcs []upstream, f Fetcher, stdout io.Writer) error {
93 if err != nil {93 if err != nil {
94 return err94 return err
95 }95 }
96- changed := cur.differs(t, manifest)96+ changed, err := cur.differs(t)
97+ if err != nil {
98+ return err
99+ }
97 100
98 if o.notes != "" {101 if o.notes != "" {
99 n, err := diff(cur, curParsed, t)102 n, err := diff(cur, curParsed, t)
M cmd/wlbsync/notes.go +43 -3
@@ -130,7 +130,11 @@ func diffProtocol(n *notes, pkg string, old, cur *spec.Protocol) {
130130 }
131131 if o.Version != it.Version {
132132 n.bumps = append(n.bumps, fmt.Sprintf("%s: %s v%d → v%d", pkg, it.Name, o.Version, it.Version))
133+ if it.Version < o.Version {
134+ n.breaking = append(n.breaking, fmt.Sprintf("%s: interface %s version decreased v%d → v%d", pkg, it.Name, o.Version, it.Version))
135+ }
133136 }
137+ diffEnums(n, pkg, it.Name, o.Enums, it.Enums)
134138 diffMessages(n, pkg, it.Name, "request", o.Requests, it.Requests)
135139 diffMessages(n, pkg, it.Name, "event", o.Events, it.Events)
136140 }
@@ -141,6 +145,31 @@ func diffProtocol(n *notes, pkg string, old, cur *spec.Protocol) {
141145 }
142146 }
143147
148+// diffEnums reports removed enums and removed or renamed enum entries.
149+// Entries are matched by name, so a rename shows up as a removal.
150+func diffEnums(n *notes, pkg, iface string, old, cur []spec.Enum) {
151+ curByName := map[string]spec.Enum{}
152+ for _, e := range cur {
153+ curByName[e.Name] = e
154+ }
155+ for _, o := range old {
156+ e, ok := curByName[o.Name]
157+ if !ok {
158+ n.breaking = append(n.breaking, fmt.Sprintf("%s: %s enum %s removed", pkg, iface, o.Name))
159+ continue
160+ }
161+ entries := map[string]bool{}
162+ for _, en := range e.Entries {
163+ entries[en.Name] = true
164+ }
165+ for _, en := range o.Entries {
166+ if !entries[en.Name] {
167+ n.breaking = append(n.breaking, fmt.Sprintf("%s: %s enum %s entry %s removed or renamed", pkg, iface, o.Name, en.Name))
168+ }
169+ }
170+ }
171+}
172+
144173 func diffMessages(n *notes, pkg, iface, kind string, old, cur []spec.Message) {
145174 curByName := map[string]spec.Message{}
146175 for _, m := range cur {
@@ -152,14 +181,18 @@ func diffMessages(n *notes, pkg, iface, kind string, old, cur []spec.Message) {
152181 n.breaking = append(n.breaking, fmt.Sprintf("%s: %s %s %s removed", pkg, iface, kind, o.Name))
153182 continue
154183 }
184+ if (o.Type == "destructor") != (m.Type == "destructor") {
185+ n.breaking = append(n.breaking, fmt.Sprintf("%s: %s %s %s destructor flag changed: %t → %t",
186+ pkg, iface, kind, o.Name, o.Type == "destructor", m.Type == "destructor"))
187+ }
155188 if change := argsChange(o.Args, m.Args); change != "" {
156189 n.breaking = append(n.breaking, fmt.Sprintf("%s: %s %s %s arguments changed: %s", pkg, iface, kind, o.Name, change))
157190 }
158191 }
159192 }
160193
161-// argsChange describes the first difference between two argument lists,
162-// comparing name, type and interface by position. It returns "" when equal.
194+// argsChange describes the differences between two argument lists, comparing
195+// name, type, interface, enum and allow-null by position. It returns "" when equal.
163196 func argsChange(old, cur []spec.Arg) string {
164197 if len(old) != len(cur) {
165198 return fmt.Sprintf("%d → %d arguments", len(old), len(cur))
@@ -167,7 +200,8 @@ func argsChange(old, cur []spec.Arg) string {
167200 var diffs []string
168201 for i := range old {
169202 o, c := old[i], cur[i]
170- if o.Name != c.Name || o.Type != c.Type || o.Interface != c.Interface {
203+ if o.Name != c.Name || o.Type != c.Type || o.Interface != c.Interface ||
204+ o.Enum != c.Enum || o.AllowNull != c.AllowNull {
171205 diffs = append(diffs, fmt.Sprintf("#%d %s → %s", i+1, argString(o), argString(c)))
172206 }
173207 }
@@ -179,6 +213,12 @@ func argString(a spec.Arg) string {
179213 if a.Interface != "" {
180214 s += "<" + a.Interface + ">"
181215 }
216+ if a.Enum != "" {
217+ s += " enum=" + a.Enum
218+ }
219+ if a.AllowNull {
220+ s += " allow-null"
221+ }
182222 return s
183223 }
184224
@@ -130,7 +130,11 @@ func diffProtocol(n *notes, pkg string, old, cur *spec.Protocol) {
130 }130 }
131 if o.Version != it.Version {131 if o.Version != it.Version {
132 n.bumps = append(n.bumps, fmt.Sprintf("%s: %s v%d → v%d", pkg, it.Name, o.Version, it.Version))132 n.bumps = append(n.bumps, fmt.Sprintf("%s: %s v%d → v%d", pkg, it.Name, o.Version, it.Version))
133+ if it.Version < o.Version {
134+ n.breaking = append(n.breaking, fmt.Sprintf("%s: interface %s version decreased v%d → v%d", pkg, it.Name, o.Version, it.Version))
135+ }
133 }136 }
137+ diffEnums(n, pkg, it.Name, o.Enums, it.Enums)
134 diffMessages(n, pkg, it.Name, "request", o.Requests, it.Requests)138 diffMessages(n, pkg, it.Name, "request", o.Requests, it.Requests)
135 diffMessages(n, pkg, it.Name, "event", o.Events, it.Events)139 diffMessages(n, pkg, it.Name, "event", o.Events, it.Events)
136 }140 }
@@ -141,6 +145,31 @@ func diffProtocol(n *notes, pkg string, old, cur *spec.Protocol) {
141 }145 }
142 }146 }
143 147
148+// diffEnums reports removed enums and removed or renamed enum entries.
149+// Entries are matched by name, so a rename shows up as a removal.
150+func diffEnums(n *notes, pkg, iface string, old, cur []spec.Enum) {
151+ curByName := map[string]spec.Enum{}
152+ for _, e := range cur {
153+ curByName[e.Name] = e
154+ }
155+ for _, o := range old {
156+ e, ok := curByName[o.Name]
157+ if !ok {
158+ n.breaking = append(n.breaking, fmt.Sprintf("%s: %s enum %s removed", pkg, iface, o.Name))
159+ continue
160+ }
161+ entries := map[string]bool{}
162+ for _, en := range e.Entries {
163+ entries[en.Name] = true
164+ }
165+ for _, en := range o.Entries {
166+ if !entries[en.Name] {
167+ n.breaking = append(n.breaking, fmt.Sprintf("%s: %s enum %s entry %s removed or renamed", pkg, iface, o.Name, en.Name))
168+ }
169+ }
170+ }
171+}
172+
144 func diffMessages(n *notes, pkg, iface, kind string, old, cur []spec.Message) {173 func diffMessages(n *notes, pkg, iface, kind string, old, cur []spec.Message) {
145 curByName := map[string]spec.Message{}174 curByName := map[string]spec.Message{}
146 for _, m := range cur {175 for _, m := range cur {
@@ -152,14 +181,18 @@ func diffMessages(n *notes, pkg, iface, kind string, old, cur []spec.Message) {
152 n.breaking = append(n.breaking, fmt.Sprintf("%s: %s %s %s removed", pkg, iface, kind, o.Name))181 n.breaking = append(n.breaking, fmt.Sprintf("%s: %s %s %s removed", pkg, iface, kind, o.Name))
153 continue182 continue
154 }183 }
184+ if (o.Type == "destructor") != (m.Type == "destructor") {
185+ n.breaking = append(n.breaking, fmt.Sprintf("%s: %s %s %s destructor flag changed: %t → %t",
186+ pkg, iface, kind, o.Name, o.Type == "destructor", m.Type == "destructor"))
187+ }
155 if change := argsChange(o.Args, m.Args); change != "" {188 if change := argsChange(o.Args, m.Args); change != "" {
156 n.breaking = append(n.breaking, fmt.Sprintf("%s: %s %s %s arguments changed: %s", pkg, iface, kind, o.Name, change))189 n.breaking = append(n.breaking, fmt.Sprintf("%s: %s %s %s arguments changed: %s", pkg, iface, kind, o.Name, change))
157 }190 }
158 }191 }
159 }192 }
160 193
161-// argsChange describes the first difference between two argument lists,194+// argsChange describes the differences between two argument lists, comparing
162-// comparing name, type and interface by position. It returns "" when equal.195+// name, type, interface, enum and allow-null by position. It returns "" when equal.
163 func argsChange(old, cur []spec.Arg) string {196 func argsChange(old, cur []spec.Arg) string {
164 if len(old) != len(cur) {197 if len(old) != len(cur) {
165 return fmt.Sprintf("%d → %d arguments", len(old), len(cur))198 return fmt.Sprintf("%d → %d arguments", len(old), len(cur))
@@ -167,7 +200,8 @@ func argsChange(old, cur []spec.Arg) string {
167 var diffs []string200 var diffs []string
168 for i := range old {201 for i := range old {
169 o, c := old[i], cur[i]202 o, c := old[i], cur[i]
170- if o.Name != c.Name || o.Type != c.Type || o.Interface != c.Interface {203+ if o.Name != c.Name || o.Type != c.Type || o.Interface != c.Interface ||
204+ o.Enum != c.Enum || o.AllowNull != c.AllowNull {
171 diffs = append(diffs, fmt.Sprintf("#%d %s → %s", i+1, argString(o), argString(c)))205 diffs = append(diffs, fmt.Sprintf("#%d %s → %s", i+1, argString(o), argString(c)))
172 }206 }
173 }207 }
@@ -179,6 +213,12 @@ func argString(a spec.Arg) string {
179 if a.Interface != "" {213 if a.Interface != "" {
180 s += "<" + a.Interface + ">"214 s += "<" + a.Interface + ">"
181 }215 }
216+ if a.Enum != "" {
217+ s += " enum=" + a.Enum
218+ }
219+ if a.AllowNull {
220+ s += " allow-null"
221+ }
182 return s222 return s
183 }223 }
184 224
M cmd/wlbsync/sources.go +3 -6
@@ -49,14 +49,11 @@ var (
4949 tagVThreePart = regexp.MustCompile(`^v\d+\.\d+\.\d+$`)
5050 )
5151
52-// waylandTagOK accepts release tags: even minor, or patch below 90 (patch
53-// 90 and above are release candidates).
52+// waylandTagOK accepts release tags X.Y.Z only; patch 90 and above are
53+// release candidates regardless of the minor version.
5454 func waylandTagOK(tag string) bool {
5555 v := versionParts(tag)
56- if len(v) != 3 {
57- return false
58- }
59- return v[1]%2 == 0 || v[2] < 90
56+ return len(v) == 3 && v[2] < 90
6057 }
6158
6259 // exactly matches only the listed repository paths, all with stability s.
@@ -49,14 +49,11 @@ var (
49 tagVThreePart = regexp.MustCompile(`^v\d+\.\d+\.\d+$`)49 tagVThreePart = regexp.MustCompile(`^v\d+\.\d+\.\d+$`)
50 )50 )
51 51
52-// waylandTagOK accepts release tags: even minor, or patch below 90 (patch52+// waylandTagOK accepts release tags X.Y.Z only; patch 90 and above are
53-// 90 and above are release candidates).53+// release candidates regardless of the minor version.
54 func waylandTagOK(tag string) bool {54 func waylandTagOK(tag string) bool {
55 v := versionParts(tag)55 v := versionParts(tag)
56- if len(v) != 3 {56+ return len(v) == 3 && v[2] < 90
57- return false
58- }
59- return v[1]%2 == 0 || v[2] < 90
60 }57 }
61 58
62 // exactly matches only the listed repository paths, all with stability s.59 // exactly matches only the listed repository paths, all with stability s.
M cmd/wlbsync/sync.go +31 -6
@@ -89,6 +89,9 @@ func checkoutAndScan(src upstream, ref, commit string, f Fetcher) ([]candidate,
8989 }
9090 return nil
9191 }
92+ if !d.Type().IsRegular() {
93+ return nil // symlinks and other special files are never vendored
94+ }
9295 rel, err := filepath.Rel(dir, p)
9396 if err != nil {
9497 return err
@@ -252,17 +255,39 @@ func readState(root string) (*state, error) {
252255 return s, nil
253256 }
254257
255-// differs reports whether writing t would change anything on disk.
256-func (s *state) differs(t *tree, manifest []byte) bool {
257- if !bytes.Equal(s.raw, manifest) || len(s.xml) != len(t.xml) {
258- return true
258+// differs reports whether t carries a real content change relative to disk:
259+// different XML bytes or different protocol entries. A move of a source ref
260+// or commit that leaves every selected XML byte-identical (and the set of
261+// packages unchanged) is not a change, so nothing is rewritten and no release
262+// is cut for it.
263+func (s *state) differs(t *tree) (bool, error) {
264+ if len(s.xml) != len(t.xml) {
265+ return true, nil
266+ }
267+ // A different set of upstream repositories is a change.
268+ if len(s.manifest.Sources) != len(t.manifest.Sources) {
269+ return true, nil
270+ }
271+ for i, o := range s.manifest.Sources {
272+ if n := t.manifest.Sources[i]; o.ID != n.ID || o.URL != n.URL {
273+ return true, nil
274+ }
275+ }
276+ // Compare manifests with the current source records substituted in.
277+ probe := &spec.Manifest{Sources: s.manifest.Sources, Protocols: t.manifest.Protocols}
278+ b, err := probe.Marshal()
279+ if err != nil {
280+ return false, err
281+ }
282+ if !bytes.Equal(s.raw, b) {
283+ return true, nil
259284 }
260285 for name, b := range t.xml {
261286 if old, ok := s.xml[name]; !ok || !bytes.Equal(old, b) {
262- return true
287+ return true, nil
263288 }
264289 }
265- return false
290+ return false, nil
266291 }
267292
268293 // write stores t under root/spec and removes stale XML files.
@@ -89,6 +89,9 @@ func checkoutAndScan(src upstream, ref, commit string, f Fetcher) ([]candidate,
89 }89 }
90 return nil90 return nil
91 }91 }
92+ if !d.Type().IsRegular() {
93+ return nil // symlinks and other special files are never vendored
94+ }
92 rel, err := filepath.Rel(dir, p)95 rel, err := filepath.Rel(dir, p)
93 if err != nil {96 if err != nil {
94 return err97 return err
@@ -252,17 +255,39 @@ func readState(root string) (*state, error) {
252 return s, nil255 return s, nil
253 }256 }
254 257
255-// differs reports whether writing t would change anything on disk.258+// differs reports whether t carries a real content change relative to disk:
256-func (s *state) differs(t *tree, manifest []byte) bool {259+// different XML bytes or different protocol entries. A move of a source ref
257- if !bytes.Equal(s.raw, manifest) || len(s.xml) != len(t.xml) {260+// or commit that leaves every selected XML byte-identical (and the set of
258- return true261+// packages unchanged) is not a change, so nothing is rewritten and no release
262+// is cut for it.
263+func (s *state) differs(t *tree) (bool, error) {
264+ if len(s.xml) != len(t.xml) {
265+ return true, nil
266+ }
267+ // A different set of upstream repositories is a change.
268+ if len(s.manifest.Sources) != len(t.manifest.Sources) {
269+ return true, nil
270+ }
271+ for i, o := range s.manifest.Sources {
272+ if n := t.manifest.Sources[i]; o.ID != n.ID || o.URL != n.URL {
273+ return true, nil
274+ }
275+ }
276+ // Compare manifests with the current source records substituted in.
277+ probe := &spec.Manifest{Sources: s.manifest.Sources, Protocols: t.manifest.Protocols}
278+ b, err := probe.Marshal()
279+ if err != nil {
280+ return false, err
281+ }
282+ if !bytes.Equal(s.raw, b) {
283+ return true, nil
259 }284 }
260 for name, b := range t.xml {285 for name, b := range t.xml {
261 if old, ok := s.xml[name]; !ok || !bytes.Equal(old, b) {286 if old, ok := s.xml[name]; !ok || !bytes.Equal(old, b) {
262- return true287+ return true, nil
263 }288 }
264 }289 }
265- return false290+ return false, nil
266 }291 }
267 292
268 // write stores t under root/spec and removes stale XML files.293 // write stores t under root/spec and removes stale XML files.
M cmd/wlbsync/sync_test.go +133 -3
@@ -408,6 +408,102 @@ func TestBreakingMessageChanges(t *testing.T) {
408408 }
409409 }
410410
411+func TestSourceMoveOnlyIsNotAChange(t *testing.T) {
412+ root := t.TempDir()
413+ f := baseFetcher()
414+ mustSync(t, root, f, options{})
415+ before := snapshot(t, root)
416+
417+ // New tags, byte-identical XML.
418+ f.trees["wayland"]["1.0.1"] = f.trees["wayland"]["1.0.0"]
419+ f.trees["wayland-protocols"]["1.1"] = f.trees["wayland-protocols"]["1.0"]
420+ f.latest["wayland"] = "1.0.1"
421+ f.latest["wayland-protocols"] = "1.1"
422+
423+ for _, o := range []options{{}, {check: true}} {
424+ if got := mustSync(t, root, f, o); got != "changed=false" {
425+ t.Errorf("options %+v: output %q, want changed=false", o, got)
426+ }
427+ if !reflect.DeepEqual(before, snapshot(t, root)) {
428+ t.Errorf("options %+v: spec/ was rewritten", o)
429+ }
430+ }
431+
432+ // A real XML change afterwards rewrites the manifest with the new refs.
433+ f.trees["wayland"]["1.0.2"] = map[string]string{
434+ "protocol/wayland.xml": xmlDoc("wayland", true, "wl_surface:7", "wl_seat:9"),
435+ }
436+ f.latest["wayland"] = "1.0.2"
437+ if got := mustSync(t, root, f, options{}); got != "changed=true" {
438+ t.Fatalf("output %q", got)
439+ }
440+ m := loadRoot(t, root)
441+ for _, s := range m.Sources {
442+ if s.ID == "wayland-protocols" && s.Ref != "1.1" {
443+ t.Errorf("wayland-protocols ref = %s, want 1.1", s.Ref)
444+ }
445+ }
446+}
447+
448+func TestBreakingEnumsAndAttributes(t *testing.T) {
449+ enum := func(name string, entries ...string) spec.Enum {
450+ e := spec.Enum{Name: name}
451+ for _, en := range entries {
452+ e.Entries = append(e.Entries, spec.Entry{Name: en})
453+ }
454+ return e
455+ }
456+ iface := func(version int, enums []spec.Enum, reqs ...spec.Message) *spec.Protocol {
457+ return &spec.Protocol{Interfaces: []spec.Interface{{Name: "a", Version: version, Enums: enums, Requests: reqs}}}
458+ }
459+ msg := func(typ string, args ...spec.Arg) spec.Message {
460+ return spec.Message{Name: "m", Type: typ, Args: args}
461+ }
462+ cases := []struct {
463+ name string
464+ old, cur *spec.Protocol
465+ want string // substring of the single breaking entry; "" means none
466+ }{
467+ {"enum removed", iface(1, []spec.Enum{enum("e", "x")}), iface(1, nil), "enum e removed"},
468+ {"entry removed", iface(1, []spec.Enum{enum("e", "x", "y")}), iface(1, []spec.Enum{enum("e", "x")}), "enum e entry y removed or renamed"},
469+ {"entry renamed", iface(1, []spec.Enum{enum("e", "x")}), iface(1, []spec.Enum{enum("e", "z")}), "enum e entry x removed or renamed"},
470+ {"entry added", iface(1, []spec.Enum{enum("e", "x")}), iface(1, []spec.Enum{enum("e", "x", "y")}), ""},
471+ {"enum added", iface(1, nil), iface(1, []spec.Enum{enum("e", "x")}), ""},
472+ {"version decreased", iface(3, nil), iface(2, nil), "interface a version decreased v3 → v2"},
473+ {"version increased", iface(2, nil), iface(3, nil), ""},
474+ {"destructor added", iface(1, nil, msg("")), iface(1, nil, msg("destructor")), "request m destructor flag changed: false → true"},
475+ {"destructor removed", iface(1, nil, msg("destructor")), iface(1, nil, msg("")), "request m destructor flag changed: true → false"},
476+ {"enum attr changed",
477+ iface(1, nil, msg("", spec.Arg{Name: "x", Type: "uint", Enum: "e"})),
478+ iface(1, nil, msg("", spec.Arg{Name: "x", Type: "uint", Enum: "f"})),
479+ "#1 x:uint enum=e → x:uint enum=f"},
480+ {"enum attr dropped",
481+ iface(1, nil, msg("", spec.Arg{Name: "x", Type: "uint", Enum: "e"})),
482+ iface(1, nil, msg("", spec.Arg{Name: "x", Type: "uint"})),
483+ "arguments changed"},
484+ {"allow-null changed",
485+ iface(1, nil, msg("", spec.Arg{Name: "x", Type: "object", Interface: "b"})),
486+ iface(1, nil, msg("", spec.Arg{Name: "x", Type: "object", Interface: "b", AllowNull: true})),
487+ "#1 x:object<b> → x:object<b> allow-null"},
488+ {"identical",
489+ iface(1, []spec.Enum{enum("e", "x")}, msg("destructor", spec.Arg{Name: "x", Type: "uint", Enum: "e", AllowNull: true})),
490+ iface(1, []spec.Enum{enum("e", "x")}, msg("destructor", spec.Arg{Name: "x", Type: "uint", Enum: "e", AllowNull: true})),
491+ ""},
492+ }
493+ for _, c := range cases {
494+ t.Run(c.name, func(t *testing.T) {
495+ n := &notes{}
496+ diffProtocol(n, "p", c.old, c.cur)
497+ switch {
498+ case c.want == "" && len(n.breaking) != 0:
499+ t.Errorf("unexpected breaking: %v", n.breaking)
500+ case c.want != "" && (len(n.breaking) != 1 || !strings.Contains(n.breaking[0], c.want)):
501+ t.Errorf("breaking = %v, want one containing %q", n.breaking, c.want)
502+ }
503+ })
504+ }
505+}
506+
411507 func TestBreakingEventAndRename(t *testing.T) {
412508 old := &spec.Protocol{Interfaces: []spec.Interface{{
413509 Name: "a",
@@ -429,6 +525,32 @@ func TestBreakingEventAndRename(t *testing.T) {
429525 }
430526 }
431527
528+// symlinkFetcher checks out a repository whose vendored path is a symlink.
529+type symlinkFetcher struct{ target string }
530+
531+func (f symlinkFetcher) Resolve(upstream, string) (string, string, error) { return "", "", nil }
532+
533+func (f symlinkFetcher) Checkout(_ upstream, _, _, dir string) error {
534+ if err := os.MkdirAll(filepath.Join(dir, "protocol"), 0o755); err != nil {
535+ return err
536+ }
537+ return os.Symlink(f.target, filepath.Join(dir, "protocol", "wayland.xml"))
538+}
539+
540+func TestScanSkipsNonRegularFiles(t *testing.T) {
541+ secret := filepath.Join(t.TempDir(), "secret.xml")
542+ if err := os.WriteFile(secret, []byte("secret"), 0o644); err != nil {
543+ t.Fatal(err)
544+ }
545+ found, err := checkoutAndScan(testSources()[0], "1.0.0", "c", symlinkFetcher{target: secret})
546+ if err != nil {
547+ t.Skipf("symlinks unsupported: %v", err)
548+ }
549+ if len(found) != 0 {
550+ t.Errorf("symlink was scanned: %+v", found)
551+ }
552+}
553+
432554 func TestSelectionRule(t *testing.T) {
433555 cases := []struct {
434556 name string
@@ -714,15 +836,23 @@ func TestTagSelection(t *testing.T) {
714836 plasma = s
715837 }
716838 }
717- // Even minor or patch<90: 1.10.90 allowed (even minor), 1.11.91 and 1.9.95 are not,
718- // 1.11.1 is. Numeric compare: 1.10.90 > 1.11.1? No: 1.11.1 > 1.10.90.
839+ // Only X.Y.Z with Z < 90: 1.10.90, 1.11.91 and 1.9.95 are release
840+ // candidates; 1.11.1 is accepted.
719841 if got, err := latestTag(wayland, tags); err != nil || got != "1.11.1" {
720842 t.Errorf("wayland latest = %q, %v", got, err)
721843 }
722844 delete(tags, "1.11.1")
723- if got, _ := latestTag(wayland, tags); got != "1.10.90" {
845+ if got, _ := latestTag(wayland, tags); got != "1.10.0" {
724846 t.Errorf("wayland latest = %q", got)
725847 }
848+ for tag, want := range map[string]bool{
849+ "1.26.0": true, "1.3.89": true, "1.26.90": false, "1.26.91": false, "1.4.91": false,
850+ "1.4": false, "1.4.0.1": false, "1.4.x": false,
851+ } {
852+ if got := waylandTagOK(tag); got != want {
853+ t.Errorf("waylandTagOK(%q) = %v, want %v", tag, got, want)
854+ }
855+ }
726856
727857 if got, err := latestTag(protocols, map[string]string{"1.9": "a", "1.10": "b", "1.49": "c", "1.49.1": "d", "v1.50": "e"}); err != nil || got != "1.49" {
728858 t.Errorf("wayland-protocols latest = %q, %v", got, err)
@@ -408,6 +408,102 @@ func TestBreakingMessageChanges(t *testing.T) {
408 }408 }
409 }409 }
410 410
411+func TestSourceMoveOnlyIsNotAChange(t *testing.T) {
412+ root := t.TempDir()
413+ f := baseFetcher()
414+ mustSync(t, root, f, options{})
415+ before := snapshot(t, root)
416+
417+ // New tags, byte-identical XML.
418+ f.trees["wayland"]["1.0.1"] = f.trees["wayland"]["1.0.0"]
419+ f.trees["wayland-protocols"]["1.1"] = f.trees["wayland-protocols"]["1.0"]
420+ f.latest["wayland"] = "1.0.1"
421+ f.latest["wayland-protocols"] = "1.1"
422+
423+ for _, o := range []options{{}, {check: true}} {
424+ if got := mustSync(t, root, f, o); got != "changed=false" {
425+ t.Errorf("options %+v: output %q, want changed=false", o, got)
426+ }
427+ if !reflect.DeepEqual(before, snapshot(t, root)) {
428+ t.Errorf("options %+v: spec/ was rewritten", o)
429+ }
430+ }
431+
432+ // A real XML change afterwards rewrites the manifest with the new refs.
433+ f.trees["wayland"]["1.0.2"] = map[string]string{
434+ "protocol/wayland.xml": xmlDoc("wayland", true, "wl_surface:7", "wl_seat:9"),
435+ }
436+ f.latest["wayland"] = "1.0.2"
437+ if got := mustSync(t, root, f, options{}); got != "changed=true" {
438+ t.Fatalf("output %q", got)
439+ }
440+ m := loadRoot(t, root)
441+ for _, s := range m.Sources {
442+ if s.ID == "wayland-protocols" && s.Ref != "1.1" {
443+ t.Errorf("wayland-protocols ref = %s, want 1.1", s.Ref)
444+ }
445+ }
446+}
447+
448+func TestBreakingEnumsAndAttributes(t *testing.T) {
449+ enum := func(name string, entries ...string) spec.Enum {
450+ e := spec.Enum{Name: name}
451+ for _, en := range entries {
452+ e.Entries = append(e.Entries, spec.Entry{Name: en})
453+ }
454+ return e
455+ }
456+ iface := func(version int, enums []spec.Enum, reqs ...spec.Message) *spec.Protocol {
457+ return &spec.Protocol{Interfaces: []spec.Interface{{Name: "a", Version: version, Enums: enums, Requests: reqs}}}
458+ }
459+ msg := func(typ string, args ...spec.Arg) spec.Message {
460+ return spec.Message{Name: "m", Type: typ, Args: args}
461+ }
462+ cases := []struct {
463+ name string
464+ old, cur *spec.Protocol
465+ want string // substring of the single breaking entry; "" means none
466+ }{
467+ {"enum removed", iface(1, []spec.Enum{enum("e", "x")}), iface(1, nil), "enum e removed"},
468+ {"entry removed", iface(1, []spec.Enum{enum("e", "x", "y")}), iface(1, []spec.Enum{enum("e", "x")}), "enum e entry y removed or renamed"},
469+ {"entry renamed", iface(1, []spec.Enum{enum("e", "x")}), iface(1, []spec.Enum{enum("e", "z")}), "enum e entry x removed or renamed"},
470+ {"entry added", iface(1, []spec.Enum{enum("e", "x")}), iface(1, []spec.Enum{enum("e", "x", "y")}), ""},
471+ {"enum added", iface(1, nil), iface(1, []spec.Enum{enum("e", "x")}), ""},
472+ {"version decreased", iface(3, nil), iface(2, nil), "interface a version decreased v3 → v2"},
473+ {"version increased", iface(2, nil), iface(3, nil), ""},
474+ {"destructor added", iface(1, nil, msg("")), iface(1, nil, msg("destructor")), "request m destructor flag changed: false → true"},
475+ {"destructor removed", iface(1, nil, msg("destructor")), iface(1, nil, msg("")), "request m destructor flag changed: true → false"},
476+ {"enum attr changed",
477+ iface(1, nil, msg("", spec.Arg{Name: "x", Type: "uint", Enum: "e"})),
478+ iface(1, nil, msg("", spec.Arg{Name: "x", Type: "uint", Enum: "f"})),
479+ "#1 x:uint enum=e → x:uint enum=f"},
480+ {"enum attr dropped",
481+ iface(1, nil, msg("", spec.Arg{Name: "x", Type: "uint", Enum: "e"})),
482+ iface(1, nil, msg("", spec.Arg{Name: "x", Type: "uint"})),
483+ "arguments changed"},
484+ {"allow-null changed",
485+ iface(1, nil, msg("", spec.Arg{Name: "x", Type: "object", Interface: "b"})),
486+ iface(1, nil, msg("", spec.Arg{Name: "x", Type: "object", Interface: "b", AllowNull: true})),
487+ "#1 x:object<b> → x:object<b> allow-null"},
488+ {"identical",
489+ iface(1, []spec.Enum{enum("e", "x")}, msg("destructor", spec.Arg{Name: "x", Type: "uint", Enum: "e", AllowNull: true})),
490+ iface(1, []spec.Enum{enum("e", "x")}, msg("destructor", spec.Arg{Name: "x", Type: "uint", Enum: "e", AllowNull: true})),
491+ ""},
492+ }
493+ for _, c := range cases {
494+ t.Run(c.name, func(t *testing.T) {
495+ n := &notes{}
496+ diffProtocol(n, "p", c.old, c.cur)
497+ switch {
498+ case c.want == "" && len(n.breaking) != 0:
499+ t.Errorf("unexpected breaking: %v", n.breaking)
500+ case c.want != "" && (len(n.breaking) != 1 || !strings.Contains(n.breaking[0], c.want)):
501+ t.Errorf("breaking = %v, want one containing %q", n.breaking, c.want)
502+ }
503+ })
504+ }
505+}
506+
411 func TestBreakingEventAndRename(t *testing.T) {507 func TestBreakingEventAndRename(t *testing.T) {
412 old := &spec.Protocol{Interfaces: []spec.Interface{{508 old := &spec.Protocol{Interfaces: []spec.Interface{{
413 Name: "a",509 Name: "a",
@@ -429,6 +525,32 @@ func TestBreakingEventAndRename(t *testing.T) {
429 }525 }
430 }526 }
431 527
528+// symlinkFetcher checks out a repository whose vendored path is a symlink.
529+type symlinkFetcher struct{ target string }
530+
531+func (f symlinkFetcher) Resolve(upstream, string) (string, string, error) { return "", "", nil }
532+
533+func (f symlinkFetcher) Checkout(_ upstream, _, _, dir string) error {
534+ if err := os.MkdirAll(filepath.Join(dir, "protocol"), 0o755); err != nil {
535+ return err
536+ }
537+ return os.Symlink(f.target, filepath.Join(dir, "protocol", "wayland.xml"))
538+}
539+
540+func TestScanSkipsNonRegularFiles(t *testing.T) {
541+ secret := filepath.Join(t.TempDir(), "secret.xml")
542+ if err := os.WriteFile(secret, []byte("secret"), 0o644); err != nil {
543+ t.Fatal(err)
544+ }
545+ found, err := checkoutAndScan(testSources()[0], "1.0.0", "c", symlinkFetcher{target: secret})
546+ if err != nil {
547+ t.Skipf("symlinks unsupported: %v", err)
548+ }
549+ if len(found) != 0 {
550+ t.Errorf("symlink was scanned: %+v", found)
551+ }
552+}
553+
432 func TestSelectionRule(t *testing.T) {554 func TestSelectionRule(t *testing.T) {
433 cases := []struct {555 cases := []struct {
434 name string556 name string
@@ -714,15 +836,23 @@ func TestTagSelection(t *testing.T) {
714 plasma = s836 plasma = s
715 }837 }
716 }838 }
717- // Even minor or patch<90: 1.10.90 allowed (even minor), 1.11.91 and 1.9.95 are not,839+ // Only X.Y.Z with Z < 90: 1.10.90, 1.11.91 and 1.9.95 are release
718- // 1.11.1 is. Numeric compare: 1.10.90 > 1.11.1? No: 1.11.1 > 1.10.90.840+ // candidates; 1.11.1 is accepted.
719 if got, err := latestTag(wayland, tags); err != nil || got != "1.11.1" {841 if got, err := latestTag(wayland, tags); err != nil || got != "1.11.1" {
720 t.Errorf("wayland latest = %q, %v", got, err)842 t.Errorf("wayland latest = %q, %v", got, err)
721 }843 }
722 delete(tags, "1.11.1")844 delete(tags, "1.11.1")
723- if got, _ := latestTag(wayland, tags); got != "1.10.90" {845+ if got, _ := latestTag(wayland, tags); got != "1.10.0" {
724 t.Errorf("wayland latest = %q", got)846 t.Errorf("wayland latest = %q", got)
725 }847 }
848+ for tag, want := range map[string]bool{
849+ "1.26.0": true, "1.3.89": true, "1.26.90": false, "1.26.91": false, "1.4.91": false,
850+ "1.4": false, "1.4.0.1": false, "1.4.x": false,
851+ } {
852+ if got := waylandTagOK(tag); got != want {
853+ t.Errorf("waylandTagOK(%q) = %v, want %v", tag, got, want)
854+ }
855+ }
726 856
727 if got, err := latestTag(protocols, map[string]string{"1.9": "a", "1.10": "b", "1.49": "c", "1.49.1": "d", "v1.50": "e"}); err != nil || got != "1.49" {857 if got, err := latestTag(protocols, map[string]string{"1.9": "a", "1.10": "b", "1.49": "c", "1.49.1": "d", "v1.50": "e"}); err != nil || got != "1.49" {
728 t.Errorf("wayland-protocols latest = %q, %v", got, err)858 t.Errorf("wayland-protocols latest = %q, %v", got, err)

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. Elsewhere: into the side 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
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
esc
close a dialog, leave a field

Branches 1

Tags 1

↑↓ moveenter openesc close