Skip to content

Commit 2e91c77

Browse files
committed
feat(circleci): opt-in docker_build with BASE_HASH + DAG; add layer_caching option; fix --config .cigen output path; docs workflow waits for CI; tests + README updates
1 parent 598a18d commit 2e91c77

File tree

9 files changed

+211
-7
lines changed

9 files changed

+211
-7
lines changed

README.md

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ The tool can generate both static CircleCI configs and setup/dynamic configs. Th
1515
- Architecture variants per job (e.g., `build_amd64`, `build_arm64`)
1616
- Advanced git checkout: shallow clone by default with configurable clone/fetch options, host key scanning, and path overrides
1717
- Descriptive error messages and schema/data validation ([miette], JSON Schema)
18+
- Opt-in Docker builds with a single BASE_HASH and image DAG
1819

1920
## Not Yet Implemented / In Progress
2021

@@ -249,3 +250,54 @@ This project is licensed under the MIT License - see the LICENSE file for detail
249250
## Support
250251

251252
For issues and feature requests, please use the [GitHub issue tracker](https://github.com/DocSpring/cigen/issues).
253+
254+
### Docker Builds (opt-in)
255+
256+
CIGen can build and tag your CI Docker images as first-class jobs. Enable it with split config under `.cigen/config/docker_build.yml` or inline in your config:
257+
258+
```yaml
259+
docker_build:
260+
enabled: true
261+
# Optional on CircleCI cloud
262+
layer_caching: true
263+
264+
registry:
265+
repo: yourorg/ci
266+
# Default push behavior (true recommended on cloud)
267+
push: true
268+
269+
images:
270+
- name: ci_base
271+
dockerfile: docker/ci/base.Dockerfile
272+
context: .
273+
arch: [amd64]
274+
build_args:
275+
BASE_IMAGE: cimg/base:current
276+
# Sources for the canonical BASE_HASH (one hash across images)
277+
hash_sources:
278+
- scripts/package_versions_env.sh
279+
- .tool-versions
280+
- .ruby-version
281+
- docker/**/*.erb
282+
- scripts/docker/**
283+
depends_on: []
284+
# Optional per-image push override
285+
# push: false
286+
```
287+
288+
What happens:
289+
290+
- CIGen computes one `BASE_HASH` by hashing all declared `hash_sources` (path + content) across images.
291+
- For each image+arch, a `build_<image>` job builds `registry/<name>:<BASE_HASH>-<arch>` and (optionally) pushes it.
292+
- Downstream jobs that specify `image: <name>` are resolved to `registry/<name>:<BASE_HASH>-<arch>` and automatically `require` `build_<image>`.
293+
- Build jobs include job-status skip logic (native CircleCI cache or Redis) so unchanged images skip quickly.
294+
- On CircleCI cloud, `layer_caching: true` emits `setup_remote_docker: { docker_layer_caching: true }` for faster rebuilds.
295+
296+
Notes:
297+
298+
- If a job `image` contains `/` or `:`, it is treated as a full reference and not rewritten.
299+
- Per-image `push` overrides the registry default.
300+
301+
```]
302+
303+
```
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
provider: circleci
2+
output_path: .circleci
3+
workflows:
4+
test:
5+
jobs:
6+
build_and_use:
7+
image: ci_base
8+
steps:
9+
- run: echo "hello"
10+
11+
docker_build:
12+
enabled: true
13+
layer_caching: true
14+
registry:
15+
repo: example/repo
16+
push: false
17+
images:
18+
- name: ci_base
19+
dockerfile: Dockerfile
20+
context: .
21+
arch: [amd64]
22+
build_args: {}
23+
hash_sources:
24+
- Dockerfile
25+
depends_on: []
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
FROM alpine:3.19
2+
RUN echo hello
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
provider: circleci
2+
output_path: .circleci
3+
setup: false
4+
workflows:
5+
test:
6+
jobs:
7+
build_and_use:
8+
image: ci_base
9+
steps:
10+
- run: echo "hello"
11+
12+
docker_build:
13+
enabled: true
14+
registry:
15+
repo: example/repo
16+
push: false
17+
images:
18+
- name: ci_base
19+
dockerfile: Dockerfile
20+
context: .
21+
arch: [amd64]
22+
build_args: {}
23+
hash_sources:
24+
- Dockerfile
25+
depends_on: []
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
FROM alpine:3.19
2+
RUN echo hello

src/main.rs

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -136,8 +136,18 @@ fn main() -> Result<()> {
136136
};
137137

138138
if config_dir.exists() {
139+
// If config_dir is a .cigen directory, treat its parent as the project root (original_dir)
140+
let project_root = if config_dir.file_name() == Some(std::ffi::OsStr::new(".cigen"))
141+
{
142+
config_dir
143+
.parent()
144+
.map(|p| p.to_path_buf())
145+
.unwrap_or(original_dir.clone())
146+
} else {
147+
original_dir.clone()
148+
};
139149
// Initialize context before changing directory
140-
cigen::loader::context::init_context(original_dir, config_dir.clone());
150+
cigen::loader::context::init_context(project_root, config_dir.clone());
141151

142152
std::env::set_current_dir(&config_dir)?;
143153
tracing::debug!("Changed working directory to: {}", config_dir.display());

src/models/config.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,9 @@ pub struct DockerBuild {
119119
pub registry: DockerRegistry,
120120

121121
pub images: Vec<DockerBuildImage>,
122+
123+
#[serde(skip_serializing_if = "Option::is_none")]
124+
pub layer_caching: Option<bool>,
122125
}
123126

124127
#[derive(Debug, Clone, Serialize, Deserialize)]

src/providers/circleci/generator.rs

Lines changed: 33 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -92,13 +92,21 @@ impl CircleCIGenerator {
9292

9393
// Process all workflows
9494
for (workflow_name, jobs) in workflows {
95-
let workflow_config = self.build_workflow(workflow_name, jobs)?;
95+
// Augment jobs with docker_build if enabled
96+
let mut jobs_augmented = jobs.clone();
97+
if let Some(db) = &config.docker_build
98+
&& db.enabled
99+
{
100+
self.augment_with_docker_build(config, &mut jobs_augmented)?;
101+
}
102+
103+
let workflow_config = self.build_workflow(workflow_name, &jobs_augmented)?;
96104
circleci_config
97105
.workflows
98106
.insert(workflow_name.clone(), workflow_config);
99107

100108
// Process all jobs in the workflow with architecture variants
101-
for (job_name, job_def) in jobs {
109+
for (job_name, job_def) in &jobs_augmented {
102110
// Skip approval jobs (they are workflow-level only)
103111
if job_def.job_type.as_deref() == Some("approval") {
104112
continue;
@@ -511,8 +519,8 @@ impl CircleCIGenerator {
511519
),
512520
])).unwrap());
513521

514-
// setup_remote_docker
515-
raw_steps.push(serde_yaml::Value::String("setup_remote_docker".to_string()));
522+
// setup_remote_docker (optionally with layer caching)
523+
raw_steps.push(self.setup_remote_docker_step(config));
516524

517525
// docker login if pushing with auth
518526
let push_enabled = images_by_name
@@ -601,8 +609,8 @@ impl CircleCIGenerator {
601609
),
602610
])).unwrap());
603611

