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
38 changes: 32 additions & 6 deletions .github/workflows/release-js.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
28 changes: 27 additions & 1 deletion .github/workflows/release.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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 }}
10 changes: 9 additions & 1 deletion js/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
```
40 changes: 40 additions & 0 deletions js/launcher.ts
Original file line number Diff line number Diff line change
@@ -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)
}
2 changes: 2 additions & 0 deletions js/npm/darwin-arm64/bin/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
*
!.gitignore
14 changes: 14 additions & 0 deletions js/npm/darwin-arm64/package.json
Original file line number Diff line number Diff line change
@@ -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"]
}
2 changes: 2 additions & 0 deletions js/npm/darwin-x64/bin/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
*
!.gitignore
14 changes: 14 additions & 0 deletions js/npm/darwin-x64/package.json
Original file line number Diff line number Diff line change
@@ -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"]
}
2 changes: 2 additions & 0 deletions js/npm/linux-arm64/bin/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
*
!.gitignore
14 changes: 14 additions & 0 deletions js/npm/linux-arm64/package.json
Original file line number Diff line number Diff line change
@@ -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"]
}
2 changes: 2 additions & 0 deletions js/npm/linux-x64/bin/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
*
!.gitignore
14 changes: 14 additions & 0 deletions js/npm/linux-x64/package.json
Original file line number Diff line number Diff line change
@@ -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"]
}
9 changes: 9 additions & 0 deletions js/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
],
Expand Down
42 changes: 42 additions & 0 deletions js/scripts/set-version.mjs
Original file line number Diff line number Diff line change
@@ -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 <version>
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 <version>')
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`,
)
65 changes: 51 additions & 14 deletions lib/format.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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") {
Expand Down Expand Up @@ -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,
Expand All @@ -152,7 +163,7 @@ func init() {
command.StopSignal: formatBasic,
command.User: formatBasic,
command.Volume: formatBasic,
command.Workdir: formatSpaceSeparated,
command.Workdir: spaceSeparated(collapseLines),
}
}

Expand Down Expand Up @@ -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 "<src> <dst>" 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 {
Expand Down
9 changes: 9 additions & 0 deletions tests/in/copy.dockerfile
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading