diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 56cde3a..0612697 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -11,87 +11,184 @@ defaults: shell: bash jobs: - release: + prepare: runs-on: ubuntu-latest - permissions: - contents: write - env: - VERSION: "" - COMMIT: "" - RELEASE: "" - GH_TOKEN: ${{ github.token }} + outputs: + version: ${{ steps.release.outputs.version }} + release: ${{ steps.release.outputs.release }} steps: - uses: actions/checkout@v4 with: fetch-depth: 0 - - uses: actions/setup-go@v5 - with: - go-version: 'stable' - - run: go version - name: Check need release - id: check_release + id: release run: | - version="" - release="false" + release=false new_ver=$(grep -Po "v\d+\.\d+\.\d+" cmd/compiledb/main.go) cur_ver=$(git describe --abbrev=0 --tags) commit=$(git rev-parse --short HEAD) - if [[ "$new_ver" != "$cur_ver" && $(git branch --show-current) == "main" ]]; then + if [[ "$new_ver" != "$cur_ver" && "$GITHUB_REF" == "refs/heads/main" ]]; then version=$new_ver - release="true" + release=true else - version=dev-$new_ver\($commit\) - sed -E "s|v[0-9]+\.[0-9]+\.[0-9]+|$version|" cmd/compiledb/main.go -i + version="dev-${new_ver}(${commit})" + fi + echo "version=$version" >> "$GITHUB_OUTPUT" + echo "release=$release" >> "$GITHUB_OUTPUT" + + linux-amd64: + needs: prepare + runs-on: ubuntu-latest + env: + VERSION: ${{ needs.prepare.outputs.version }} + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version: stable + + - name: Check format + run: | + unformatted=$(gofmt -l .) + if [[ -n "$unformatted" ]]; then + printf 'Files need gofmt:\n%s\n' "$unformatted" + exit 1 fi - echo "RELEASE=$release" >> $GITHUB_ENV - echo "VERSION=$version" >> $GITHUB_ENV - echo "COMMIT=$commit" >> $GITHUB_ENV - - name: Install dependencies - run: go mod tidy + - name: Check dependencies + run: | + go mod tidy + git diff --exit-code -- go.mod go.sum + + - name: Vet + run: go vet ./... + + - name: Test + run: go test ./... + + - name: Race test + run: go test -race ./... + + - name: Build and smoke test + run: | + test "$(go env GOOS)/$(go env GOARCH)" = "linux/amd64" + mkdir -p dist + go build -ldflags "-X main.Version=$VERSION" -o dist/compiledb ./cmd/compiledb + ./dist/compiledb --help + tar cJf dist/compiledb-linux-amd64.txz -C dist compiledb + + - uses: actions/upload-artifact@v4 + with: + name: release-linux-amd64 + path: dist/compiledb-linux-amd64.txz + if-no-files-found: error + + linux-arm64: + needs: prepare + runs-on: ubuntu-24.04-arm + env: + VERSION: ${{ needs.prepare.outputs.version }} + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version: stable + + - name: Build and smoke test + run: | + test "$(go env GOOS)/$(go env GOARCH)" = "linux/arm64" + mkdir -p dist + go build -ldflags "-X main.Version=$VERSION" -o dist/compiledb ./cmd/compiledb + ./dist/compiledb --help + tar cJf dist/compiledb-linux-arm64.txz -C dist compiledb + + - uses: actions/upload-artifact@v4 + with: + name: release-linux-arm64 + path: dist/compiledb-linux-arm64.txz + if-no-files-found: error - - name: Build + windows-amd64: + needs: prepare + runs-on: windows-latest + env: + VERSION: ${{ needs.prepare.outputs.version }} + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version: stable + + - name: Build and smoke test run: | - out=$(pwd)/build - mkdir -p $out + test "$(go env GOOS)/$(go env GOARCH)" = "windows/amd64" + mkdir -p dist + go build -ldflags "-X main.Version=$VERSION" -o dist/compiledb.exe ./cmd/compiledb + ./dist/compiledb.exe --help + (cd dist && 7z a compiledb-windows-amd64.zip compiledb.exe) - cd ./cmd/compiledb + - uses: actions/upload-artifact@v4 + with: + name: release-windows-amd64 + path: dist/compiledb-windows-amd64.zip + if-no-files-found: error - echo "Build linux-amd64 version" - GOOS=linux GOARCH=amd64 go build - chmod +x compiledb - ./compiledb -h | head -1 || true - tar cJvf compiledb.txz compiledb - mv compiledb.txz $out/compiledb-linux-amd64.txz + darwin-arm64: + needs: prepare + runs-on: macos-15 + env: + VERSION: ${{ needs.prepare.outputs.version }} + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version: stable - echo "Build linux-arm64 version" - GOOS=linux GOARCH=arm64 go build - chmod +x compiledb - tar cJvf compiledb.txz compiledb - mv compiledb.txz $out/compiledb-linux-arm64.txz + - name: Build and smoke test + run: | + test "$(go env GOOS)/$(go env GOARCH)" = "darwin/arm64" + mkdir -p dist + go build -ldflags "-X main.Version=$VERSION" -o dist/compiledb ./cmd/compiledb + ./dist/compiledb --help + tar cJf dist/compiledb-darwin-arm64.txz -C dist compiledb - echo "Build windows-amd64 version" - GOOS=windows GOARCH=amd64 go build - 7z a compiledb.zip compiledb.exe - mv compiledb.zip $out/compiledb-windows-amd64.zip + - uses: actions/upload-artifact@v4 + with: + name: release-darwin-arm64 + path: dist/compiledb-darwin-arm64.txz + if-no-files-found: error - echo "Build darwin-arm64 version" - GOOS=darwin GOARCH=arm64 go build - chmod +x compiledb - tar cJvf compiledb.txz compiledb - mv compiledb.txz $out/compiledb-darwin-arm64.txz + release: + needs: [ prepare, linux-amd64, linux-arm64, windows-amd64, darwin-arm64 ] + if: ${{ github.event_name != 'pull_request' }} + runs-on: ubuntu-latest + permissions: + contents: write + env: + GH_TOKEN: ${{ github.token }} + steps: + - uses: actions/checkout@v4 + - uses: actions/download-artifact@v4 + with: + pattern: release-* + path: build + merge-multiple: true - # delete draft - gh release delete dev --cleanup-tag -y || true + - name: List artifacts + run: ls -la build - ls -la $out + - name: Delete previous development release + run: | + if gh release view dev >/dev/null 2>&1; then + gh release delete dev -y + fi - name: Release - if: ${{ env.RELEASE == 'true' }} + if: ${{ needs.prepare.outputs.release == 'true' }} uses: ncipollo/release-action@v1 with: - tag: ${{ env.VERSION }} + tag: ${{ needs.prepare.outputs.version }} allowUpdates: true artifactErrorsFailBuild: true generateReleaseNotes: true @@ -103,7 +200,7 @@ jobs: tag: dev artifactErrorsFailBuild: true generateReleaseNotes: true - name: ${{ env.VERSION }} + name: ${{ needs.prepare.outputs.version }} prerelease: true draft: true artifacts: "build/*" diff --git a/AGENTS.md b/AGENTS.md index 90ce52b..35ce28b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -42,7 +42,7 @@ - Focus a test with `go test ./internal -run '^TestName$'` or `go test ./cmd/compiledb -run '^TestName$'`. - Build without leaving a repository artifact using `go build -o /tmp/compiledb-go ./cmd/compiledb`. The root `compiledb` binary is ignored, but `/tmp` is preferred for manual verification. - Do not treat `just` as a normal build check. `.justfile` requires Nushell, and its default `build` recipe runs the CLI against `tests/build.log`, generating `compile_commands.json` rather than only compiling the binary. -- The release workflow builds artifacts but does not run tests, vet, or race checks. Local verification remains required. +- The release workflow gates Linux amd64 on gofmt, vet, tests, and race tests. Linux amd64/arm64, Windows amd64, and macOS arm64 artifacts are built natively and must run `--help` before publication. Local verification remains required. ## Test And Artifact Gotchas @@ -54,6 +54,6 @@ ## Release Gotchas -- `Version` in `cmd/compiledb/main.go` is parsed directly by `.github/workflows/release.yml`. On `main`, a version different from the latest tag triggers a tagged release; non-release builds rewrite it to a `dev-...` version during CI. +- `Version` in `cmd/compiledb/main.go` is parsed directly by `.github/workflows/release.yml`. On `main`, a version different from the latest tag triggers a tagged release; CI injects `dev-...` for non-release artifacts through a linker flag. - Do not change `Version` as part of unrelated work. When changing it intentionally, review the release workflow and tag state together. -- The release workflow runs `go mod tidy` before building. Dependency changes must leave `go.mod` and `go.sum` tidy locally rather than relying on CI to rewrite them. +- The Linux release gate runs `go mod tidy` and rejects changes to `go.mod` or `go.sum`. Dependency changes must leave both files tidy locally rather than relying on CI to rewrite them. diff --git a/README.md b/README.md index 696db2d..9fcc651 100644 --- a/README.md +++ b/README.md @@ -269,10 +269,10 @@ could use it with some great tools, such as: - [Neovim][neovim] + [LanguageClient-neovim][lsp] + [cquery][cquery] + [deoplete][deoplete] - [Neovim][neovim] + [ALE][ale] + [ccls][ccls] -Current release automation cross-builds Linux amd64/arm64, Windows amd64, and macOS arm64 -artifacts on an Ubuntu runner. Only the Linux amd64 artifact receives a `compiledb -h` runtime -smoke check there; the workflow does not currently run the test suite or native Windows, macOS, -or arm64 runtime tests. +The release workflow supports Linux amd64/arm64, Windows amd64, and macOS arm64. Each artifact is +built on a native GitHub-hosted runner and must successfully execute `compiledb --help` before it +can be published. Linux amd64 additionally gates releases on `gofmt`, `go vet`, the full test suite, +and the race detector. Other platforms are unsupported and receive no release artifacts. ## License GNU GPLv3 diff --git a/internal/make_wrap_test.go b/internal/make_wrap_test.go index f9969e3..a0641f1 100644 --- a/internal/make_wrap_test.go +++ b/internal/make_wrap_test.go @@ -687,12 +687,19 @@ func TestMakeWrapPreservesQuotedMakeFlagAssignments(t *testing.T) { } func TestMakeWrapDetectsEnvironmentStdinBeforeUnmatchedQuote(t *testing.T) { - makeExecutable, err := exec.LookPath("make") - if err != nil { - t.Skip("GNU Make is not available") - } t.Setenv("MAKEFLAGS", "-f - FOO='x") tmpDir := t.TempDir() + makeExecutable := filepath.Join(tmpDir, "fake-make.sh") + contents := `#!/bin/sh +input=$(cat) +case "$input" in + *"cc -c unmatched-quote.c"*) echo 'cc -c unmatched-quote.c' ;; + *) exit 9 ;; +esac +` + if err := os.WriteFile(makeExecutable, []byte(contents), 0o755); err != nil { + t.Fatalf("write fake make failed: %v", err) + } stdin, err := os.CreateTemp(tmpDir, "Makefile") if err != nil { t.Fatalf("create stdin Makefile failed: %v", err) @@ -712,7 +719,14 @@ func TestMakeWrapDetectsEnvironmentStdinBeforeUnmatchedQuote(t *testing.T) { defer func() { makePath = oldMakePath }() outputFile := filepath.Join(tmpDir, "compile_commands.json") - tool := newTestTool(t, Config{OutputFile: outputFile, RegexCompile: RegexCompile, RegexFile: RegexFile, NoBuild: true, NoStrict: true}) + tool := newTestTool(t, Config{ + OutputFile: outputFile, + RegexCompile: RegexCompile, + RegexFile: RegexFile, + NoBuild: true, + NoStrict: true, + Encoding: EncodingRaw, + }) tool.MakeWrap(nil) commands := readCompilerTestCommands(t, outputFile) if tool.StatusCode != 0 || len(commands) != 1 || commands[0].File != "unmatched-quote.c" { diff --git a/internal/process_unix_test.go b/internal/process_unix_test.go index d689ac9..7ce5822 100644 --- a/internal/process_unix_test.go +++ b/internal/process_unix_test.go @@ -270,7 +270,11 @@ func TestRunMakeCommandBoundsContinuousOutputAfterLeaderExit(t *testing.T) { tmpDir := t.TempDir() pidFile := filepath.Join(tmpDir, "continuous-output.pid") ctx := context.Background() - cmd := processUnixHelperCommand(ctx, "continuous-parent", "COMPILEDB_TEST_PROCESS_PID_FILE="+pidFile) + // The race runtime's default one-second exit delay is unrelated to output draining. + cmd := processUnixHelperCommand(ctx, "continuous-parent", + "COMPILEDB_TEST_PROCESS_PID_FILE="+pidFile, + "GORACE=atexit_sleep_ms=0", + ) configureMakeCommand(cmd, ctx) type commandResult struct { diff --git a/internal/shell.go b/internal/shell.go index 5dc1c40..8e02bf5 100644 --- a/internal/shell.go +++ b/internal/shell.go @@ -9,6 +9,7 @@ import ( "os/exec" "strings" "sync" + "syscall" "mvdan.cc/sh/v3/expand" "mvdan.cc/sh/v3/interp" @@ -24,6 +25,19 @@ func runShellProgram(ctx context.Context, program, workingDir string, stdout, st if err != nil { return err } + return runParsedShellProgram(ctx, file, workingDir, expand.ListEnviron(os.Environ()...), nil, + nil, synchronizedWriter(stdout), synchronizedWriter(stderr)) +} + +func runParsedShellProgram( + ctx context.Context, + file *syntax.File, + workingDir string, + environment expand.Environ, + params []string, + stdin io.Reader, + stdout, stderr io.Writer, +) error { cleanup, err := syntax.NewParser(syntax.Variant(syntax.LangPOSIX)).Parse( strings.NewReader(shellCleanupCommand), "") if err != nil { @@ -64,6 +78,26 @@ func runShellProgram(ctx context.Context, program, workingDir string, stdout, st if ctx.Err() != nil { return context.Cause(ctx) } + if cmd.Process == nil && errors.Is(err, syscall.ENOEXEC) { + script, openErr := os.Open(executable) + if openErr != nil { + fmt.Fprintln(handler.Stderr, openErr) + return interp.ExitStatus(126) + } + file, parseErr := syntax.NewParser(syntax.Variant(syntax.LangPOSIX)).Parse(script, arguments[0]) + closeErr := script.Close() + if parseErr != nil { + fmt.Fprintln(handler.Stderr, parseErr) + return interp.ExitStatus(2) + } + if closeErr != nil { + fmt.Fprintln(handler.Stderr, closeErr) + return interp.ExitStatus(126) + } + scriptEnvironment := expand.ListEnviron(shellEnvironment(handler.Env)...) + return runParsedShellProgram(ctx, file, handler.Dir, scriptEnvironment, arguments[1:], + handler.Stdin, handler.Stdout, handler.Stderr) + } var exitError *exec.ExitError if errors.As(err, &exitError) { if status, ok := signaledExitCode(exitError); ok { @@ -75,12 +109,16 @@ func runShellProgram(ctx context.Context, program, workingDir string, stdout, st } } - runner, err := interp.New( + options := []interp.RunnerOption{ interp.Dir(workingDir), - interp.Env(expand.ListEnviron(os.Environ()...)), - interp.StdIO(nil, synchronizedWriter(stdout), synchronizedWriter(stderr)), + interp.Env(environment), + interp.StdIO(stdin, stdout, stderr), interp.ExecHandlers(execHandler), - ) + } + if params != nil { + options = append(options, interp.Params(append([]string{"--"}, params...)...)) + } + runner, err := interp.New(options...) if err != nil { return err } diff --git a/internal/shell_unix_test.go b/internal/shell_unix_test.go new file mode 100644 index 0000000..85ecbda --- /dev/null +++ b/internal/shell_unix_test.go @@ -0,0 +1,69 @@ +//go:build darwin || linux + +package internal + +import ( + "bytes" + "context" + "os" + "path/filepath" + "slices" + "testing" +) + +func TestRunShellProgramFallsBackToTextExecutable(t *testing.T) { + workingDir := t.TempDir() + scriptName := "compiledb-text-executable" + writeTextExecutable(t, filepath.Join(workingDir, scriptName), ` +printf '%s|%s|%s|%s' "$0" "$1" "$2" "$VALUE" +: > fallback-ran +exit "${EXIT_STATUS:-0}" +`) + t.Setenv("PATH", workingDir) + + var stdout bytes.Buffer + var stderr bytes.Buffer + program := "VALUE=exported " + scriptName + " -leading 'two words'; " + + "EXIT_STATUS=7 " + scriptName + " ignored >/dev/null || printf '|recovered'" + if err := runShellProgram(context.Background(), program, workingDir, &stdout, &stderr); err != nil { + t.Fatalf("run embedded shell failed: %v: %s", err, stderr.String()) + } + want := scriptName + "|-leading|two words|exported|recovered" + if stdout.String() != want { + t.Fatalf("text executable produced unexpected output: want %q, got %q", want, stdout.String()) + } + if _, err := os.Stat(filepath.Join(workingDir, "fallback-ran")); err != nil { + t.Fatalf("text executable did not use the tracked working directory: %v", err) + } +} + +func TestParseBacktickFallsBackToTextExecutable(t *testing.T) { + workingDir := t.TempDir() + scriptName := "compiledb-backtick-text-executable" + writeTextExecutable(t, filepath.Join(workingDir, scriptName), "printf generated\n") + t.Setenv("PATH", workingDir) + outputFile := filepath.Join(t.TempDir(), "compile_commands.json") + tool := newTestTool(t, Config{ + InputFile: "stdin", + OutputFile: outputFile, + BuildDir: workingDir, + RegexCompile: RegexCompile, + RegexFile: RegexFile, + NoStrict: true, + }) + + tool.Parse([]string{"gcc -DVALUE=`" + scriptName + "` -c fallback.c"}) + + commands := readCompilerTestCommands(t, outputFile) + if len(commands) != 1 || commands[0].File != "fallback.c" || + !slices.Contains(commands[0].Arguments, "-DVALUE=generated") { + t.Fatalf("text executable backtick was not expanded: %#v", commands) + } +} + +func writeTextExecutable(t *testing.T, path, contents string) { + t.Helper() + if err := os.WriteFile(path, []byte(contents), 0o755); err != nil { + t.Fatalf("write text executable failed: %v", err) + } +} diff --git a/tests/build.log b/tests/build.log index ea7b143..f39a42d 100644 --- a/tests/build.log +++ b/tests/build.log @@ -8,7 +8,7 @@ g++ -c test1.cpp \ g++ -c test_none.c ccache-clang-11 -c /opt/compiledb_test/test2.c -o objs/test2.c.o -cd /opt/compiledb_test && printf 't' 1>&2; gcc -c `test -f 'test_none.c' || echo 'src/'`test1.c `echo -DNESTED_CMD` && +cd /opt/compiledb_test && printf 't' 1>&2; gcc -c src/test1.c -DNESTED_CMD && printf 't' 1>&2; cc -c -DINC=\"t.h\" ../test2.c