Skip to content

Commit 4b491a2

Browse files
committed
Add verified release installer
1 parent c381b68 commit 4b491a2

10 files changed

Lines changed: 291 additions & 13 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@
3939
- Added a public JSON Schema for evidence bundle manifests and test coverage that validates exported manifests against it.
4040
- Added `openexit verify-bundle` for offline evidence bundle verification with text and JSON reports.
4141
- Added `openexit release-manifest` and `openexit verify-release` for schema-backed release artifact manifests and offline binary/checksum verification.
42+
- Added a release installer script that detects the local platform, verifies the selected artifact against `SHA256SUMS` and `RELEASE_MANIFEST.json`, and is smoke-tested by the release gate.
4243
- Added `make release-check` and wired the draft-release workflow to run the full release gate before packaging artifacts.
4344
- Updated push CI to run the same release readiness gate, including smoke pipelines and bundle verification.
4445
- Updated CI and release workflows to Node.js 24-native GitHub Actions.

Makefile

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ EXAMPLE_INPUT ?= examples/datadog-to-grafana/input/datadog-fixture.json
1313
EXAMPLE_DIR ?= examples/datadog-to-grafana/output
1414
EXAMPLE_BUNDLE ?= examples/datadog-to-grafana/openexit-example.zip
1515

16-
.PHONY: build test fmt fmt-check lint golangci-lint smoke example example-smoke verify release-dist release-check clean
16+
.PHONY: build test fmt fmt-check lint golangci-lint smoke example example-smoke verify release-dist install-smoke release-check clean
1717

1818
build:
1919
mkdir -p $(dir $(BINARY))
@@ -130,10 +130,19 @@ release-dist:
130130
done
131131
cd dist && sha256sum openexit_* > SHA256SUMS
132132
$(GO) run -trimpath -ldflags "$(LDFLAGS)" ./cmd/openexit release-manifest --dist dist --out dist/$(RELEASE_MANIFEST) $(foreach target,$(PLATFORMS),--platform $(target))
133+
cp scripts/install.sh dist/install.sh
134+
chmod +x dist/install.sh
133135

134-
release-check: verify release-dist
136+
install-smoke: release-dist
137+
tmp=$$(mktemp -d); \
138+
trap 'rm -rf "$$tmp"' EXIT; \
139+
OPENEXIT_VERSION=$(VERSION) OPENEXIT_BASE_URL=$(CURDIR)/dist BIN_DIR="$$tmp/bin" sh scripts/install.sh; \
140+
"$$tmp/bin/openexit" version | grep -q 'version: $(VERSION)'
141+
142+
release-check: verify release-dist install-smoke
135143
@test -s dist/SHA256SUMS
136144
@test -s dist/$(RELEASE_MANIFEST)
145+
@test -x dist/install.sh
137146
@expected=$$(printf '%s\n' $(PLATFORMS) | wc -w | tr -d ' '); \
138147
actual=$$(wc -l < dist/SHA256SUMS | tr -d ' '); \
139148
if [ "$$actual" != "$$expected" ]; then \

README.md

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,16 @@ make build
2525

2626
`openexit demo` uses built-in redacted fixture data, runs the deterministic workflow, validates the output, and writes `./demo/openexit-demo.zip`.
2727

28+
## Install Release Binary
29+
30+
```bash
31+
curl -fsSL https://github.com/RamazanKara/openexit/releases/latest/download/install.sh | sh
32+
openexit doctor
33+
openexit demo ./demo
34+
```
35+
36+
The installer detects Linux or macOS plus `amd64` or `arm64`, downloads the matching release binary, verifies it against `SHA256SUMS`, verifies that artifact against `RELEASE_MANIFEST.json`, and installs `openexit` into `/usr/local/bin` when writable or `~/.local/bin` otherwise. Set `OPENEXIT_VERSION=v0.1.0` for a specific release or `BIN_DIR=/path/to/bin` for a custom install location.
37+
2838
## Install From Source
2939

