Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 71 additions & 2 deletions .github/scripts/package-release.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,22 +2,43 @@ package main

import (
"archive/zip"
"bytes"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"flag"
"fmt"
"io"
"os"
"path/filepath"
)

type artifactManifest struct {
SchemaVersion int `json:"schema_version"`
Version string `json:"version"`
GOOS string `json:"goos"`
GOARCH string `json:"goarch"`
SourceCommit string `json:"source_commit"`
Archive string `json:"archive"`
ArchiveSHA256 string `json:"archive_sha256"`
Library string `json:"library"`
LibrarySize int64 `json:"library_size"`
LibrarySHA256 string `json:"library_sha256"`
}

func main() {
libraryPath := flag.String("library", "", "compiled shared library")
archivePath := flag.String("archive", "", "output zip")
checksumPath := flag.String("checksum", "", "output checksum")
manifestPath := flag.String("manifest", "", "output artifact manifest")
version := flag.String("version", "", "plugin version")
goos := flag.String("goos", "", "target operating system")
goarch := flag.String("goarch", "", "target architecture")
sourceCommit := flag.String("source-commit", "", "source commit hash")
flag.Parse()
if *libraryPath == "" || *archivePath == "" || *checksumPath == "" {
fatalf("library, archive, and checksum are required")
if *libraryPath == "" || *archivePath == "" || *checksumPath == "" || *manifestPath == "" ||
*version == "" || *goos == "" || *goarch == "" || *sourceCommit == "" {
fatalf("library, archive, checksum, manifest, version, goos, goarch, and source-commit are required")
}
data, errPackage := packageLibrary(*libraryPath, *archivePath)
if errPackage != nil {
Expand All @@ -28,6 +49,54 @@ func main() {
if errWrite := os.WriteFile(*checksumPath, []byte(line), 0o644); errWrite != nil {
fatalf("checksum: %v", errWrite)
}
manifest, errManifest := buildArtifactManifest(*libraryPath, *archivePath, data, *version, *goos, *goarch, *sourceCommit)
if errManifest != nil {
fatalf("manifest: %v", errManifest)
}
manifestJSON, errMarshal := json.MarshalIndent(manifest, "", " ")
if errMarshal != nil {
fatalf("manifest: %v", errMarshal)
}
manifestJSON = append(manifestJSON, '\n')
if errWrite := os.WriteFile(*manifestPath, manifestJSON, 0o644); errWrite != nil {
fatalf("manifest: %v", errWrite)
}
}

func buildArtifactManifest(libraryPath, archivePath string, archiveData []byte, version, goos, goarch, sourceCommit string) (artifactManifest, error) {
reader, errOpen := zip.NewReader(bytes.NewReader(archiveData), int64(len(archiveData)))
if errOpen != nil {
return artifactManifest{}, errOpen
}
if len(reader.File) != 1 || reader.File[0].Name != filepath.Base(libraryPath) {
return artifactManifest{}, fmt.Errorf("archive must contain only %s", filepath.Base(libraryPath))
}
library, errLibraryOpen := reader.File[0].Open()
if errLibraryOpen != nil {
return artifactManifest{}, errLibraryOpen
}
libraryData, errRead := io.ReadAll(library)
errClose := library.Close()
if errRead != nil {
return artifactManifest{}, errRead
}
if errClose != nil {
return artifactManifest{}, errClose
}
archiveSum := sha256.Sum256(archiveData)
librarySum := sha256.Sum256(libraryData)
return artifactManifest{
SchemaVersion: 1,
Version: version,
GOOS: goos,
GOARCH: goarch,
SourceCommit: sourceCommit,
Archive: filepath.Base(archivePath),
ArchiveSHA256: hex.EncodeToString(archiveSum[:]),
Library: reader.File[0].Name,
LibrarySize: int64(len(libraryData)),
LibrarySHA256: hex.EncodeToString(librarySum[:]),
}, nil
}

func packageLibrary(libraryPath, archivePath string) ([]byte, error) {
Expand Down
64 changes: 64 additions & 0 deletions .github/scripts/package-release_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
package main

import (
"archive/zip"
"bytes"
"crypto/sha256"
"encoding/hex"
"io"
"os"
"path/filepath"
"testing"
)

func TestPackageManifestMatchesArchiveAndLibrary(t *testing.T) {
tempDir := t.TempDir()
libraryPath := filepath.Join(tempDir, "codex-switch-safe.dll")
archivePath := filepath.Join(tempDir, "codex-switch-safe_1.2.3_windows_amd64.zip")
libraryData := []byte("test shared library")
if errWrite := os.WriteFile(libraryPath, libraryData, 0o755); errWrite != nil {
t.Fatal(errWrite)
}

archiveData, errPackage := packageLibrary(libraryPath, archivePath)
if errPackage != nil {
t.Fatal(errPackage)
}
manifest, errManifest := buildArtifactManifest(libraryPath, archivePath, archiveData, "1.2.3", "windows", "amd64", "commit-sha")
if errManifest != nil {
t.Fatal(errManifest)
}
if manifest.SchemaVersion != 1 || manifest.Version != "1.2.3" || manifest.GOOS != "windows" || manifest.GOARCH != "amd64" || manifest.SourceCommit != "commit-sha" {
t.Fatalf("manifest metadata = %#v", manifest)
}
if manifest.LibrarySize != int64(len(libraryData)) || manifest.Archive == "" || manifest.ArchiveSHA256 == "" || manifest.LibrarySHA256 == "" {
t.Fatalf("manifest hashes = %#v", manifest)
}

reader, errOpen := zip.NewReader(bytes.NewReader(archiveData), int64(len(archiveData)))
if errOpen != nil {
t.Fatal(errOpen)
}
if len(reader.File) != 1 || reader.File[0].Name != manifest.Library {
t.Fatalf("archive entries = %#v", reader.File)
}
entry, errEntry := reader.File[0].Open()
if errEntry != nil {
t.Fatal(errEntry)
}
packagedLibrary, errRead := io.ReadAll(entry)
if errRead != nil {
t.Fatal(errRead)
}
if errClose := entry.Close(); errClose != nil {
t.Fatal(errClose)
}
if !bytes.Equal(packagedLibrary, libraryData) {
t.Fatalf("packaged library = %q", packagedLibrary)
}
archiveSum := sha256.Sum256(archiveData)
librarySum := sha256.Sum256(packagedLibrary)
if manifest.ArchiveSHA256 != hex.EncodeToString(archiveSum[:]) || manifest.LibrarySHA256 != hex.EncodeToString(librarySum[:]) {
t.Fatalf("manifest hashes = %#v", manifest)
}
}
42 changes: 39 additions & 3 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,9 @@ jobs:
cache: true
- name: Test
run: |
go test ./...
CGO_ENABLED=0 go test ./...
CGO_ENABLED=1 go test ./...
go test ./.github/scripts
go test ./.github/scripts/extract-release-notes
- name: Race detector
run: go test -race ./...
Expand Down Expand Up @@ -79,6 +81,7 @@ jobs:
VERSION="0.0.0-dev"
fi
echo "VERSION=${VERSION}" >> "${GITHUB_ENV}"
echo "SOURCE_COMMIT=$(git rev-parse HEAD)" >> "${GITHUB_ENV}"
echo "LIB_NAME=${PLUGIN_ID}.${{ matrix.ext }}" >> "${GITHUB_ENV}"
echo "ARCHIVE_NAME=${PLUGIN_ID}_${VERSION}_${{ matrix.goos }}_${{ matrix.goarch }}.zip" >> "${GITHUB_ENV}"
- name: Build shared library
Expand All @@ -95,6 +98,11 @@ jobs:
-library "dist/${LIB_NAME}"
-archive "${ARCHIVE_NAME}"
-checksum "${ARCHIVE_NAME}.sha256"
-manifest "${ARCHIVE_NAME}.artifact.json"
-version "${VERSION}"
-goos "${{ matrix.goos }}"
-goarch "${{ matrix.goarch }}"
-source-commit "${SOURCE_COMMIT}"
- name: Upload release artifact
if: startsWith(github.ref, 'refs/tags/v')
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
Expand All @@ -103,6 +111,7 @@ jobs:
path: |
${{ env.ARCHIVE_NAME }}
${{ env.ARCHIVE_NAME }}.sha256
${{ env.ARCHIVE_NAME }}.artifact.json

build-windows-arm64:
needs: test
Expand All @@ -122,6 +131,7 @@ jobs:
VERSION="0.0.0-dev"
fi
echo "VERSION=${VERSION}" >> "${GITHUB_ENV}"
echo "SOURCE_COMMIT=$(git rev-parse HEAD)" >> "${GITHUB_ENV}"
echo "version=${VERSION}" >> "${GITHUB_OUTPUT}"
echo "ARCHIVE_NAME=${PLUGIN_ID}_${VERSION}_windows_arm64.zip" >> "${GITHUB_ENV}"
- uses: go-cross/cgo-actions@d0b8f2f2d67923ce9a42d92a7ef0ed1ebd905f0a # v1
Expand All @@ -141,7 +151,12 @@ jobs:
go run ./.github/scripts/package-release.go \
-library "dist/windows-arm64/${PLUGIN_ID}.dll" \
-archive "${ARCHIVE_NAME}" \
-checksum "${ARCHIVE_NAME}.sha256"
-checksum "${ARCHIVE_NAME}.sha256" \
-manifest "${ARCHIVE_NAME}.artifact.json" \
-version "${VERSION}" \
-goos "windows" \
-goarch "arm64" \
-source-commit "${SOURCE_COMMIT}"
- name: Upload release artifact
if: startsWith(github.ref, 'refs/tags/v')
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
Expand All @@ -150,6 +165,7 @@ jobs:
path: |
${{ env.ARCHIVE_NAME }}
${{ env.ARCHIVE_NAME }}.sha256
${{ env.ARCHIVE_NAME }}.artifact.json

ci:
if: always()
Expand Down Expand Up @@ -199,14 +215,33 @@ jobs:

test "$(find dist -maxdepth 1 -type f -name '*.zip' | wc -l)" -eq 6
test "$(find dist -maxdepth 1 -type f -name '*.sha256' | wc -l)" -eq 6
test "$(find dist -maxdepth 1 -type f -name '*.artifact.json' | wc -l)" -eq 6
sort dist/*.sha256 > dist/checksums.txt
(cd dist && sha256sum -c checksums.txt)

jq -s 'sort_by(.goos, .goarch)' dist/*.artifact.json > dist/artifact-manifest.json
jq -e --arg version "${VERSION}" --arg commit "$(git rev-parse HEAD)" \
'length == 6 and
all(.[]; .schema_version == 1 and .version == $version and .source_commit == $commit) and
(map(.goos + "/" + .goarch) | unique | length) == 6 and
(map(.archive) | unique | length) == 6' \
dist/artifact-manifest.json >/dev/null
while IFS= read -r manifest; do
archive="$(jq -r '.archive' "${manifest}")"
library="$(jq -r '.library' "${manifest}")"
expected_archive="$(jq -r '.archive_sha256' "${manifest}")"
expected_library="$(jq -r '.library_sha256' "${manifest}")"
expected_size="$(jq -r '.library_size' "${manifest}")"
test "$(sha256sum "dist/${archive}" | cut -d' ' -f1)" = "${expected_archive}"
test "$(unzip -p "dist/${archive}" "${library}" | sha256sum | cut -d' ' -f1)" = "${expected_library}"
test "$(unzip -p "dist/${archive}" "${library}" | wc -c)" -eq "${expected_size}"
done < <(find dist -maxdepth 1 -type f -name '*.artifact.json' | sort)
- name: Create draft release
env:
GH_TOKEN: ${{ github.token }}
run: |
gh release create "${GITHUB_REF_NAME}" \
dist/*.zip dist/checksums.txt \
dist/*.zip dist/checksums.txt dist/artifact-manifest.json \
--draft \
--verify-tag \
--title "Codex Switch Safe ${GITHUB_REF_NAME}" \
Expand All @@ -218,6 +253,7 @@ jobs:
{
find dist -maxdepth 1 -type f -name '*.zip' -printf '%f\n'
echo checksums.txt
echo artifact-manifest.json
} | sort > dist/expected-assets.txt

gh release view "${GITHUB_REF_NAME}" \
Expand Down
12 changes: 11 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,15 @@ matching version section verbatim.

## [Unreleased]

## [0.2.1] - 2026-08-21

### Maintenance

- Made pure-Go unit tests compile without a host C ABI, so CI explicitly tests
both `CGO_ENABLED=0` and `CGO_ENABLED=1` configurations.
- Added release artifact manifests containing the source commit plus archive
and packaged-library SHA-256 hashes for installed-binary verification.

## [0.2.0] - 2026-08-15

### Highlights
Expand Down Expand Up @@ -59,7 +68,8 @@ matching version section verbatim.
Initial release. This version is deprecated because retry and session edge cases
were corrected in `v0.1.1`.

[Unreleased]: https://github.com/Rat0323/cpa-plugin-codex-switch-safe/compare/v0.2.0...HEAD
[Unreleased]: https://github.com/Rat0323/cpa-plugin-codex-switch-safe/compare/v0.2.1...HEAD
[0.2.1]: https://github.com/Rat0323/cpa-plugin-codex-switch-safe/compare/v0.2.0...v0.2.1
[0.2.0]: https://github.com/Rat0323/cpa-plugin-codex-switch-safe/releases/tag/v0.2.0
[0.1.1]: https://github.com/Rat0323/cpa-plugin-codex-switch-safe/releases/tag/v0.1.1
[0.1.0]: https://github.com/Rat0323/cpa-plugin-codex-switch-safe/releases/tag/v0.1.0
6 changes: 6 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,12 @@ $go = 'C:\Program Files\Go\bin\go.exe'
Native shared-library builds require a C compiler. Pull requests run tests and
all six supported platform builds in GitHub Actions.

The local `dist/` and `smoke/plugins/` directories are disposable build output.
They are intentionally ignored and are not release evidence. GitHub Release
assets are authoritative. Each release includes `checksums.txt` for archives
and `artifact-manifest.json` for the SHA-256 and size of the library inside
each archive, along with its source commit.

## Pull requests

- Explain the behavior change and its safety implications.
Expand Down
14 changes: 11 additions & 3 deletions Makefile
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
PLUGIN_NAME ?= codex-switch-safe
VERSION ?= 0.2.0
VERSION ?= 0.2.1
GOOS ?= $(shell go env GOOS)
GOARCH ?= $(shell go env GOARCH)
GO_LDFLAGS ?= -s -w -X main.pluginVersion=$(VERSION)
Expand All @@ -26,8 +26,16 @@ build:

package: build
@version=$(VERSION); archive=$(PLUGIN_NAME)_$${version}_$(GOOS)_$(GOARCH).zip; \
go run ./.github/scripts/package-release.go -library "$(PLUGIN_OUTPUT)" -archive "$${archive}" -checksum "$${archive}.sha256"
go run ./.github/scripts/package-release.go \
-library "$(PLUGIN_OUTPUT)" \
-archive "$${archive}" \
-checksum "$${archive}.sha256" \
-manifest "$${archive}.artifact.json" \
-version "$${version}" \
-goos "$(GOOS)" \
-goarch "$(GOARCH)" \
-source-commit "$$(git rev-parse HEAD)"

clean:
rm -rf dist
rm -f $(PLUGIN_NAME)_*.zip $(PLUGIN_NAME)_*.sha256 checksums.txt
rm -f $(PLUGIN_NAME)_*.zip $(PLUGIN_NAME)_*.sha256 $(PLUGIN_NAME)_*.artifact.json checksums.txt artifact-manifest.json
19 changes: 18 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,9 @@ directory and restart CPA. Release archives use this naming convention:
codex-switch-safe_<version>_<goos>_<goarch>.zip
```

The release also includes `checksums.txt` for SHA-256 verification.
The release also includes `checksums.txt` for archive verification and
`artifact-manifest.json` for the source commit, packaged-library size, and
packaged-library SHA-256.

## Behavior

Expand Down Expand Up @@ -104,6 +106,21 @@ plugins:
compaction context. Use `strip` only when continuing without that context is
preferable to returning a conflict.

The policies differ only when unsafe top-level `compaction` is present. They
handle ordinary encrypted reasoning and same-route continuations identically:

| Situation | `block` | `strip` |
| --- | --- | --- |
| Same committed credential/model route | Preserve route-bound state and continue | Preserve route-bound state and continue |
| Changed/unknown route with reasoning but no compaction | Strip unsafe reasoning and prior response ID, then continue | Strip unsafe reasoning and prior response ID, then continue |
| Changed/unknown route with compaction | Return HTTP 409 without sending the request upstream | Strip reasoning, compaction, and prior response ID, then continue |
| Main tradeoff | Maximum continuity safety; the turn may require retrying on the original route or starting clean | Higher availability; compressed context may be discarded on a route switch |

`block` is appropriate when preserving compressed conversation context matters
more than completing the current turn. `strip` is appropriate when automatic
credential failover should keep working even if the new route must continue
without the old route's compressed state.

CPA `7.2.130` exposes plugin-owned configuration fields without a separate
default-value property. Management screens may therefore display these fields as
blank until an override is saved. Blank or omitted fields are valid: the plugin
Expand Down
6 changes: 6 additions & 0 deletions abi_nocgo.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
//go:build !cgo

package main

// Unit tests and static checks do not have a host ABI to receive diagnostics.
func hostDiagnosticSink(_ string, _ string, _ map[string]any) {}
Loading
Loading