604-
// setup_remote_docker
605-
raw_steps.push(serde_yaml::Value::String("setup_remote_docker".to_string()));
612+
// setup_remote_docker (optionally with layer caching)
613+
raw_steps.push(self.setup_remote_docker_step(config));
606614
}
607615
}
608616

@@ -1655,6 +1663,25 @@ cat /tmp/continuation.json
16551663
println!(" # or visit: https://circleci.com/docs/local-cli/");
16561664
}
16571665

1666+
fn setup_remote_docker_step(&self, config: &Config) -> serde_yaml::Value {
1667+
if let Some(db) = &config.docker_build
1668+
&& db.layer_caching.unwrap_or(false)
1669+
{
1670+
let mut map = serde_yaml::Mapping::new();
1671+
let mut inner = serde_yaml::Mapping::new();
1672+
inner.insert(
1673+
serde_yaml::Value::String("docker_layer_caching".to_string()),
1674+
serde_yaml::Value::Bool(true),
1675+
);
1676+
map.insert(
1677+
serde_yaml::Value::String("setup_remote_docker".to_string()),
1678+
serde_yaml::Value::Mapping(inner),
1679+
);
1680+
return serde_yaml::Value::Mapping(map);
1681+
}
1682+
serde_yaml::Value::String("setup_remote_docker".to_string())
1683+
}
1684+
16581685
fn scan_for_template_commands(&self, config: &CircleCIConfig) -> HashSet<String> {
16591686
let mut used_commands = HashSet::new();
16601687

tests/snapshot_docker_build.rs

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
use assert_cmd::prelude::*;
2+
use std::fs;
3+
use std::path::PathBuf;
4+
use std::process::Command;
5+
6+
fn repo_root() -> PathBuf {
7+
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
8+
}
9+
10+
#[test]
11+
fn snapshot_docker_build_minimal() {
12+
let root = repo_root();
13+
let fixture_dir = root.join("integration_tests/circleci_docker_build_minimal");
14+
let output_dir = fixture_dir.join(".circleci");
15+
let _ = fs::remove_dir_all(&output_dir);
16+
fs::create_dir_all(&output_dir).unwrap();
17+
18+
let mut cmd = Command::cargo_bin("cigen").unwrap();
19+
cmd.current_dir(&fixture_dir)
20+
.env("CIGEN_SKIP_CIRCLECI_CLI", "1")
21+
.arg("generate");
22+
cmd.assert().success();
23+
24+
let yaml = fs::read_to_string(output_dir.join("config.yml")).unwrap();
25+
26+
// Should contain build job and resolved image tag
27+
assert!(yaml.contains("build_ci_base"), "missing build job");
28+
assert!(
29+
yaml.contains("example/repo/ci_base:"),
30+
"missing resolved image tag"
31+
);
32+
assert!(yaml.contains("requires:"), "missing requires between jobs");
33+
}
34+
35+
#[test]
36+
fn snapshot_docker_build_with_layer_cache() {
37+
let root = repo_root();
38+
let fixture_dir = root.join("integration_tests/circleci_docker_build_layer_cache");
39+
let output_dir = fixture_dir.join(".circleci");
40+
let _ = fs::remove_dir_all(&output_dir);
41+
fs::create_dir_all(&output_dir).unwrap();
42+
43+
let mut cmd = Command::cargo_bin("cigen").unwrap();
44+
cmd.current_dir(&fixture_dir)
45+
.env("CIGEN_SKIP_CIRCLECI_CLI", "1")
46+
.arg("generate");
47+
cmd.assert().success();
48+
49+
let yaml = fs::read_to_string(output_dir.join("config.yml")).unwrap();
50+
assert!(
51+
yaml.contains("setup_remote_docker:"),
52+
"missing setup_remote_docker step"
53+
);
54+
assert!(
55+
yaml.contains("docker_layer_caching: true"),
56+
"missing docker_layer_caching flag"
57+
);
58+
}

0 commit comments

Comments
 (0)