3040
```bash
@@ -77,7 +87,7 @@ make example VERSION=0.1.0-dev
7787
- `openexit export --project <project-dir> --format zip --out <file>`
7888
- `openexit verify-bundle <file> [--json]`
7989
- `openexit release-manifest [--dist dist --out dist/RELEASE_MANIFEST.json]`
80-
- `openexit verify-release <manifest.json> [--dist dist] [--require-checksums] [--json]`
90+
- `openexit verify-release <manifest.json> [--dist dist] [--artifact <name>] [--require-checksums] [--json]`
8191
- `openexit assist summarize --project <project-dir> --provider noop`
8292

8393
The Datadog, GitHub, Okta, Auth0, Cloudflare, Akamai, OpenAI, and Anthropic collectors are read-only. API tokens are read from environment variables or local credential files, are not printed, and are not stored.
@@ -127,6 +137,7 @@ Included in the current implementation:
127137
- Offline evidence bundle verification for manifest schema, checksums, digest/size metadata, and archive path safety.
128138
- Release artifact manifest generation with per-binary OS/architecture metadata, size, and SHA-256 digests.
129139
- Offline release artifact verification against `RELEASE_MANIFEST.json` and optional `SHA256SUMS`.
140+
- Release installer script that selects the current platform binary and verifies it before installation.
130141
- Evidence bundle path-safety checks that reject symlinks in exported project sections.
131142
- No-op assist provider and explicit opt-in LiteLLM assist.
132143
- GitHub Enterprise to Forgejo assessment path with fixture import and live repository inventory collection.

docs/cli.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ The checked-in Datadog example can be refreshed with `make example VERSION=0.1.0
3737

3838
`openexit release-manifest --dist dist --out dist/RELEASE_MANIFEST.json` writes a machine-readable manifest for release binaries. It records the stamped release version, commit, build date, generation time, and each expected OS/architecture artifact with file name, relative path, size, and SHA-256 digest. The manifest shape is published as `schemas/openexit.release-manifest.schema.json`.
3939

40-
`openexit verify-release dist/RELEASE_MANIFEST.json --dist dist --require-checksums` verifies release binaries after download or before publishing. It validates the manifest schema, rejects unsafe artifact paths, recomputes file sizes and SHA-256 digests, and verifies `SHA256SUMS` when required. Use `--json` to feed the verification report into a release gate.
40+
`openexit verify-release dist/RELEASE_MANIFEST.json --dist dist --require-checksums` verifies release binaries after download or before publishing. It validates the manifest schema, rejects unsafe artifact paths, recomputes file sizes and SHA-256 digests, and verifies `SHA256SUMS` when required. Use repeatable `--artifact <name>` to verify only the current platform artifact after download, or `--json` to feed the verification report into a release gate.
4141

4242
Generate individual artifacts with `openexit generate --artifact <name>`. The primary Datadog path supports `mapping`, `assessment`, `risk-register`, `manual-review`, `cost-drivers`, `target-architecture`, `acceptance-criteria`, `rollback-plan`, `runbook`, `restore-drill-checklist`, `alert-shadowing-plan`, `migration-plan`, `grafana-dashboards`, `prometheus-rules`, `opentelemetry`, and `argocd`. The GitHub Enterprise path also supports `forgejo-migration-candidate`; the identity path also supports `realm-client-candidate`; the edge path also supports `vcl-candidates`, `haproxy-candidates`, and `coraza-rule-candidates`; the AI provider path also supports `litellm-config-candidate`.
4343

docs/release.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ Release-blocking v0.1 requirements:
2121
- Evidence bundle export with checksums, a schema-backed machine-readable manifest, and OpenExit version metadata.
2222
- Offline evidence bundle verification for archive path safety, manifest schema, manifest digests, and checksums.
2323
- Machine-readable release manifest generation and offline release artifact verification for OS/architecture binaries and `SHA256SUMS`.
24+
- Verified release installer for Linux/macOS `amd64` and `arm64` downloads.
2425
- No-op AI assist and optional external assist behind explicit opt-in.
2526
- Documentation, examples, CI, release draft workflow, and reproducible release artifacts.
2627

