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
2 changes: 2 additions & 0 deletions .cargo/config.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
[alias]
xtask = "run --manifest-path xtask/Cargo.toml --"
2 changes: 1 addition & 1 deletion .dockerignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,5 @@ target/
.git/
.github/
.vscode/
testdata/
xtask/
*.md
22 changes: 21 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,28 @@ jobs:
# name: coverage
# path: coverage.lcov

publish:
docker-test:
needs: test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5

- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3

- name: Build image
uses: docker/build-push-action@v6
with:
context: .
push: false
load: true
tags: envsubst:ci-test

- name: Run image integration tests
run: cargo xtask docker-test envsubst:ci-test

publish:
needs: [test, docker-test]
if: github.event_name == 'push'
runs-on: ubuntu-latest
permissions:
Expand Down
5 changes: 2 additions & 3 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
# syntax=docker/dockerfile:1

# Global args — declared before any FROM so they can be used in FROM instructions.
# Override at build time: --build-arg DISTROLESS_VARIANT=static-debian13 --build-arg DISTROLESS_TAG=nonroot
ARG DISTROLESS_VARIANT=static-debian12
ARG DISTROLESS_TAG=latest
Expand All @@ -21,7 +20,6 @@ COPY --from=verifier /etc/os-release /tmp/distroless-verified

ARG TARGETARCH

# Map Docker architecture → Rust musl target triple
RUN case "$TARGETARCH" in \
amd64) echo x86_64-unknown-linux-musl ;; \
arm64) echo aarch64-unknown-linux-musl ;; \
Expand All @@ -40,7 +38,8 @@ RUN mkdir src && echo 'fn main(){}' > src/main.rs \
&& rm -rf src

COPY src ./src
RUN cargo zigbuild --release --target "$(cat /rust_target)" \
RUN touch src/main.rs \
&& cargo zigbuild --release --target "$(cat /rust_target)" \
&& cp "target/$(cat /rust_target)/release/envsubst" /envsubst

# Final image
Expand Down
85 changes: 20 additions & 65 deletions Taskfile.yml
Original file line number Diff line number Diff line change
@@ -1,53 +1,37 @@
version: '3'

# pipefail ensures pipe errors propagate correctly on Unix.
# Task silently ignores shell options it cannot apply on the current platform.
set: [pipefail]

vars:
# Override at the CLI: task docker:build IMAGE=myregistry/myimage
IMAGE: envsubst

tasks:

# ── Default ──────────────────────────────────────────────────────────────────

default:
desc: List all available tasks
silent: true
cmds:
- task --list

# ── Rust ─────────────────────────────────────────────────────────────────────

test:
desc: Run tests
desc: Run unit and integration tests
cmds:
- cargo test
- cargo xtask test

test:coverage:
desc: Run tests with LLVM coverage and emit an HTML report under target/llvm-cov/
desc: Run tests with LLVM coverage (HTML report under target/llvm-cov/)
cmds:
- cargo llvm-cov --html

lint:
desc: Run all lints in parallel (Clippy + formatting check)
deps: [lint:clippy, lint:fmt]

lint:clippy:
desc: Run Clippy — all warnings treated as errors
cmds:
- cargo clippy -- -D warnings

lint:fmt:
desc: Check that source is formatted (does not modify files)
desc: Run Clippy and formatting check
cmds:
- cargo fmt --check
- cargo xtask lint

fmt:
desc: Auto-format all source files with rustfmt
desc: Auto-format source files
cmds:
- cargo fmt
- cargo xtask fmt

build:
desc: Debug build
Expand All @@ -61,54 +45,25 @@ tasks:

docker:build:
desc: >
Build one Docker variant for the current platform and load it into Docker.
Defaults to the standard variant (static-debian12, distroless:latest).
Override any combination at the CLI:
task docker:build VARIANT=static-debian13 DISTROLESS_TAG=nonroot IMAGE_TAG=mytest
Build the default image variant for the current platform (tag: dev).
Override the tag: task docker:build IMAGE_TAG=mytest
vars:
VARIANT: "{{.VARIANT | default \"static-debian12\"}}"
DISTROLESS_TAG: "{{.DISTROLESS_TAG | default \"latest\"}}"
IMAGE_TAG: "{{.IMAGE_TAG | default \"dev\"}}"
IMAGE_TAG: "{{.IMAGE_TAG | default \"dev\"}}"
cmds:
- >-
docker buildx build
--build-arg DISTROLESS_VARIANT={{.VARIANT}}
--build-arg DISTROLESS_TAG={{.DISTROLESS_TAG}}
--tag {{.IMAGE}}:{{.IMAGE_TAG}}
--load
.
- cargo xtask docker-build {{.IMAGE_TAG}}

docker:build:all:
desc: >
Build all four matrix variants for the current platform and load them
into Docker. Produces: dev, dev-nonroot, dev-debian13, dev-debian13-nonroot.
desc: Build all four image variants for the current platform
cmds:
- task: _docker:variant
vars: {VARIANT: static-debian12, DISTROLESS_TAG: latest, IMAGE_TAG: dev}
- task: _docker:variant
vars: {VARIANT: static-debian12, DISTROLESS_TAG: nonroot, IMAGE_TAG: dev-nonroot}
- task: _docker:variant
vars: {VARIANT: static-debian13, DISTROLESS_TAG: latest, IMAGE_TAG: dev-debian13}
- task: _docker:variant
vars: {VARIANT: static-debian13, DISTROLESS_TAG: nonroot, IMAGE_TAG: dev-debian13-nonroot}
- cargo xtask docker-build-all

