Skip to content

Commit e112470

Browse files
authored
ci: enforce contract compatibility on pull requests (#161)
1 parent 4de8cd8 commit e112470

2 files changed

Lines changed: 133 additions & 6 deletions

File tree

‎.github/workflows/test.yml‎

Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -70,23 +70,33 @@ jobs:
7070

7171
contract:
7272
name: contract schema (L2)
73-
if: github.event_name != 'pull_request'
7473
runs-on: ubuntu-latest
7574
steps:
7675
- name: Checkout code
7776
uses: actions/checkout@v4
7877

78+
- name: Checkout contract
79+
uses: actions/checkout@v4
80+
with:
81+
repository: openbootdotdev/openboot-contract
82+
ref: ${{ github.event.client_payload.ref || 'main' }}
83+
path: .contract
84+
85+
- name: Set up Go
86+
uses: actions/setup-go@v5
87+
with:
88+
go-version-file: "go.mod"
89+
7990
- name: Contract schema validation
8091
run: |
81-
git clone --depth 1 https://github.com/openbootdotdev/openboot-contract.git /tmp/contract
8292
pip3 install --break-system-packages jsonschema
8393
8494
python3 -c "
8595
import json, jsonschema, sys
8696
8797
checks = [
88-
('/tmp/contract/schemas/remote-config.json', '/tmp/contract/fixtures/config-v1.json'),
89-
('/tmp/contract/schemas/snapshot.json', '/tmp/contract/fixtures/snapshot-v1.json'),
98+
('.contract/schemas/remote-config.json', '.contract/fixtures/config-v1.json'),
99+
('.contract/schemas/snapshot.json', '.contract/fixtures/snapshot-v1.json'),
90100
]
91101
92102
failed = 0
@@ -103,9 +113,13 @@ jobs:
103113
sys.exit(1 if failed else 0)
104114
"
105115
116+
- name: Verify CLI consumes canonical fixtures losslessly
117+
env:
118+
OPENBOOT_CONTRACT_DIR: ${{ github.workspace }}/.contract
119+
run: go test -v -tags=contract ./test/contract
120+
106121
curl-bash-smoke:
107122
name: curl|bash smoke
108-
if: github.event_name != 'pull_request'
109123
runs-on: macos-latest
110124
steps:
111125
- name: Checkout code
@@ -137,7 +151,6 @@ jobs:
137151

138152
cli-compat:
139153
name: old-cli compat
140-
if: github.event_name != 'pull_request'
141154
runs-on: macos-latest
142155
steps:
143156
- name: Checkout code

‎test/contract/contract_test.go‎

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
//go:build contract
2+
3+
package contract_test
4+
5+
import (
6+
"bytes"
7+
"encoding/json"
8+
"os"
9+
"path/filepath"
10+
"testing"
11+
12+
"github.com/stretchr/testify/assert"
13+
"github.com/stretchr/testify/require"
14+
15+
"github.com/openbootdotdev/openboot/internal/config"
16+
"github.com/openbootdotdev/openboot/internal/snapshot"
17+
)
18+
19+
type canonicalPackageEntry struct {
20+
Name string `json:"name"`
21+
Desc string `json:"desc"`
22+
}
23+
24+
type canonicalRemoteConfig struct {
25+
Username string `json:"username"`
26+
Slug string `json:"slug"`
27+
Name string `json:"name"`
28+
Preset string `json:"preset"`
29+
Packages []canonicalPackageEntry `json:"packages"`
30+
Casks []canonicalPackageEntry `json:"casks"`
31+
Taps []string `json:"taps"`
32+
Npm []canonicalPackageEntry `json:"npm"`
33+
DotfilesRepo string `json:"dotfiles_repo"`
34+
PostInstall []string `json:"post_install"`
35+
Shell *config.RemoteShellConfig `json:"shell"`
36+
MacOSPrefs []config.RemoteMacOSPref `json:"macos_prefs"`
37+
}
38+
39+
func TestRemoteConfigFixtureIsConsumedLosslessly(t *testing.T) {
40+
data := readContractFixture(t, "config-v1.json")
41+
42+
var wire canonicalRemoteConfig
43+
decoder := json.NewDecoder(bytes.NewReader(data))
44+
decoder.DisallowUnknownFields()
45+
require.NoError(t, decoder.Decode(&wire), "contract fixture must use the canonical remote-config shape")
46+
require.NotEmpty(t, wire.Packages, "fixture must exercise formulae")
47+
require.NotEmpty(t, wire.Casks, "fixture must exercise casks")
48+
require.NotEmpty(t, wire.Taps, "fixture must exercise taps")
49+
require.NotEmpty(t, wire.Npm, "fixture must exercise npm packages")
50+
51+
got, err := config.UnmarshalRemoteConfigFlexible(data)
52+
require.NoError(t, err)
53+
require.NoError(t, got.Validate())
54+
55+
want := &config.RemoteConfig{
56+
Username: wire.Username,
57+
Slug: wire.Slug,
58+
Name: wire.Name,
59+
Preset: wire.Preset,
60+
Packages: packageEntries(wire.Packages),
61+
Casks: packageEntries(wire.Casks),
62+
Taps: wire.Taps,
63+
Npm: packageEntries(wire.Npm),
64+
DotfilesRepo: wire.DotfilesRepo,
65+
PostInstall: wire.PostInstall,
66+
Shell: wire.Shell,
67+
MacOSPrefs: wire.MacOSPrefs,
68+
}
69+
assert.Equal(t, want, got, "CLI decoding must not repair, move, or drop fields from the canonical fixture")
70+
}
71+
72+
func TestSnapshotFixtureIsConsumedLosslessly(t *testing.T) {
73+
data := readContractFixture(t, "snapshot-v1.json")
74+
75+
var wire struct {
76+
Packages struct {
77+
Formulae []string `json:"formulae"`
78+
Casks []string `json:"casks"`
79+
Taps []string `json:"taps"`
80+
Npm []string `json:"npm"`
81+
} `json:"packages"`
82+
}
83+
require.NoError(t, json.Unmarshal(data, &wire))
84+
require.NotEmpty(t, wire.Packages.Formulae, "fixture must exercise formulae")
85+
require.NotEmpty(t, wire.Packages.Casks, "fixture must exercise casks")
86+
require.NotEmpty(t, wire.Packages.Taps, "fixture must exercise taps")
87+
require.NotEmpty(t, wire.Packages.Npm, "fixture must exercise npm packages")
88+
89+
got, err := snapshot.ParseBytes(data)
90+
require.NoError(t, err)
91+
assert.Equal(t, wire.Packages.Formulae, got.Packages.Formulae)
92+
assert.Equal(t, wire.Packages.Casks, got.Packages.Casks)
93+
assert.Equal(t, wire.Packages.Taps, got.Packages.Taps)
94+
assert.Equal(t, wire.Packages.Npm, got.Packages.Npm)
95+
}
96+
97+
func readContractFixture(t *testing.T, name string) []byte {
98+
t.Helper()
99+
100+
contractDir := os.Getenv("OPENBOOT_CONTRACT_DIR")
101+
require.NotEmpty(t, contractDir, "OPENBOOT_CONTRACT_DIR must point to an openboot-contract checkout")
102+
103+
data, err := os.ReadFile(filepath.Join(contractDir, "fixtures", name))
104+
require.NoError(t, err)
105+
return data
106+
}
107+
108+
func packageEntries(entries []canonicalPackageEntry) config.PackageEntryList {
109+
result := make(config.PackageEntryList, len(entries))
110+
for i, entry := range entries {
111+
result[i] = config.PackageEntry{Name: entry.Name, Desc: entry.Desc}
112+
}
113+
return result
114+
}

0 commit comments

Comments
 (0)