@@ -37,11 +38,12 @@ The AI provider path is complete for local fixture assessment workflows and incl
3738

3839
- [ ] `git status --short --branch` is clean and on the intended release branch.
3940
- [ ] GitHub Actions CI passes the same `make release-check VERSION=0.1.0-ci` gate used for local release readiness.
40-
- [ ] `make release-check VERSION=0.1.0` passes locally, including verification, CLI smoke pipelines, bundle verification, release artifact builds, and checksum count checks.
41+
- [ ] `make release-check VERSION=0.1.0` passes locally, including verification, CLI smoke pipelines, bundle verification, release artifact builds, installer smoke, and checksum count checks.
4142
- [ ] `make verify VERSION=0.1.0` passes, including CLI smoke pipelines.
4243
- [ ] `make lint` runs `gofmt`, `golangci-lint`, and `go vet`.
43-
- [ ] `make release-dist VERSION=0.1.0` produces binaries, `dist/SHA256SUMS`, and `dist/RELEASE_MANIFEST.json`.
44+
- [ ] `make release-dist VERSION=0.1.0` produces binaries, `dist/SHA256SUMS`, `dist/RELEASE_MANIFEST.json`, and `dist/install.sh`.
4445
- [ ] `openexit verify-release dist/RELEASE_MANIFEST.json --dist dist --require-checksums` passes and fails when a release binary is tampered with.
46+
- [ ] `OPENEXIT_VERSION=0.1.0 OPENEXIT_BASE_URL=$PWD/dist BIN_DIR=$(mktemp -d)/bin sh scripts/install.sh` installs a verified local release binary and `openexit version` reports `0.1.0`.
4547
- [ ] `make example VERSION=0.1.0-dev` refreshes `examples/datadog-to-grafana/output/` and exports `examples/datadog-to-grafana/openexit-example.zip`.
4648
- [ ] Datadog definition-of-done pipeline passes:
4749
`init`, `collect fixture`, `assess`, `map`, `generate --all`, `validate`, `export`.

