diff --git a/.github/workflows/release-js.yaml b/.github/workflows/release-js.yaml index 83d3a1a..ecadea7 100644 --- a/.github/workflows/release-js.yaml +++ b/.github/workflows/release-js.yaml @@ -13,11 +13,37 @@ jobs: - uses: actions/setup-node@v4 with: node-version: '24.x' - - name: Set version from tag and publish + - uses: actions/setup-go@v5 + with: + go-version: '1.24.x' + - name: Build platform binaries + run: | + LDFLAGS="-s -w -X github.com/reteps/dockerfmt/cmd.Version=${GITHUB_REF_NAME}" + build() { + pkg=$1; goos=$2; goarch=$3 + echo "building $pkg ($goos/$goarch)" + GOOS=$goos GOARCH=$goarch CGO_ENABLED=0 \ + go build -ldflags "$LDFLAGS" -o "js/npm/${pkg}/bin/dockerfmt" . + } + build linux-x64 linux amd64 + build linux-arm64 linux arm64 + build darwin-x64 darwin amd64 + build darwin-arm64 darwin arm64 + # Pin the main package, every sub-package, and the optionalDependencies to + # the release version so they publish in lockstep. + - name: Pin versions + run: node js/scripts/set-version.mjs "${GITHUB_REF_NAME#v}" + - name: Build JS library bindings + working-directory: js run: | - cd js - VERSION=${GITHUB_REF_NAME#v} - npm --no-git-tag-version version "${VERSION}" - npm ci + npm install --no-package-lock --omit=optional npm run build-js - npm publish --provenance --access public + # Publish the platform packages first so the main package's + # optionalDependencies resolve immediately on install. + - name: Publish platform packages + run: | + for dir in js/npm/*/; do + npm publish --provenance --access public "$dir" + done + - name: Publish main package + run: npm publish --provenance --access public ./js diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 795ace6..efd0f08 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -35,7 +35,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - # build and publish in parallel: linux/386, linux/amd64, linux/arm64, windows/386, windows/amd64, darwin/amd64, darwin/arm64 + # build and publish in parallel: linux/amd64, linux/arm64, darwin/arm64 goos: [linux, darwin] goarch: [amd64, arm64] exclude: @@ -48,4 +48,30 @@ jobs: github_token: ${{ secrets.GITHUB_TOKEN }} goos: ${{ matrix.goos }} goarch: ${{ matrix.goarch }} + ldflags: -X github.com/reteps/dockerfmt/cmd.Version=${{ github.ref_name }} + # dockerfmt is pure Go (no cgo), so a CGO_ENABLED=0 build is fully static and + # already runs on musl-based systems like Alpine. This job re-publishes the + # static linux binaries under a clearly "musl"-labeled asset name so musl + # users have an obvious download. The default (glibc-labeled) artifacts above + # are left untouched. + releases-musl-matrix: + name: Release Go Binary (musl) + runs-on: ubuntu-latest + strategy: + matrix: + goos: [linux] + goarch: [amd64, arm64] + steps: + - uses: actions/checkout@v4 + - uses: wangyoucao577/go-release-action@v1 + env: + # Guarantee a statically linked binary with no libc dependency. + CGO_ENABLED: 0 + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + goos: ${{ matrix.goos }} + goarch: ${{ matrix.goarch }} + # Append a "musl" suffix to the default asset name so the static, + # Alpine-compatible build is easy to identify. + asset_name: dockerfmt-${{ github.ref_name }}-${{ matrix.goos }}-${{ matrix.goarch }}-musl ldflags: -X github.com/reteps/dockerfmt/cmd.Version=${{ github.ref_name }} \ No newline at end of file diff --git a/js/README.md b/js/README.md index 32b69bb..26399d5 100644 --- a/js/README.md +++ b/js/README.md @@ -2,7 +2,6 @@ Bindings around the Golang `dockerfmt` tooling. It compiles the Go code to WebAssembly (using standard Go's `GOOS=js GOARCH=wasm` target), which is then used in the JS bindings. - ```js import { formatDockerfile } from '@reteps/dockerfmt' // Alternatively, you can use `formatDockerfileContents` to format a string instead of a file. @@ -11,3 +10,12 @@ const result = await formatDockerfile('../tests/comment.dockerfile', { indent: 4 console.log(result) ``` + +## CLI + +The package also ships the `dockerfmt` CLI. + +```sh +# Format a Dockerfile and print to stdout +npx dockerfmt Dockerfile +``` diff --git a/js/launcher.ts b/js/launcher.ts new file mode 100644 index 0000000..5ee0e82 --- /dev/null +++ b/js/launcher.ts @@ -0,0 +1,40 @@ +#!/usr/bin/env node +// Thin launcher that execs the real `dockerfmt` Go binary. There is no JS +// reimplementation of the CLI: the binary is built from cmd/root.go, so flags, +// defaults, EditorConfig handling and exit codes are guaranteed to match the +// standalone Go tool exactly. Each platform's binary ships in its own optional +// dependency (see optionalDependencies in package.json); npm installs only the +// one matching the host's os/cpu. +import { execFileSync } from 'node:child_process' +import { createRequire } from 'node:module' + +const require = createRequire(import.meta.url) + +const platform = process.platform +const arch = process.arch + +// Keep this list in sync with optionalDependencies in package.json, the +// directories under js/npm/, and the build matrix in release-js.yaml. +const pkg = `@reteps/dockerfmt-${platform}-${arch}` + +let binPath: string +try { + binPath = require.resolve(`${pkg}/bin/dockerfmt`) +} catch { + throw new Error( + `dockerfmt does not ship a prebuilt binary for ${platform}-${arch} ` + + `(expected optional dependency "${pkg}"). Supported platforms: ` + + `linux-x64, linux-arm64, darwin-x64, darwin-arm64. ` + + `Download the Go binary from ` + + `https://github.com/reteps/dockerfmt/releases instead.`, + ) +} + +try { + execFileSync(binPath, process.argv.slice(2), { stdio: 'inherit' }) +} catch (err: unknown) { + const status = (err as { status?: number; signal?: string })?.status + // Propagate the binary's own exit code (e.g. 1 from --check) so the launcher + // is behaviourally transparent. + process.exit(typeof status === 'number' ? status : 1) +} diff --git a/js/npm/darwin-arm64/bin/.gitignore b/js/npm/darwin-arm64/bin/.gitignore new file mode 100644 index 0000000..d6b7ef3 --- /dev/null +++ b/js/npm/darwin-arm64/bin/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/js/npm/darwin-arm64/package.json b/js/npm/darwin-arm64/package.json new file mode 100644 index 0000000..9aa5080 --- /dev/null +++ b/js/npm/darwin-arm64/package.json @@ -0,0 +1,14 @@ +{ + "name": "@reteps/dockerfmt-darwin-arm64", + "version": "0.0.0-dev", + "description": "The macOS arm64 binary for dockerfmt.", + "repository": { + "type": "git", + "url": "https://github.com/reteps/dockerfmt.git", + "directory": "js" + }, + "license": "MIT", + "os": ["darwin"], + "cpu": ["arm64"], + "files": ["bin"] +} diff --git a/js/npm/darwin-x64/bin/.gitignore b/js/npm/darwin-x64/bin/.gitignore new file mode 100644 index 0000000..d6b7ef3 --- /dev/null +++ b/js/npm/darwin-x64/bin/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/js/npm/darwin-x64/package.json b/js/npm/darwin-x64/package.json new file mode 100644 index 0000000..b8b650d --- /dev/null +++ b/js/npm/darwin-x64/package.json @@ -0,0 +1,14 @@ +{ + "name": "@reteps/dockerfmt-darwin-x64", + "version": "0.0.0-dev", + "description": "The macOS x64 binary for dockerfmt.", + "repository": { + "type": "git", + "url": "https://github.com/reteps/dockerfmt.git", + "directory": "js" + }, + "license": "MIT", + "os": ["darwin"], + "cpu": ["x64"], + "files": ["bin"] +} diff --git a/js/npm/linux-arm64/bin/.gitignore b/js/npm/linux-arm64/bin/.gitignore new file mode 100644 index 0000000..d6b7ef3 --- /dev/null +++ b/js/npm/linux-arm64/bin/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/js/npm/linux-arm64/package.json b/js/npm/linux-arm64/package.json new file mode 100644 index 0000000..0b70a9c --- /dev/null +++ b/js/npm/linux-arm64/package.json @@ -0,0 +1,14 @@ +{ + "name": "@reteps/dockerfmt-linux-arm64", + "version": "0.0.0-dev", + "description": "The Linux arm64 binary for dockerfmt.", + "repository": { + "type": "git", + "url": "https://github.com/reteps/dockerfmt.git", + "directory": "js" + }, + "license": "MIT", + "os": ["linux"], + "cpu": ["arm64"], + "files": ["bin"] +} diff --git a/js/npm/linux-x64/bin/.gitignore b/js/npm/linux-x64/bin/.gitignore new file mode 100644 index 0000000..d6b7ef3 --- /dev/null +++ b/js/npm/linux-x64/bin/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/js/npm/linux-x64/package.json b/js/npm/linux-x64/package.json new file mode 100644 index 0000000..757ed21 --- /dev/null +++ b/js/npm/linux-x64/package.json @@ -0,0 +1,14 @@ +{ + "name": "@reteps/dockerfmt-linux-x64", + "version": "0.0.0-dev", + "description": "The Linux x64 binary for dockerfmt.", + "repository": { + "type": "git", + "url": "https://github.com/reteps/dockerfmt.git", + "directory": "js" + }, + "license": "MIT", + "os": ["linux"], + "cpu": ["x64"], + "files": ["bin"] +} diff --git a/js/package.json b/js/package.json index 67f0f88..d1dbcb8 100644 --- a/js/package.json +++ b/js/package.json @@ -29,6 +29,15 @@ "./wasm_exec": "./dist/wasm_exec.js", "./wasm_exec.js": "./dist/wasm_exec.js" }, + "bin": { + "dockerfmt": "./dist/launcher.js" + }, + "optionalDependencies": { + "@reteps/dockerfmt-darwin-arm64": "0.0.0-dev", + "@reteps/dockerfmt-darwin-x64": "0.0.0-dev", + "@reteps/dockerfmt-linux-arm64": "0.0.0-dev", + "@reteps/dockerfmt-linux-x64": "0.0.0-dev" + }, "files": [ "dist" ], diff --git a/js/scripts/set-version.mjs b/js/scripts/set-version.mjs new file mode 100644 index 0000000..5a24dc2 --- /dev/null +++ b/js/scripts/set-version.mjs @@ -0,0 +1,42 @@ +// Pins the main package, every platform sub-package, and the main package's +// optionalDependencies to a single version. Run at publish time so the launcher +// always pulls in the exact-matching platform binary and the published versions +// can never drift apart. +// +// node scripts/set-version.mjs +import { readFileSync, writeFileSync, readdirSync } from 'node:fs' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' + +const version = process.argv[2] +if (!version) { + console.error('usage: node scripts/set-version.mjs ') + process.exit(1) +} + +const jsDir = dirname(dirname(fileURLToPath(import.meta.url))) +const npmDir = join(jsDir, 'npm') + +const writeJson = (path, obj) => + writeFileSync(path, JSON.stringify(obj, null, 2) + '\n') + +// Platform sub-packages. +const optionalDependencies = {} +for (const name of readdirSync(npmDir).sort()) { + const pkgPath = join(npmDir, name, 'package.json') + const pkg = JSON.parse(readFileSync(pkgPath, 'utf8')) + pkg.version = version + writeJson(pkgPath, pkg) + optionalDependencies[pkg.name] = version +} + +// Main package: bump version and pin every optional dependency exactly. +const mainPath = join(jsDir, 'package.json') +const main = JSON.parse(readFileSync(mainPath, 'utf8')) +main.version = version +main.optionalDependencies = optionalDependencies +writeJson(mainPath, main) + +console.log( + `Pinned ${version} on main + ${Object.keys(optionalDependencies).length} platform packages`, +) diff --git a/lib/format.go b/lib/format.go index d3722f3..bf13c80 100644 --- a/lib/format.go +++ b/lib/format.go @@ -63,10 +63,16 @@ func (n *ExtendedNode) directive() string { // prependFlags prepends flags (e.g. "--network=host") to content if any exist. // When any flag starts with "--mount", each flag is placed on its own continuation line. func prependFlags(flags []string, content string, c *Config) string { + return prependFlagsImpl(flags, content, c, hasMountFlag(flags)) +} + +// prependFlagsImpl prepends flags to content. When multiline is true, each flag +// is placed on its own continuation line. +func prependFlagsImpl(flags []string, content string, c *Config, multiline bool) string { if len(flags) == 0 { return content } - if hasMountFlag(flags) { + if multiline { indent := strings.Repeat(" ", int(c.IndentSize)) var b strings.Builder for _, flag := range flags { @@ -80,6 +86,12 @@ func prependFlags(flags []string, content string, c *Config) string { return strings.Join(flags, " ") + " " + content } +// hasLineContinuation reports whether the node's original source spanned multiple +// lines via "\" continuations. +func hasLineContinuation(n *ExtendedNode) bool { + return strings.Contains(n.OriginalMultiline, "\\\n") +} + func hasMountFlag(flags []string) bool { for _, f := range flags { if strings.HasPrefix(f, "--mount") { @@ -130,19 +142,18 @@ func extractDirectiveContent(n *ExtendedNode, flagCount int) (string, bool) { return parts[1], true } - var nodeFormatters map[string]func(*ExtendedNode, *Config) string func init() { nodeFormatters = map[string]func(*ExtendedNode, *Config) string{ - command.Add: formatSpaceSeparated, + command.Add: spaceSeparated(flagsOnOwnLines), command.Arg: formatBasic, command.Cmd: formatCmd, - command.Copy: formatSpaceSeparated, + command.Copy: spaceSeparated(flagsOnOwnLines), command.Entrypoint: formatCmd, command.Env: formatEnv, - command.Expose: formatSpaceSeparated, - command.From: formatSpaceSeparated, + command.Expose: spaceSeparated(argsOnOwnLines), + command.From: spaceSeparated(collapseLines), command.Healthcheck: formatBasic, command.Label: formatBasic, command.Maintainer: formatMaintainer, @@ -152,7 +163,7 @@ func init() { command.StopSignal: formatBasic, command.User: formatBasic, command.Volume: formatBasic, - command.Workdir: formatSpaceSeparated, + command.Workdir: spaceSeparated(collapseLines), } } @@ -528,14 +539,40 @@ func formatCmd(n *ExtendedNode, c *Config) string { return n.directive() + " " + prependFlags(flags, shell, c) } -func formatSpaceSeparated(n *ExtendedNode, c *Config) string { - isJSON := n.Attributes["json"] - cmd, success := GetHeredoc(n) - if !success { - cmd = prependFlags(n.Flags, strings.Join(getCmd(n.Next, isJSON), " "), c) + "\n" - } +// multilineMode controls how a space-separated directive that the author wrote +// across multiple "\" continuation lines is re-emitted. The modes differ because +// the natural break point differs per directive: COPY/ADD break before each flag +// but keep " " together, while EXPOSE breaks before each port. +type multilineMode int + +const ( + // collapseLines always joins everything onto a single line (FROM, WORKDIR). + collapseLines multilineMode = iota + // flagsOnOwnLines keeps each flag on its own continuation line (COPY, ADD). + flagsOnOwnLines + // argsOnOwnLines keeps each argument on its own continuation line (EXPOSE). + argsOnOwnLines +) + +// spaceSeparated returns a formatter for directives whose payload is a list of +// flags and space-separated arguments (COPY, ADD, EXPOSE, FROM, WORKDIR). The +// mode selects how multiline source is preserved; see multilineMode. +func spaceSeparated(mode multilineMode) func(*ExtendedNode, *Config) string { + return func(n *ExtendedNode, c *Config) string { + isJSON := n.Attributes["json"] + cmd, success := GetHeredoc(n) + if !success { + argSep := " " + if mode == argsOnOwnLines && hasLineContinuation(n) { + argSep = " \\\n" + strings.Repeat(" ", int(c.IndentSize)) + } + content := strings.Join(getCmd(n.Next, isJSON), argSep) + flagsMultiline := mode == flagsOnOwnLines && (hasLineContinuation(n) || hasMountFlag(n.Flags)) + cmd = prependFlagsImpl(n.Flags, content, c, flagsMultiline) + "\n" + } - return n.directive() + " " + cmd + return n.directive() + " " + cmd + } } func formatMaintainer(n *ExtendedNode, c *Config) string { diff --git a/tests/in/copy.dockerfile b/tests/in/copy.dockerfile new file mode 100644 index 0000000..fb4318e --- /dev/null +++ b/tests/in/copy.dockerfile @@ -0,0 +1,9 @@ +FROM scratch +COPY --exclude=nginx-default.conf \ + --exclude=zap-scan-automation-framework.yml \ + --exclude=renovate.json5 \ + --exclude=compose.yml \ + . . +COPY --chown=user:group ./single-line /dest +ADD --keep-git-dir \ + ./ /data/src diff --git a/tests/in/expose.dockerfile b/tests/in/expose.dockerfile new file mode 100644 index 0000000..24350b2 --- /dev/null +++ b/tests/in/expose.dockerfile @@ -0,0 +1,5 @@ +FROM scratch +EXPOSE 80 \ + 443 \ + 8080 +EXPOSE 9000 diff --git a/tests/out/copy.dockerfile b/tests/out/copy.dockerfile new file mode 100644 index 0000000..a5f3296 --- /dev/null +++ b/tests/out/copy.dockerfile @@ -0,0 +1,9 @@ +FROM scratch +COPY --exclude=nginx-default.conf \ + --exclude=zap-scan-automation-framework.yml \ + --exclude=renovate.json5 \ + --exclude=compose.yml \ + . . +COPY --chown=user:group ./single-line /dest +ADD --keep-git-dir \ + ./ /data/src diff --git a/tests/out/expose.dockerfile b/tests/out/expose.dockerfile new file mode 100644 index 0000000..dc32a3e --- /dev/null +++ b/tests/out/expose.dockerfile @@ -0,0 +1,5 @@ +FROM scratch +EXPOSE 80 \ + 443 \ + 8080 +EXPOSE 9000