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 Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
10 files · +397 -73
M .github/workflows/ci.yml +6 -4
| @@ -12,16 +12,18 @@ jobs: | |||
| 12 | 12 | test: | |
| 13 | 13 | runs-on: ubuntu-latest | |
| 14 | 14 | 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 | |
| 17 | 19 | with: | |
| 18 | 20 | go-version-file: go.mod | |
| 19 | 21 | - 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 | |
| 21 | 23 | - name: Vet | |
| 22 | 24 | run: go vet ./... | |
| 23 | 25 | - 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 | 27 | - name: Test (CGO disabled) | |
| 26 | 28 | run: CGO_ENABLED=0 go test ./... | |
| 27 | 29 | - name: Test (race) | |
| @@ -12,16 +12,18 @@ jobs: | |||||
| 12 | test: | 12 | test: | ||
| 13 | runs-on: ubuntu-latest | 13 | runs-on: ubuntu-latest | ||
| 14 | steps: | 14 | steps: | ||
| 15 | - | - uses: actions/checkout@v4 | 15 | + | - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 |
| 16 | - | - uses: actions/setup-go@v5 | 16 | + | with: |
| 17 | + | persist-credentials: false | |||
| 18 | + | - uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5 | |||
| 17 | with: | 19 | with: | ||
| 18 | go-version-file: go.mod | 20 | go-version-file: go.mod | ||
| 19 | - name: Install Wayland libraries | 21 | - 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 |
| 21 | - name: Vet | 23 | - name: Vet | ||
| 22 | run: go vet ./... | 24 | run: go vet ./... | ||
| 23 | - name: Staticcheck | 25 | - 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: | |||
| 6 | 6 | workflow_dispatch: | |
| 7 | 7 | ||
| 8 | 8 | permissions: | |
| 9 | - | contents: write | |
| 9 | + | contents: read | |
| 10 | 10 | ||
| 11 | 11 | concurrency: | |
| 12 | 12 | group: sync | |
| 13 | 13 | cancel-in-progress: false | |
| 14 | 14 | ||
| 15 | 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 | 19 | runs-on: ubuntu-latest | |
| 20 | + | permissions: | |
| 21 | + | contents: read | |
| 22 | + | outputs: | |
| 23 | + | changed: ${{ steps.sync.outputs.changed }} | |
| 18 | 24 | steps: | |
| 19 | - | - uses: actions/checkout@v4 | |
| 25 | + | - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 | |
| 20 | 26 | with: | |
| 21 | - | fetch-depth: 0 | |
| 22 | - | - uses: actions/setup-go@v5 | |
| 27 | + | persist-credentials: false | |
| 28 | + | - uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5 | |
| 23 | 29 | with: | |
| 24 | 30 | 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 | 31 | - 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 | |
| 31 | 33 | - name: Sync upstream protocols | |
| 32 | 34 | id: sync | |
| 33 | 35 | 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" | |
| 41 | 40 | - name: Generate | |
| 42 | 41 | if: steps.sync.outputs.changed == 'true' | |
| 43 | 42 | run: go generate ./... | |
| 44 | 43 | - name: Vet | |
| 45 | 44 | if: steps.sync.outputs.changed == 'true' | |
| 46 | 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 | 49 | - name: Test (CGO disabled) | |
| 48 | 50 | if: steps.sync.outputs.changed == 'true' | |
| 49 | 51 | run: CGO_ENABLED=0 go test ./... | |
| 50 | 52 | - name: Test (race) | |
| 51 | 53 | if: steps.sync.outputs.changed == 'true' | |
| 52 | 54 | 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 }} | |
| 56 | 58 | 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" | |
| 64 | 64 | 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" | |
| 67 | 98 | - name: Commit via GitHub API | |
| 68 | 99 | id: commit | |
| 69 | - | if: steps.sync.outputs.changed == 'true' | |
| 100 | + | if: needs.prepare.outputs.changed == 'true' | |
| 70 | 101 | env: | |
| 71 | 102 | GITHUB_TOKEN: ${{ github.token }} | |
| 72 | 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 | 105 | sha="$(go run ./cmd/wlbcommit -repo "$GITHUB_REPOSITORY" -branch main -message "$message")" | |
| 75 | 106 | echo "commit: $sha" | |
| 76 | 107 | 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() | |
| 79 | 111 | env: | |
| 80 | 112 | GH_TOKEN: ${{ github.token }} | |
| 81 | - | TAG: ${{ steps.version.outputs.tag }} | |
| 82 | - | SHA: ${{ steps.commit.outputs.sha }} | |
| 113 | + | COMMITTED_SHA: ${{ steps.commit.outputs.sha }} | |
| 83 | 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 | 161 | - name: Warm Go module proxy | |
| 86 | - | if: steps.sync.outputs.changed == 'true' | |
| 162 | + | if: steps.release.outputs.tag != '' | |
| 87 | 163 | env: | |
| 88 | - | TAG: ${{ steps.version.outputs.tag }} | |
| 164 | + | TAG: ${{ steps.release.outputs.tag }} | |
| 89 | 165 | run: | | |
| 90 | 166 | 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: write | 9 | + | contents: read |
| 10 | 10 | ||||
| 11 | concurrency: | 11 | concurrency: | ||
| 12 | group: sync | 12 | group: sync | ||
| 13 | cancel-in-progress: false | 13 | 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-latest | 19 | 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@v4 | 25 | + | - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 |
| 20 | with: | 26 | with: | ||
| 21 | - | fetch-depth: 0 | 27 | + | persist-credentials: false |
| 22 | - | - uses: actions/setup-go@v5 | 28 | + | - uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5 |
| 23 | with: | 29 | with: | ||
| 24 | go-version-file: go.mod | 30 | 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 libraries | 31 | - 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 |
| 31 | - name: Sync upstream protocols | 33 | - name: Sync upstream protocols | ||
| 32 | id: sync | 34 | 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)" ]; then | 37 | + | 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: Generate | 40 | - 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: Vet | 43 | - 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 version | 55 | + | - name: Create patch |
| 54 | - | id: version | 56 | + | 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" ]; then | 60 | + | if [ "$CHANGED" = 'true' ]; then |
| 59 | - | tag="v0.1.0" | 61 | + | git add -A |
| 60 | - | else | 62 | + | 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 | fi | 64 | 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 API | 98 | - name: Commit via GitHub API | ||
| 68 | id: commit | 99 | 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 release | 108 | + | - 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 proxy | 161 | - 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" || true | 166 | 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) { | |||
| 200 | 200 | } | |
| 201 | 201 | 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 | 206 | if info.IsDir() { | |
| 204 | 207 | continue // e.g. a submodule; not representable | |
| 205 | 208 | } | |
| @@ -211,11 +214,6 @@ func collectChanges(root, status string) (fileChanges, error) { | |||
| 211 | 214 | changes.Additions = append(changes.Additions, addition{Path: p, Contents: base64.StdEncoding.EncodeToString(data)}) | |
| 212 | 215 | } | |
| 213 | 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 | 217 | changes.Deletions = append(changes.Deletions, deletion{Path: p}) | |
| 220 | 218 | } | |
| 221 | 219 | return changes, nil | |
| @@ -200,6 +200,9 @@ func collectChanges(root, status string) (fileChanges, error) { | |||||
| 200 | } | 200 | } | ||
| 201 | return fileChanges{}, err | 201 | 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 representable | 207 | 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, nil | 219 | return changes, nil | ||
M cmd/wlbcommit/main_test.go +36 -1
| @@ -8,15 +8,33 @@ import ( | |||
| 8 | 8 | "net/http" | |
| 9 | 9 | "net/http/httptest" | |
| 10 | 10 | "os" | |
| 11 | + | "os/exec" | |
| 11 | 12 | "path/filepath" | |
| 12 | 13 | "strings" | |
| 13 | 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 | 34 | func gitT(t *testing.T, dir string, args ...string) string { | |
| 17 | 35 | t.Helper() | |
| 18 | 36 | full := append([]string{ | |
| 19 | - | "-c", "commit.gpgsign=false", | |
| 37 | + | "-c", "commit.gpgsign=false", "-c", "tag.gpgsign=false", | |
| 20 | 38 | "-c", "user.name=test", "-c", "user.email=test@example.com", | |
| 21 | 39 | }, args...) | |
| 22 | 40 | out, err := git(context.Background(), dir, full...) | |
| @@ -39,6 +57,7 @@ func writeFile(t *testing.T, dir, name, content string) { | |||
| 39 | 57 | ||
| 40 | 58 | func newRepo(t *testing.T) (dir, head string) { | |
| 41 | 59 | t.Helper() | |
| 60 | + | isolateGit(t) | |
| 42 | 61 | dir = t.TempDir() | |
| 43 | 62 | gitT(t, dir, "init", "-q") | |
| 44 | 63 | writeFile(t, dir, "keep.txt", "keep\n") | |
| @@ -195,6 +214,22 @@ func TestRunErrors(t *testing.T) { | |||
| 195 | 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 | 233 | t.Run("non-200", func(t *testing.T) { | |
| 199 | 234 | dir, _ := newRepo(t) | |
| 200 | 235 | 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 ( | |||
| 4 | 4 | "os" | |
| 5 | 5 | "os/exec" | |
| 6 | 6 | "path/filepath" | |
| 7 | + | "strings" | |
| 7 | 8 | "testing" | |
| 8 | 9 | ) | |
| 9 | 10 | ||
| 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() | |
| 13 | 15 | if _, err := exec.LookPath("git"); err != nil { | |
| 14 | 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 | 32 | repo := t.TempDir() | |
| 17 | 33 | run := func(args ...string) string { | |
| 18 | 34 | t.Helper() | |
| @@ -41,9 +57,11 @@ func TestGitFetcherLocal(t *testing.T) { | |||
| 41 | 57 | write("two") | |
| 42 | 58 | run("commit", "-q", "-am", "two") | |
| 43 | 59 | 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") | |
| 47 | 65 | run("tag", "not-a-version") | |
| 48 | 66 | ||
| 49 | 67 | 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 local | 11 | + | // 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") // lightweight | 60 | + | run("tag", "1.2.0") // lightweight |
| 45 | - | run("tag", "1.3.1") // odd minor, patch < 90: accepted | 61 | + | 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 { | |||
| 93 | 93 | if err != nil { | |
| 94 | 94 | 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 | 101 | if o.notes != "" { | |
| 99 | 102 | 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 err | 94 | 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) { | |||
| 130 | 130 | } | |
| 131 | 131 | if o.Version != it.Version { | |
| 132 | 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 | 138 | diffMessages(n, pkg, it.Name, "request", o.Requests, it.Requests) | |
| 135 | 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 | 173 | func diffMessages(n *notes, pkg, iface, kind string, old, cur []spec.Message) { | |
| 145 | 174 | curByName := map[string]spec.Message{} | |
| 146 | 175 | for _, m := range cur { | |
| @@ -152,14 +181,18 @@ func diffMessages(n *notes, pkg, iface, kind string, old, cur []spec.Message) { | |||
| 152 | 181 | n.breaking = append(n.breaking, fmt.Sprintf("%s: %s %s %s removed", pkg, iface, kind, o.Name)) | |
| 153 | 182 | 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 | 188 | if change := argsChange(o.Args, m.Args); change != "" { | |
| 156 | 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, | |
| 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. | |
| 163 | 196 | func argsChange(old, cur []spec.Arg) string { | |
| 164 | 197 | if len(old) != len(cur) { | |
| 165 | 198 | return fmt.Sprintf("%d → %d arguments", len(old), len(cur)) | |
| @@ -167,7 +200,8 @@ func argsChange(old, cur []spec.Arg) string { | |||
| 167 | 200 | var diffs []string | |
| 168 | 201 | for i := range old { | |
| 169 | 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 | 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 | 213 | if a.Interface != "" { | |
| 180 | 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 | 222 | return s | |
| 183 | 223 | } | |
| 184 | 224 | ||
| @@ -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 | continue | 182 | 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 []string | 200 | 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 s | 222 | return s | ||
| 183 | } | 223 | } | ||
| 184 | 224 | ||||
M cmd/wlbsync/sources.go +3 -6
| @@ -49,14 +49,11 @@ var ( | |||
| 49 | 49 | tagVThreePart = regexp.MustCompile(`^v\d+\.\d+\.\d+$`) | |
| 50 | 50 | ) | |
| 51 | 51 | ||
| 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. | |
| 54 | 54 | func waylandTagOK(tag string) bool { | |
| 55 | 55 | 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 | |
| 60 | 57 | } | |
| 61 | 58 | ||
| 62 | 59 | // 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 (patch | 52 | + | // 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, | |||
| 89 | 89 | } | |
| 90 | 90 | return nil | |
| 91 | 91 | } | |
| 92 | + | if !d.Type().IsRegular() { | |
| 93 | + | return nil // symlinks and other special files are never vendored | |
| 94 | + | } | |
| 92 | 95 | rel, err := filepath.Rel(dir, p) | |
| 93 | 96 | if err != nil { | |
| 94 | 97 | return err | |
| @@ -252,17 +255,39 @@ func readState(root string) (*state, error) { | |||
| 252 | 255 | return s, nil | |
| 253 | 256 | } | |
| 254 | 257 | ||
| 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 | |
| 259 | 284 | } | |
| 260 | 285 | for name, b := range t.xml { | |
| 261 | 286 | if old, ok := s.xml[name]; !ok || !bytes.Equal(old, b) { | |
| 262 | - | return true | |
| 287 | + | return true, nil | |
| 263 | 288 | } | |
| 264 | 289 | } | |
| 265 | - | return false | |
| 290 | + | return false, nil | |
| 266 | 291 | } | |
| 267 | 292 | ||
| 268 | 293 | // 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 nil | 90 | 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 err | 97 | return err | ||
| @@ -252,17 +255,39 @@ func readState(root string) (*state, error) { | |||||
| 252 | return s, nil | 255 | 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 true | 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 | |||
| 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 true | 287 | + | return true, nil |
| 263 | } | 288 | } | ||
| 264 | } | 289 | } | ||
| 265 | - | return false | 290 | + | 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) { | |||
| 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 := ¬es{} | |
| 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 | 507 | func TestBreakingEventAndRename(t *testing.T) { | |
| 412 | 508 | old := &spec.Protocol{Interfaces: []spec.Interface{{ | |
| 413 | 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 | 554 | func TestSelectionRule(t *testing.T) { | |
| 433 | 555 | cases := []struct { | |
| 434 | 556 | name string | |
| @@ -714,15 +836,23 @@ func TestTagSelection(t *testing.T) { | |||
| 714 | 836 | 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, | |
| 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. | |
| 719 | 841 | if got, err := latestTag(wayland, tags); err != nil || got != "1.11.1" { | |
| 720 | 842 | t.Errorf("wayland latest = %q, %v", got, err) | |
| 721 | 843 | } | |
| 722 | 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 | 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 | 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 | 858 | 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 := ¬es{} | |||
| 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 string | 556 | name string | ||
| @@ -714,15 +836,23 @@ func TestTagSelection(t *testing.T) { | |||||
| 714 | plasma = s | 836 | 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) | ||