internal/app/command.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -974,6 +974,7 @@ func newReleaseManifestCommand() *cobra.Command {
974974

975975
func newVerifyReleaseCommand() *cobra.Command {
976976
var distDir string
977+
var artifacts []string
977978
var jsonOutput, requireChecksums bool
978979
cmd := &cobra.Command{
979980
Use: "verify-release <manifest.json>",
@@ -984,6 +985,7 @@ func newVerifyReleaseCommand() *cobra.Command {
984985
ManifestPath: args[0],
985986
DistDir: distDir,
986987
RequireChecksums: requireChecksums,
988+
Artifacts: artifacts,
987989
})
988990
if jsonOutput {
989991
enc := json.NewEncoder(cmd.OutOrStdout())
@@ -998,6 +1000,7 @@ func newVerifyReleaseCommand() *cobra.Command {
9981000
},
9991001
}
10001002
cmd.Flags().StringVar(&distDir, "dist", "", "Release artifact directory; defaults to the manifest directory")
1003+
cmd.Flags().StringArrayVar(&artifacts, "artifact", nil, "Verify only a named artifact path; repeatable")
10011004
cmd.Flags().BoolVar(&requireChecksums, "require-checksums", false, "Require and verify SHA256SUMS alongside the manifest")
10021005
cmd.Flags().BoolVar(&jsonOutput, "json", false, "Write machine-readable release verification report")
10031006
return cmd

internal/app/e2e_test.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,14 @@ func TestReleaseManifestAndVerifyCommands(t *testing.T) {
178178
}
179179
}
180180

181+
out, err = executeForTestWithOutput("verify-release", manifestPath, "--dist", distDir, "--require-checksums", "--artifact", manifest.Artifacts[0].Path)
182+
if err != nil {
183+
t.Fatalf("openexit verify-release --artifact failed: %v\n%s", err, out)
184+
}
185+
if !strings.Contains(out, "artifacts: 1") {
186+
t.Fatalf("expected single-artifact verification, got:\n%s", out)
187+
}
188+
181189
jsonOut, err := executeForTestWithOutput("verify-release", manifestPath, "--dist", distDir, "--require-checksums", "--json")
182190
if err != nil {
183191
t.Fatalf("openexit verify-release --json failed: %v\n%s", err, jsonOut)

internal/release/manifest.go

Lines changed: 57 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@ type VerifyOptions struct {
7070
ManifestPath string
7171
DistDir string
7272
RequireChecksums bool
73+
Artifacts []string
7374
}
7475

7576
type VerificationReport struct {
@@ -201,8 +202,13 @@ func Verify(opts VerifyOptions) (*VerificationReport, error) {
201202
report.GeneratedAt = manifest.GeneratedAt
202203

203204
seen := map[string]struct{}{}
205+
filters := artifactFilters(opts.Artifacts, report)
206+
matchedFilters := map[string]struct{}{}
204207
checksums := readChecksums(filepath.Join(distDir, ChecksumFileName), report, opts.RequireChecksums)
205208
for _, artifact := range manifest.Artifacts {
209+
if len(filters) > 0 && !artifactSelected(artifact, filters, matchedFilters) {
210+
continue
211+
}
206212
result := ArtifactVerification{
207213
Name: artifact.Name,
208214
OS: artifact.OS,
@@ -222,11 +228,13 @@ func Verify(opts VerifyOptions) (*VerificationReport, error) {
222228
resultError(&result, "duplicate artifact path")
223229
}
224230
seen[artifact.Path] = struct{}{}
231+
actualDigest := ""
225232
if result.Status == "passed" {
226233
info, digest, err := fileDigest(filepath.Join(distDir, filepath.FromSlash(artifact.Path)))
227234
if err != nil {
228235
resultError(&result, err.Error())
229236
} else {
237+
actualDigest = digest
230238
if info.Size() != artifact.Size {
231239
resultError(&result, "size mismatch")
232240
}
@@ -241,6 +249,8 @@ func Verify(opts VerifyOptions) (*VerificationReport, error) {
241249
resultError(&result, "missing SHA256SUMS entry")
242250
} else if digest != artifact.SHA256 {
243251
resultError(&result, "SHA256SUMS digest mismatch")
252+
} else if actualDigest != "" && digest != actualDigest {
253+
resultError(&result, "SHA256SUMS digest mismatch")
244254
}
245255
}
246256
if result.Status != "passed" {
@@ -250,6 +260,23 @@ func Verify(opts VerifyOptions) (*VerificationReport, error) {
250260
}
251261
report.Artifacts = append(report.Artifacts, result)
252262
}
263+
if len(filters) > 0 {
264+
for filter := range filters {
265+
if _, ok := matchedFilters[filter]; !ok {
266+
reportError(report, "manifest has no artifact "+filter)
267+
}
268+
}
269+
}
270+
if len(report.Artifacts) == 0 {
271+
reportError(report, "manifest has no artifacts to verify")
272+
}
273+
if checksums != nil && len(filters) == 0 {
274+
for rel := range checksums {
275+
if _, ok := seen[rel]; !ok {
276+
reportError(report, "SHA256SUMS entry not present in manifest "+rel)
277+
}
278+
}
279+
}
253280
return finishReport(report)
254281
}
255282

@@ -337,17 +364,40 @@ func readChecksums(path string, report *VerificationReport, require bool) map[st
337364
reportError(report, "duplicate SHA256SUMS entry "+rel)
338365
continue
339366
}
340-
_, actual, err := fileDigest(filepath.Join(filepath.Dir(path), filepath.FromSlash(rel)))
341-
if err != nil {
342-
reportError(report, "SHA256SUMS "+rel+": "+err.Error())
367+
checksums[rel] = digest
368+
}
369+
return checksums
370+
}
371+
372+
func artifactFilters(artifacts []string, report *VerificationReport) map[string]struct{} {
373+
if len(artifacts) == 0 {
374+
return nil
375+
}
376+
filters := map[string]struct{}{}
377+
for _, artifact := range artifacts {
378+
artifact = strings.TrimSpace(artifact)
379+
if artifact == "" {
380+
reportError(report, "empty artifact filter")
381+
continue
382+
}
383+
if err := safeArtifactPath(artifact); err != nil {
384+
reportError(report, "artifact filter "+artifact+": "+err.Error())
343385
continue
344386
}
345-
if actual != digest {
346-
reportError(report, "SHA256SUMS digest mismatch for "+rel)
387+
filters[artifact] = struct{}{}
388+
}
389+
return filters
390+
}
391+
392+
func artifactSelected(artifact Artifact, filters, matched map[string]struct{}) bool {
393+
selected := false
394+
for _, candidate := range []string{artifact.Path, artifact.Name} {
395+
if _, ok := filters[candidate]; ok {
396+
matched[candidate] = struct{}{}
397+
selected = true
347398
}
348-
checksums[rel] = digest
349399
}
350-
return checksums
400+
return selected
351401
}
352402

353403
func safeArtifactPath(rel string) error {

internal/release/manifest_test.go

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,56 @@ func TestVerifyManifestRejectsTamperedArtifact(t *testing.T) {
8484
}
8585
}
8686

87+
func TestVerifyManifestCanCheckSingleArtifactSubset(t *testing.T) {
88+
distDir := t.TempDir()
89+
version := "1.2.3"
90+
linux := ArtifactName(version, "linux", "amd64")
91+
windows := ArtifactName(version, "windows", "amd64")
92+
writeArtifact(t, distDir, linux, "linux binary")
93+
writeArtifact(t, distDir, windows, "windows binary")
94+
manifest, err := Generate(ManifestOptions{
95+
DistDir: distDir,
96+
Version: version,
97+
Commit: "abc123",
98+
Date: "2026-05-27T00:00:00Z",
99+
Platforms: []string{"linux/amd64", "windows/amd64"},
100+
})
101+
if err != nil {
102+
t.Fatalf("generate manifest: %v", err)
103+
}
104+
manifestPath := filepath.Join(distDir, DefaultManifest)
105+
if err := Write(manifestPath, manifest); err != nil {
106+
t.Fatalf("write manifest: %v", err)
107+
}
108+
if err := writeSHA256SUMS(distDir, manifest.Artifacts); err != nil {
109+
t.Fatalf("write checksums: %v", err)
110+
}
111+
if err := os.Remove(filepath.Join(distDir, windows)); err != nil {
112+
t.Fatal(err)
113+
}
114+
115+
report, err := Verify(VerifyOptions{
116+
ManifestPath: manifestPath,
117+
DistDir: distDir,
118+
RequireChecksums: true,
119+
Artifacts: []string{linux},
120+
})
121+
if err != nil {
122+
t.Fatalf("verify filtered release: %v", err)
123+
}
124+
if report.Status != "passed" || len(report.Artifacts) != 1 || report.Artifacts[0].Path != linux {
125+
t.Fatalf("unexpected filtered verification report: %+v", report)
126+
}
127+
128+
report, err = Verify(VerifyOptions{ManifestPath: manifestPath, DistDir: distDir, RequireChecksums: true})
129+
if err == nil {
130+
t.Fatalf("expected full verification to fail after removing artifact, got %+v", report)
131+
}
132+
if !strings.Contains(strings.Join(report.Errors, "\n"), windows) {
133+
t.Fatalf("expected missing windows artifact error, got %+v", report.Errors)
134+
}
135+
}
136+
87137
func writeArtifact(t *testing.T, distDir, name, content string) {
88138
t.Helper()
89139
if err := os.WriteFile(filepath.Join(distDir, name), []byte(content), 0o755); err != nil {

0 commit comments

Comments
 (0)