From 45a55756b5b00499c1bba4bafa9803dbca0333b1 Mon Sep 17 00:00:00 2001 From: ammann <161159040+agammann@users.noreply.github.com> Date: Tue, 6 Oct 2026 21:12:26 -0700 Subject: [PATCH 1/2] Release EmbedLedger 1.0.0 with verified native packages --- .github/workflows/ci.yml | 40 ++++++ CHANGELOG.md | 11 ++ README.md | 14 ++- docs/compatibility.md | 53 ++++++++ docs/github-actions.md | 2 +- main.go | 2 +- scripts/package/main.go | 263 +++++++++++++++++++++++++++++++++++++++ scripts/release.cjs | 171 +++++++++++++++++++++++++ 8 files changed, 549 insertions(+), 7 deletions(-) create mode 100644 docs/compatibility.md create mode 100644 scripts/package/main.go create mode 100644 scripts/release.cjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 32776e7..907685b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -8,6 +8,10 @@ on: permissions: contents: read +concurrency: + group: embedledger-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: false + jobs: verify: name: Verify (${{ matrix.os }}) @@ -33,6 +37,17 @@ jobs: - name: Require Go formatting shell: bash run: test -z "$(gofmt -l .)" + - name: Build and verify native distribution + run: go run ./scripts/package + - name: Keep verified native distribution + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: embedledger-native-${{ runner.os }}-${{ runner.arch }} + path: | + dist/embedledger_*.zip + dist/embedledger_*.zip.sha256 + if-no-files-found: error + overwrite: true race: name: Race detector @@ -44,3 +59,28 @@ jobs: go-version: '1.27.1' cache: false - run: go test -race -count=1 ./... + + publish: + name: Publish verified release + needs: [verify, race] + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + runs-on: ubuntu-latest + permissions: + contents: write + concurrency: + group: embedledger-release + cancel-in-progress: false + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + - name: Read packages from this verified run + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + pattern: embedledger-native-* + path: release-artifacts + merge-multiple: true + - name: Publish complete packages + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + with: + script: | + const publish = require('./scripts/release.cjs'); + await publish({ github, context, core }); diff --git a/CHANGELOG.md b/CHANGELOG.md index 5b23e25..7b31ff7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,16 @@ # Changelog +## 1.0.0 + +Released October 6, 2026. + +- Adopt the MIT license and publish native ZIP packages with SHA256 checksums. +- Include documentation and the checked example in each package. Go 1.27 or newer remains required on PATH for asset resolution. +- Verify each native package after unpacking on Windows, Linux, and macOS before publishing it. +- Define the 1.x compatibility contract for documented commands, flags, exit codes, and schema-1 baselines. + +The baseline format is unchanged. Existing 0.1.1 baselines remain compatible when their build scope matches. Upgrade the executable, keep the same scan options, and run `check`; a new snapshot is not required. + ## 0.1.1 Released September 19, 2026. diff --git a/README.md b/README.md index d10106f..f294215 100644 --- a/README.md +++ b/README.md @@ -15,15 +15,15 @@ One Go CLI, standard library only. No account or service required. Install [Go 1.27 or newer](https://go.dev/doc/install), then check that `go version` works in your terminal. Go must remain on PATH because EmbedLedger uses its resolver when scanning your project. ```sh -go install github.com/agammann/embedledger@v0.1.1 +go install github.com/agammann/embedledger@v1.0.0 embedledger version ``` -Expected output: `embedledger 0.1.1`. +Expected output: `embedledger 1.0.0`. -If your terminal cannot find `embedledger`, follow the [PATH setup instructions](docs/troubleshooting.md#command-not-found). This release is installed from source through Go; it does not include prebuilt binary downloads. +If your terminal cannot find `embedledger`, follow the [PATH setup instructions](docs/troubleshooting.md#command-not-found). Prebuilt native ZIP packages are also available from the release page. Download the package matching your OS and architecture, verify its SHA256 checksum, and unpack it. Each package includes the CLI, MIT license, documentation, and the checked example. Go 1.27 or newer must remain on PATH when using either installation method. -See the [0.1.1 release](https://github.com/agammann/embedledger/releases/tag/v0.1.1) and [changelog](CHANGELOG.md). Use `@latest` instead of `@v0.1.1` when you want the newest tagged version. +See the [1.0.0 release](https://github.com/agammann/embedledger/releases/tag/v1.0.0) and [changelog](CHANGELOG.md). Use `@latest` instead of `@v1.0.0` when you want the newest tagged version. ## Quick start @@ -120,10 +120,14 @@ go vet ./... go test -race ./... ``` -The race detector needs a supported platform and C compiler. [Repository CI](.github/workflows/ci.yml) runs the real resolver and CLI tests on Windows, Linux, and macOS, plus the Linux race detector. +The race detector needs a supported platform and C compiler. [Repository CI](https://github.com/agammann/embedledger/blob/v1.0.0/.github/workflows/ci.yml) runs the real resolver and CLI tests on Windows, Linux, and macOS, plus the Linux race detector. [Real project checks](docs/real-world-validation.md) · [Validation record](docs/validation.md) · [Research and related work](docs/research.md) +## Compatibility and upgrades + +Version 1.x preserves the documented commands, flags and defaults, exit codes, and schema-1 baseline fields. Existing 0.1.1 baselines remain compatible when their build scope matches; upgrading does not require a new snapshot. See the [stability and upgrade contract](docs/compatibility.md) before updating automation. + ## License EmbedLedger is licensed under the [MIT License](LICENSE). diff --git a/docs/compatibility.md b/docs/compatibility.md new file mode 100644 index 0000000..8cbe371 --- /dev/null +++ b/docs/compatibility.md @@ -0,0 +1,53 @@ +# Stability and upgrades + +[Back to the README](../README.md) + +## The 1.x contract + +EmbedLedger 1.x preserves the documented `scan`, `snapshot`, `check`, `version`, and `help` commands; existing flags and their defaults; and these exit codes: + +| Exit code | Meaning | +| :--- | :--- | +| `0` | Complete inventory, saved baseline, or matching baseline | +| `1` | A complete comparison found added, removed, or changed assets | +| `2` | Invalid input, incompatible baseline, or an incomplete scan | + +Schema-1 baselines retain their field names and meanings. Version 1.x continues to read existing valid schema-1 baselines. The JSON inventory and comparison fields documented in the [command reference](reference.md) retain their types and meanings. Additional commands and optional flags may be added; removing existing behavior or changing these formats requires a new major version. + +A baseline comparison uses the recorded target OS, architecture, CGO setting, build tags, package selection, and test setting. Keep those options identical when saving and checking. The byte budget and timeout may differ. Go version is recorded as information and does not itself cause drift; Go's selected inputs can change between toolchains, so review any reported asset changes after a toolchain update. + +Go 1.27 or newer must remain on PATH even when using a prebuilt executable. Native packages contain the CLI, MIT license, README, changelog, documentation, and the example module. The package filenames identify the OS and architecture that actually built and ran the package checks. These packages do not bundle Go. + +## Upgrade from 0.1.1 + +Install the pinned source release: + +```sh +go install github.com/agammann/embedledger@v1.0.0 +embedledger version +``` + +The version output is `embedledger 1.0.0`. Alternatively, replace your previous executable with the verified native package for your OS and architecture. + +Keep your reviewed `embedledger.json` and run `check` with the same options you used in 0.1.1: + +```sh +embedledger check --goos linux --goarch amd64 +``` + +The baseline format is unchanged; do not run `snapshot --force` merely to upgrade. If the check reports drift, review the inputs before deciding whether to accept a new baseline. Update the installation pin in your CI workflow separately. + +## Verify a native package + +Each ZIP has a matching `.zip.sha256` file. Compare its hash with the ZIP before unpacking. The release also includes `SHA256SUMS` for all native ZIP files. + +On Windows PowerShell: + +```powershell +Get-FileHash .\embedledger_1.0.0_windows_amd64.zip -Algorithm SHA256 +Get-Content .\embedledger_1.0.0_windows_amd64.zip.sha256 +``` + +On Linux, run `sha256sum -c` with the checksum filename. On macOS, run `shasum -a 256 -c` with it. Use the package matching your architecture. + +After unpacking, use the CLI from the package directory or place it on PATH. Its included example baseline can be checked with `embedledger check --goos linux --goarch amd64`; this reports 112 bytes. In PowerShell, use `.\embedledger.exe` for the executable in the current directory. On macOS and Linux, use `./embedledger`. diff --git a/docs/github-actions.md b/docs/github-actions.md index fa7d549..f475a5b 100644 --- a/docs/github-actions.md +++ b/docs/github-actions.md @@ -40,7 +40,7 @@ jobs: - name: Prepare project dependencies run: go mod download - name: Install EmbedLedger - run: go install github.com/agammann/embedledger@v0.1.1 + run: go install github.com/agammann/embedledger@v1.0.0 - name: Check reviewed embedded assets run: embedledger check --goos linux --goarch amd64 --max-bytes 10485760 --timeout 5m ``` diff --git a/main.go b/main.go index 69cae68..6a0bfde 100644 --- a/main.go +++ b/main.go @@ -13,7 +13,7 @@ import ( "time" ) -const version = "0.1.1" +const version = "1.0.0" func main() { os.Exit(run(os.Args[1:], os.Stdout, os.Stderr)) } diff --git a/scripts/package/main.go b/scripts/package/main.go new file mode 100644 index 0000000..4578f3f --- /dev/null +++ b/scripts/package/main.go @@ -0,0 +1,263 @@ +// Package builds and verifies a native EmbedLedger distribution. +package main + +import ( + "archive/zip" + "bytes" + "crypto/sha256" + "encoding/hex" + "errors" + "flag" + "fmt" + "go/ast" + "go/parser" + "go/token" + "io" + "io/fs" + "os" + "os/exec" + "path/filepath" + "regexp" + "runtime" + "slices" + "strconv" + "strings" +) + +func main() { + if err := run(); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} + +func run() error { + out := flag.String("out", "dist", "output directory for the native ZIP and checksum") + flag.Parse() + if flag.NArg() != 0 { + return errors.New("package takes only --out") + } + version, err := releaseVersion() + if err != nil { + return err + } + output, err := filepath.Abs(*out) + if err != nil { + return err + } + if err = os.MkdirAll(output, 0755); err != nil { + return err + } + work, err := os.MkdirTemp("", "embedledger-package-") + if err != nil { + return err + } + defer os.RemoveAll(work) + name := fmt.Sprintf("embedledger_%s_%s_%s", version, runtime.GOOS, runtime.GOARCH) + executable := "embedledger" + if runtime.GOOS == "windows" { + executable += ".exe" + } + binary := filepath.Join(work, executable) + build := exec.Command("go", "build", "-trimpath", "-buildvcs=false", "-o", binary, ".") + build.Env = buildEnvironment() + build.Stdout, build.Stderr = os.Stdout, os.Stderr + if err = build.Run(); err != nil { + return fmt.Errorf("build native executable: %w", err) + } + files := []string{"LICENSE", "README.md", "CHANGELOG.md", "go.mod", "embedledger.json"} + for _, directory := range []string{"docs", "examples"} { + err = filepath.WalkDir(directory, func(path string, entry fs.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + if !entry.IsDir() { + if !entry.Type().IsRegular() { + return fmt.Errorf("distribution file is not regular: %s", path) + } + files = append(files, path) + } + return nil + }) + if err != nil { + return err + } + } + slices.Sort(files) + archive := filepath.Join(output, name+".zip") + if err = writeArchive(archive, name, executable, binary, files); err != nil { + return err + } + data, err := os.ReadFile(archive) + if err != nil { + return err + } + digest := sha256.Sum256(data) + checksum := hex.EncodeToString(digest[:]) + " " + filepath.Base(archive) + "\n" + if err = os.WriteFile(archive+".sha256", []byte(checksum), 0644); err != nil { + return err + } + unpacked := filepath.Join(work, "unpacked") + if err = unpackArchive(archive, unpacked, name, executable); err != nil { + return err + } + packageRoot := filepath.Join(unpacked, name) + program := filepath.Join(packageRoot, executable) + for _, check := range []struct { + args []string + want string + }{ + {[]string{"version"}, "embedledger " + version + "\n"}, + {[]string{"help"}, "EmbedLedger: review the files selected by go:embed."}, + {[]string{"check", "--goos", "linux", "--goarch", "amd64"}, "Embedded assets match the baseline (112 bytes).\n"}, + } { + cmd := exec.Command(program, check.args...) + cmd.Dir = packageRoot + result, runErr := cmd.CombinedOutput() + if runErr != nil || !strings.Contains(string(result), check.want) { + return fmt.Errorf("unpacked package %v: %v: %s", check.args, runErr, result) + } + fmt.Printf("Verified unpacked package: %s\n", strings.Join(check.args, " ")) + } + // Verification only reads the archive. Check the bytes before handing it to CI. + unchanged, err := os.ReadFile(archive) + if err != nil { + return err + } + if !bytes.Equal(data, unchanged) { + return errors.New("archive changed during verification") + } + fmt.Printf("Native package: %s/%s\n%s", runtime.GOOS, runtime.GOARCH, checksum) + return nil +} + +func releaseVersion() (string, error) { + file, err := parser.ParseFile(token.NewFileSet(), "main.go", nil, 0) + if err != nil { + return "", err + } + var version string + for _, declaration := range file.Decls { + constants, ok := declaration.(*ast.GenDecl) + if !ok || constants.Tok != token.CONST { + continue + } + for _, specification := range constants.Specs { + value := specification.(*ast.ValueSpec) + for index, identifier := range value.Names { + if identifier.Name != "version" { + continue + } + if version != "" || index >= len(value.Values) { + return "", errors.New("version must have one string constant value") + } + literal, ok := value.Values[index].(*ast.BasicLit) + if !ok || literal.Kind != token.STRING { + return "", errors.New("version must be a string literal") + } + version, err = strconv.Unquote(literal.Value) + if err != nil { + return "", err + } + } + } + } + if !regexp.MustCompile(`^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$`).MatchString(version) { + return "", errors.New("version must be stable semantic version MAJOR.MINOR.PATCH") + } + return version, nil +} + +func buildEnvironment() []string { + replacements := map[string]string{"GOOS": runtime.GOOS, "GOARCH": runtime.GOARCH, "CGO_ENABLED": "0", "GOFLAGS": "", "GOTOOLCHAIN": "local", "GOWORK": "off"} + var env []string + for _, entry := range os.Environ() { + key, _, _ := strings.Cut(entry, "=") + if _, replace := replacements[strings.ToUpper(key)]; !replace { + env = append(env, entry) + } + } + for key, value := range replacements { + env = append(env, key+"="+value) + } + return env +} + +func writeArchive(path, name, executable, binary string, files []string) (err error) { + f, err := os.Create(path) + if err != nil { + return err + } + defer func() { err = errors.Join(err, f.Close()) }() + w := zip.NewWriter(f) + add := func(source, destination string, mode fs.FileMode) error { + header := &zip.FileHeader{Name: name + "/" + filepath.ToSlash(destination), Method: zip.Deflate} + header.SetMode(mode) + entry, err := w.CreateHeader(header) + if err != nil { + return err + } + data, err := os.ReadFile(source) + if err != nil { + return err + } + _, err = entry.Write(data) + return err + } + if err = add(binary, executable, 0755); err != nil { + w.Close() + return err + } + for _, file := range files { + if err = add(file, file, 0644); err != nil { + w.Close() + return err + } + } + return w.Close() +} + +func unpackArchive(path, destination, name, executable string) error { + r, err := zip.OpenReader(path) + if err != nil { + return err + } + defer r.Close() + executableFound := false + for _, entry := range r.File { + if !strings.HasPrefix(entry.Name, name+"/") || !fs.ValidPath(entry.Name) || strings.Contains(entry.Name, "\\") || !entry.Mode().IsRegular() { + return fmt.Errorf("invalid distribution entry: %s", entry.Name) + } + if entry.Name == name+"/"+executable { + executableFound = true + if entry.Mode().Perm() != 0755 { + return errors.New("ZIP did not preserve executable permissions") + } + } + target := filepath.Join(destination, filepath.FromSlash(entry.Name)) + if err = os.MkdirAll(filepath.Dir(target), 0755); err != nil { + return err + } + source, err := entry.Open() + if err != nil { + return err + } + output, err := os.OpenFile(target, os.O_CREATE|os.O_EXCL|os.O_WRONLY, entry.Mode().Perm()) + if err != nil { + source.Close() + return err + } + _, copyErr := io.Copy(output, source) + err = errors.Join(copyErr, source.Close(), output.Close()) + if err != nil { + return err + } + if err = os.Chmod(target, entry.Mode().Perm()); err != nil { + return err + } + } + if !executableFound { + return errors.New("distribution has no executable") + } + return nil +} diff --git a/scripts/release.cjs b/scripts/release.cjs new file mode 100644 index 0000000..2827ad8 --- /dev/null +++ b/scripts/release.cjs @@ -0,0 +1,171 @@ +const fs = require('node:fs'); +const path = require('node:path'); +const crypto = require('node:crypto'); + +module.exports = async ({ github, context, core }) => { + if (context.eventName !== 'push' || context.ref !== 'refs/heads/main' || + !/^[0-9a-f]{40}$/.test(context.sha)) { + throw new Error('Publishing requires a main push with an exact commit SHA.'); + } + const versions = [...fs.readFileSync('main.go', 'utf8') + .matchAll(/^const version = "([^"]+)"\r?$/gm)]; + if (versions.length !== 1 || + !/^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$/.test(versions[0][1])) { + throw new Error('Expected one stable semantic version constant in main.go.'); + } + const version = versions[0][1]; + const tag = 'v' + version; + const { owner, repo } = context.repo; + const releases = await github.paginate(github.rest.repos.listReleases, { + owner, repo, per_page: 100 + }); + const matching = releases.filter(release => release.tag_name === tag); + if (matching.length > 1) throw new Error('Multiple releases use ' + tag); + let release = matching[0]; + if (release && !release.draft) { + core.info('Release ' + tag + ' is already published; leaving it unchanged.'); + return; + } + if (release && (release.target_commitish !== context.sha || release.prerelease)) { + throw new Error('Existing draft does not belong to this exact release commit.'); + } + + const tagCommit = async () => { + let object; + try { + object = (await github.rest.git.getRef({ owner, repo, ref: 'tags/' + tag })).data.object; + } catch (error) { + if (error.status === 404) return null; + throw error; + } + for (let depth = 0; depth < 10; depth++) { + if (object.type === 'commit') return object.sha; + if (object.type !== 'tag') throw new Error('Release tag does not point to a commit.'); + object = (await github.rest.git.getTag({ + owner, repo, tag_sha: object.sha + })).data.object; + } + throw new Error('Release tag has too many annotated tag layers.'); + }; + const requireCorrectTag = async () => { + const commit = await tagCommit(); + if (commit !== null && commit !== context.sha) { + throw new Error('Existing release tag points to a different commit.'); + } + return commit; + }; + await requireCorrectTag(); + + const directory = 'release-artifacts'; + const filenames = fs.readdirSync(directory).sort(); + const archivePattern = new RegExp('^embedledger_' + version.replaceAll('.', '\\.') + + '_(windows_amd64|linux_amd64|darwin_amd64|darwin_arm64)\\.zip$'); + const archives = filenames.filter(filename => archivePattern.test(filename)); + if (archives.length !== 3) throw new Error('Expected exactly three verified native ZIPs.'); + const platforms = archives.map(filename => archivePattern.exec(filename)[1].split('_')[0]); + if (new Set(platforms).size !== 3) { + throw new Error('Expected one Windows, one Linux, and one macOS package.'); + } + const expectedInputs = archives.flatMap(filename => [filename, filename + '.sha256']).sort(); + if (JSON.stringify(filenames) !== JSON.stringify(expectedInputs)) { + throw new Error('Downloaded artifacts contain unexpected or missing files.'); + } + const payloads = []; + const checksumLines = []; + const readFile = filename => { + const location = path.join(directory, filename); + if (!fs.lstatSync(location).isFile()) throw new Error('Artifact is not a regular file.'); + return fs.readFileSync(location); + }; + const digest = data => 'sha256:' + crypto.createHash('sha256').update(data).digest('hex'); + for (const filename of archives) { + const data = readFile(filename); + const checksum = readFile(filename + '.sha256'); + const expected = digest(data).slice(7) + ' ' + filename + '\n'; + if (checksum.toString('utf8') !== expected) { + throw new Error('Artifact checksum mismatch: ' + filename); + } + checksumLines.push(expected); + payloads.push({ name: filename, data }, { name: filename + '.sha256', data: checksum }); + } + payloads.push({ name: 'SHA256SUMS', data: Buffer.from(checksumLines.join(''), 'utf8') }); + for (const payload of payloads) payload.digest = digest(payload.data); + + const changelog = fs.readFileSync('CHANGELOG.md', 'utf8').replaceAll('\r\n', '\n'); + const heading = '## ' + version + '\n'; + if (!changelog.includes(heading)) throw new Error('Changelog has no release section.'); + const notes = changelog.split(heading)[1].split('\n## ')[0].trim(); + const body = notes + '\n\nDownload the native ZIP matching your OS and architecture and verify its SHA256 checksum. Go 1.27 or newer remains required on PATH. Each package includes the CLI, MIT license, documentation, and the checked example.\n\n' + + '[Installation and upgrade instructions](https://github.com/' + owner + '/' + repo + + '/blob/' + tag + '/README.md).'; + if (!release) { + release = (await github.rest.repos.createRelease({ + owner, repo, tag_name: tag, target_commitish: context.sha, + name: 'EmbedLedger ' + version, body, draft: true, prerelease: false + })).data; + } else { + release = (await github.rest.repos.updateRelease({ + owner, repo, release_id: release.id, + name: 'EmbedLedger ' + version, body, draft: true, prerelease: false + })).data; + } + const existing = await github.paginate(github.rest.repos.listReleaseAssets, { + owner, repo, release_id: release.id, per_page: 100 + }); + const wanted = new Map(payloads.map(payload => [payload.name, payload])); + const existingNames = new Set(); + for (const asset of existing) { + if (!wanted.has(asset.name) || existingNames.has(asset.name)) { + throw new Error('Draft contains an unexpected or duplicate asset.'); + } + existingNames.add(asset.name); + } + for (const payload of payloads) { + const previous = existing.find(asset => asset.name === payload.name); + if (previous && previous.digest === payload.digest && + previous.size === payload.data.length && previous.state === 'uploaded') { + continue; + } + if (previous) { + await github.rest.repos.deleteReleaseAsset({ owner, repo, asset_id: previous.id }); + } + const uploaded = (await github.rest.repos.uploadReleaseAsset({ + owner, repo, release_id: release.id, name: payload.name, data: payload.data, + headers: { 'content-type': 'application/octet-stream', 'content-length': payload.data.length } + })).data; + if (uploaded.digest !== payload.digest || uploaded.size !== payload.data.length || + uploaded.state !== 'uploaded') { + throw new Error('Uploaded asset verification failed: ' + payload.name); + } + core.info('Verified uploaded asset: ' + payload.name); + } + const finalAssets = await github.paginate(github.rest.repos.listReleaseAssets, { + owner, repo, release_id: release.id, per_page: 100 + }); + if (finalAssets.length !== payloads.length || + new Set(finalAssets.map(asset => asset.name)).size !== payloads.length || + finalAssets.some(asset => { + const expected = wanted.get(asset.name); + return !expected || asset.digest !== expected.digest || + asset.size !== expected.data.length || asset.state !== 'uploaded'; + })) { + throw new Error('Draft assets do not match the complete verified package set.'); + } + const latest = (await github.rest.repos.getRelease({ + owner, repo, release_id: release.id + })).data; + if (!latest.draft || latest.target_commitish !== context.sha || latest.tag_name !== tag) { + throw new Error('Draft changed during upload; publication stopped.'); + } + // Create the tag at the checked commit explicitly; target_commitish cannot move an existing tag. + if (await requireCorrectTag() === null) { + await github.rest.git.createRef({ owner, repo, ref: 'refs/tags/' + tag, sha: context.sha }); + } + if (await requireCorrectTag() !== context.sha) { + throw new Error('Release tag is missing before publication.'); + } + await github.rest.repos.updateRelease({ + owner, repo, release_id: release.id, draft: false, make_latest: 'true' + }); + core.info('Published EmbedLedger ' + version + ' at ' + context.sha); +}; From 73c2c7eabd04c9460e74acb801b12b6c9afb685d Mon Sep 17 00:00:00 2001 From: ammann <161159040+agammann@users.noreply.github.com> Date: Tue, 6 Oct 2026 21:23:14 -0700 Subject: [PATCH 2/2] Resolve Go package directory aliases before module checks --- .github/workflows/ci.yml | 1 + CHANGELOG.md | 1 + collect.go | 8 +++++++- collect_test.go | 34 ++++++++++++++++++++++++++++++++++ 4 files changed, 43 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 907685b..976da77 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,6 +2,7 @@ name: CI on: push: + branches: [main] pull_request: workflow_dispatch: diff --git a/CHANGELOG.md b/CHANGELOG.md index 7b31ff7..acad87d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ Released October 6, 2026. - Include documentation and the checked example in each package. Go 1.27 or newer remains required on PATH for asset resolution. - Verify each native package after unpacking on Windows, Linux, and macOS before publishing it. - Define the 1.x compatibility contract for documented commands, flags, exit codes, and schema-1 baselines. +- Resolve filesystem aliases consistently when comparing Go package directories with the module root, including macOS temporary directories. The baseline format is unchanged. Existing 0.1.1 baselines remain compatible when their build scope matches. Upgrade the executable, keep the same scan options, and run `check`; a new snapshot is not required. diff --git a/collect.go b/collect.go index d898e74..e01a6ff 100644 --- a/collect.go +++ b/collect.go @@ -192,7 +192,13 @@ func collect(ctx context.Context, opt options) (manifest, error) { if pkg.ForTest != "" { continue } - dir, err := filepath.Rel(root, pkg.Dir) + // Go can report another filesystem alias for the same directory. + // Resolve it, as we did the module root, before checking containment. + packageDir, err := filepath.EvalSymlinks(pkg.Dir) + if err != nil { + return m, fmt.Errorf("resolve package %q directory: %w", pkg.ImportPath, err) + } + dir, err := filepath.Rel(root, packageDir) if err != nil || !filepath.IsLocal(dir) { return m, fmt.Errorf("package %q is outside the selected module", pkg.ImportPath) } diff --git a/collect_test.go b/collect_test.go index 1040874..cdda126 100644 --- a/collect_test.go +++ b/collect_test.go @@ -4,12 +4,14 @@ import ( "bytes" "context" "encoding/json" + "errors" "os" "os/exec" "path/filepath" "runtime" "slices" "strings" + "syscall" "testing" ) @@ -71,6 +73,38 @@ func TestGoResolverSemantics(t *testing.T) { } } +func TestGoResolverThroughModuleAlias(t *testing.T) { + dir, opt := fixture(t, "assets") + alias := filepath.Join(t.TempDir(), "module") + if err := os.Symlink(dir, alias); err != nil { + // Windows ERROR_PRIVILEGE_NOT_HELD: symlink creation needs permission. + if runtime.GOOS == "windows" && errors.Is(err, syscall.Errno(1314)) { + t.Skipf("symlink privilege unavailable: %v", err) + } + t.Fatal(err) + } + opt.Dir = alias + // Unix Go subprocesses may retain a valid PWD alias in package metadata. + t.Setenv("PWD", alias) + m, err := collect(context.Background(), opt) + if err != nil { + t.Fatal(err) + } + if len(m.Assets) != 2 || m.TotalBytes != 9 || m.Assets[0].Path != "assets/a.txt" || m.Assets[1].Path != "assets/nested/b.txt" { + t.Fatalf("unexpected inventory through module alias: %+v", m) + } + // A package alias that resolves outside the module must still fail. + outside := t.TempDir() + writeFixture(t, outside, "outside.go", "package outside\n") + if err := os.Symlink(outside, filepath.Join(dir, "outside")); err != nil { + t.Fatal(err) + } + opt.Patterns = []string{"./outside"} + if _, err := collect(context.Background(), opt); err == nil || !strings.Contains(err.Error(), "outside the selected module") { + t.Fatalf("expected outside-module rejection, got %v", err) + } +} + func TestBuildContextsAndTestAssets(t *testing.T) { dir, opt := fixture(t, "assets") writeFixture(t, dir, "special.go", "//go:build special\n\npackage fixture\nimport _ \"embed\"\n//go:embed special.txt\nvar Special string\n")