ci:
desc: >
Run the full CI pipeline locally: test → lint → docker build (all variants).
Mirrors the GitHub Actions workflow — same steps, current platform only
(no multi-arch push). Requires Docker to be running.
docker:test:
desc: Build default image and run Docker integration tests
cmds:
- task: test
- task: lint
- task: docker:build:all
- cargo xtask docker-build
- cargo xtask docker-test {{.IMAGE}}:dev

_docker:variant:
internal: true
ci:
desc: Full CI pipeline — test, lint, docker-build, docker-test
cmds:
- >-
docker buildx build
--build-arg DISTROLESS_VARIANT={{.VARIANT}}
--build-arg DISTROLESS_TAG={{.DISTROLESS_TAG}}
--tag {{.IMAGE}}:{{.IMAGE_TAG}}
--load
.
- cargo xtask ci
36 changes: 18 additions & 18 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -338,8 +338,8 @@ mod tests {
#[test]
fn integration_template_to_output_dir() {
let out_dir = tempfile::TempDir::new().unwrap();
let base = PathBuf::from("testdata/integration");
let file = PathBuf::from("testdata/integration/template.yaml");
let base = PathBuf::from("xtask/testdata/integration");
let file = PathBuf::from("xtask/testdata/integration/template.yaml");

temp_env::with_vars(
[
Expand All @@ -363,8 +363,8 @@ mod tests {
#[test]
fn integration_nested_structure_preserved() {
let out_dir = tempfile::TempDir::new().unwrap();
let base = PathBuf::from("testdata/integration");
let file = PathBuf::from("testdata/integration/nested/service.conf");
let base = PathBuf::from("xtask/testdata/integration");
let file = PathBuf::from("xtask/testdata/integration/nested/service.conf");

temp_env::with_vars(
[
Expand Down Expand Up @@ -401,8 +401,8 @@ mod tests {
|| {
let preloaded = parse_env_file(env_file.path()).unwrap();
let ctx = EnvContext::new(preloaded, false);
let base = PathBuf::from("testdata/integration");
let file = PathBuf::from("testdata/integration/nested/service.conf");
let base = PathBuf::from("xtask/testdata/integration");
let file = PathBuf::from("xtask/testdata/integration/nested/service.conf");
process_file(&file, &base, Some(out_dir.path()), &ctx, false);
let out = fs::read_to_string(out_dir.path().join("nested/service.conf")).unwrap();
assert!(out.contains("host = db.example.com"));
Expand All @@ -415,8 +415,8 @@ mod tests {
#[test]
fn integration_env_file_mode_ignores_real_env() {
let out_dir = tempfile::TempDir::new().unwrap();
let base = PathBuf::from("testdata/integration");
let file = PathBuf::from("testdata/integration/nested/service.conf");
let base = PathBuf::from("xtask/testdata/integration");
let file = PathBuf::from("xtask/testdata/integration/nested/service.conf");

// Real env has different values — file mode must not see them
temp_env::with_vars(
Expand All @@ -443,8 +443,8 @@ mod tests {
#[test]
fn integration_missing_vars_tracked() {
let out_dir = tempfile::TempDir::new().unwrap();
let base = PathBuf::from("testdata/integration");
let file = PathBuf::from("testdata/integration/template.yaml");
let base = PathBuf::from("xtask/testdata/integration");
let file = PathBuf::from("xtask/testdata/integration/template.yaml");

temp_env::with_vars(
[("EV_GREETING", Some("hey")), ("EV_SERVICE", Some("svc"))],
Expand All @@ -459,8 +459,8 @@ mod tests {
#[test]
fn integration_stdout_when_no_output_dir() {
// process_file with no output_dir should not panic and not write files
let base = PathBuf::from("testdata/integration");
let file = PathBuf::from("testdata/integration/template.yaml");
let base = PathBuf::from("xtask/testdata/integration");
let file = PathBuf::from("xtask/testdata/integration/template.yaml");

temp_env::with_vars(
[("EV_GREETING", Some("hi")), ("EV_SERVICE", Some("s"))],
Expand All @@ -477,8 +477,8 @@ mod tests {
#[test]
fn fail_on_missing_real_env_has_unresolved_vars() {
let out_dir = tempfile::TempDir::new().unwrap();
let base = PathBuf::from("testdata/integration");
let file = PathBuf::from("testdata/integration/template.yaml");
let base = PathBuf::from("xtask/testdata/integration");
let file = PathBuf::from("xtask/testdata/integration/template.yaml");

// EV_GREETING and EV_SERVICE are set; EV_UNDEFINED_12345 is not
temp_env::with_vars(
Expand Down Expand Up @@ -512,8 +512,8 @@ mod tests {
let preloaded = parse_env_file(env_file.path()).unwrap();
let ctx = EnvContext::new(preloaded, false);

let base = PathBuf::from("testdata/integration");
let file = PathBuf::from("testdata/integration/template.yaml");
let base = PathBuf::from("xtask/testdata/integration");
let file = PathBuf::from("xtask/testdata/integration/template.yaml");
process_file(&file, &base, Some(out_dir.path()), &ctx, false);

let missing = ctx.missing_vars();
Expand All @@ -527,8 +527,8 @@ mod tests {
#[test]
fn fail_on_missing_all_vars_resolved_means_empty_missing() {
let out_dir = tempfile::TempDir::new().unwrap();
let base = PathBuf::from("testdata/integration");
let file = PathBuf::from("testdata/integration/nested/service.conf");
let base = PathBuf::from("xtask/testdata/integration");
let file = PathBuf::from("xtask/testdata/integration/nested/service.conf");

// All three vars in service.conf are provided
temp_env::with_vars(
Expand Down
Loading
Loading