From 7f4917b9310140b311ef1d1579f7db6b63c7cd41 Mon Sep 17 00:00:00 2001 From: Axon Stone Date: Wed, 7 Oct 2026 15:38:19 +0200 Subject: [PATCH 1/2] chore(ci): parallelize checks and cache CDK Docker images --- .gitattributes | 1 + .github/actionlint.yaml | 7 ++ .github/workflows/cdk-deploy-dev.yml | 17 ++-- .github/workflows/cdk-deploy-test.yml | 17 ++-- .github/workflows/cdk-validate.yml | 55 +++++++++++- .gitignore | 1 + .projen/files.json | 1 + .projenrc.py | 23 ++++- src/bin/cicd_helper.py | 120 +++++++++++++++++++++++--- tests/test_cicd_helper.py | 72 ++++++++++++++++ 10 files changed, 284 insertions(+), 30 deletions(-) create mode 100644 .github/actionlint.yaml create mode 100644 tests/test_cicd_helper.py diff --git a/.gitattributes b/.gitattributes index aec5f68..d30e075 100644 --- a/.gitattributes +++ b/.gitattributes @@ -2,6 +2,7 @@ * text=auto eol=lf /.gitattributes linguist-generated +/.github/actionlint.yaml linguist-generated /.github/dependabot.yml linguist-generated /.github/workflows/cdk-deploy-dev.yml linguist-generated /.github/workflows/cdk-deploy-test.yml linguist-generated diff --git a/.github/actionlint.yaml b/.github/actionlint.yaml new file mode 100644 index 0000000..d3fb3b5 --- /dev/null +++ b/.github/actionlint.yaml @@ -0,0 +1,7 @@ +# ~~ Generated by projen. To modify, edit .projenrc.py and run "uv run projen". + +paths: + .github/workflows/{cdk-validate,cdk-deploy-*}.yml: + ignore: + - ^step must run script with "run" section or run action with "uses" section$ + - ^unexpected key "background" for step to (run shell command|execute action)\. expected one of .+$ diff --git a/.github/workflows/cdk-deploy-dev.yml b/.github/workflows/cdk-deploy-dev.yml index 3c4317b..706147a 100644 --- a/.github/workflows/cdk-deploy-dev.yml +++ b/.github/workflows/cdk-deploy-dev.yml @@ -28,18 +28,23 @@ jobs: - name: Setup uv id: setup_uv uses: astral-sh/setup-uv@v10.0.1 - - name: Install dependencies - id: install_dependencies - run: uv sync - - name: Install cdk cli - id: install_cdk_cli - run: npm install -g aws-cdk@2.1139.0 - name: Configure AWS credentials id: configure_aws_credentials uses: aws-actions/configure-aws-credentials@v6 with: role-to-assume: arn:aws:iam::987654321012:role/GitHubDeployRole aws-region: us-east-1 + background: true + - name: Install dependencies + id: install_dependencies + run: uv sync --frozen + - name: Install cdk cli + id: install_cdk_cli + run: npm install -g aws-cdk@2.1139.0 + - name: Wait for AWS authentication + id: wait_for_aws_authentication + wait: + - configure_aws_credentials - name: Validate CDK for the DEV environment id: validate_cdk_for_the_dev_environment run: uv run projen dev:validate diff --git a/.github/workflows/cdk-deploy-test.yml b/.github/workflows/cdk-deploy-test.yml index 70ec245..c517660 100644 --- a/.github/workflows/cdk-deploy-test.yml +++ b/.github/workflows/cdk-deploy-test.yml @@ -28,18 +28,23 @@ jobs: - name: Setup uv id: setup_uv uses: astral-sh/setup-uv@v10.0.1 - - name: Install dependencies - id: install_dependencies - run: uv sync - - name: Install cdk cli - id: install_cdk_cli - run: npm install -g aws-cdk@2.1139.0 - name: Configure AWS credentials id: configure_aws_credentials uses: aws-actions/configure-aws-credentials@v6 with: role-to-assume: arn:aws:iam::123456789012:role/GitHubDeployRole aws-region: us-east-1 + background: true + - name: Install dependencies + id: install_dependencies + run: uv sync --frozen + - name: Install cdk cli + id: install_cdk_cli + run: npm install -g aws-cdk@2.1139.0 + - name: Wait for AWS authentication + id: wait_for_aws_authentication + wait: + - configure_aws_credentials - name: Validate CDK for the TEST environment id: validate_cdk_for_the_test_environment run: uv run projen test:validate diff --git a/.github/workflows/cdk-validate.yml b/.github/workflows/cdk-validate.yml index 7c0fb50..5c7cc0e 100644 --- a/.github/workflows/cdk-validate.yml +++ b/.github/workflows/cdk-validate.yml @@ -26,10 +26,57 @@ jobs: uses: astral-sh/setup-uv@v10.0.1 - name: Install dependencies id: install_dependencies - run: uv sync + run: uv sync --frozen - name: Install cdk cli id: install_cdk_cli run: npm install -g aws-cdk@2.1139.0 - - name: Validate CDK app against the default rule set - id: validate_cdk_app_against_the_default_rule_set - run: uv run projen validate + - name: Choose Docker cache refresh week + id: docker_cache_epoch + run: echo "week=$(date -u +%G-%V)" >> "$GITHUB_OUTPUT" + - name: Restore Docker asset images + id: docker_cache + uses: actions/cache/restore@v6 + with: + path: ${{ runner.temp }}/docker-assets + key: docker-assets-v1-${{ runner.os }}-${{ runner.arch }}-${{ steps.docker_cache_epoch.outputs.week }}-${{ hashFiles('uv.lock', 'src/**', 'tests/**') }} + restore-keys: docker-assets-v1-${{ runner.os }}-${{ runner.arch }}-${{ steps.docker_cache_epoch.outputs.week }}- + - name: Load Docker asset images + id: load_docker_asset_images + run: if [ -f "$RUNNER_TEMP/docker-assets/images.tar" ]; then docker load --input "$RUNNER_TEMP/docker-assets/images.tar"; fi + - name: Validate dev offline + id: validate_dev + run: uv run projen dev:validate --no-online --output cdk.out/dev + background: true + - name: Validate test offline + id: validate_test + run: uv run projen test:validate --no-online --output cdk.out/test + background: true + - name: Run tests + id: unit_tests + run: uv run projen test + background: true + - name: Wait for validation and tests + id: wait_for_validation_and_tests + wait: + - validate_dev + - validate_test + - unit_tests + - name: Export Docker asset images + id: export_docker_images + if: steps.docker_cache.outputs.cache-hit != 'true' + run: |- + images=() + while IFS= read -r image; do images+=("$image"); done < <(docker image ls --filter "reference=cdk-*" --format "{{.Repository}}:{{.Tag}}") + if [ "${#images[@]}" -gt 0 ]; then + mkdir -p "$RUNNER_TEMP/docker-assets" + docker save --output "$RUNNER_TEMP/docker-assets/images.tar" "${images[@]}" + echo "has-images=true" >> "$GITHUB_OUTPUT" + fi + shell: bash + - name: Save Docker asset images + id: save_docker_asset_images + if: steps.export_docker_images.outputs.has-images == 'true' + uses: actions/cache/save@v6 + with: + path: ${{ runner.temp }}/docker-assets + key: ${{ steps.docker_cache.outputs.cache-primary-key }} diff --git a/.gitignore b/.gitignore index d456670..b6c1ae2 100644 --- a/.gitignore +++ b/.gitignore @@ -92,6 +92,7 @@ cython_debug/ /cdk.out/ .cdk.staging/ !/.github/workflows/cdk-validate.yml +!/.github/actionlint.yaml !/.github/dependabot.yml !/.github/workflows/cdk-deploy-dev.yml !/.github/workflows/cdk-deploy-test.yml diff --git a/.projen/files.json b/.projen/files.json index 75fb5f7..70c38e3 100644 --- a/.projen/files.json +++ b/.projen/files.json @@ -1,6 +1,7 @@ { "files": [ ".gitattributes", + ".github/actionlint.yaml", ".github/dependabot.yml", ".github/workflows/cdk-deploy-dev.yml", ".github/workflows/cdk-deploy-test.yml", diff --git a/.projenrc.py b/.projenrc.py index e676399..445ab9d 100644 --- a/.projenrc.py +++ b/.projenrc.py @@ -122,7 +122,28 @@ pin_github_actions(gh) # Validate the CDK app offline on every pull request (no AWS credentials required) -cdk_validate_workflow(gh, python_version, cdk_cli_version) +cdk_validate_workflow( + gh, + python_version, + cdk_cli_version, + [env for env, account in target_accounts.items() if account], +) + +# Temporary until actionlint supports background/wait steps (rhysd/actionlint#693). +YamlFile( + project, + ".github/actionlint.yaml", + obj={ + "paths": { + ".github/workflows/{cdk-validate,cdk-deploy-*}.yml": { + "ignore": [ + '^step must run script with "run" section or run action with "uses" section$', + r'^unexpected key "background" for step to (run shell command|execute action)\. expected one of .+$', + ], + }, + }, + }, +) # Add Dependabot configuration for uv YamlFile( diff --git a/src/bin/cicd_helper.py b/src/bin/cicd_helper.py index 7fc4039..2c50a26 100644 --- a/src/bin/cicd_helper.py +++ b/src/bin/cicd_helper.py @@ -5,6 +5,8 @@ # render the same versions as the workflows built here. GITHUB_ACTIONS = { "checkout": "actions/checkout@v7", + "cache_restore": "actions/cache/restore@v6", + "cache_save": "actions/cache/save@v6", "configure_aws_credentials": "aws-actions/configure-aws-credentials@v6", "semantic_pull_request": "amannn/action-semantic-pull-request@v6", "setup_python": "actions/setup-python@v7", @@ -18,7 +20,7 @@ def pin_github_actions(gh): gh.actions.set(action.split("@")[0], action) -def cdk_environment_steps(python_version, cdk_cli_version): +def cdk_environment_steps(python_version, cdk_cli_version, aws_credentials=None): """Checkout, Python, uv, project dependencies and the CDK CLI.""" return [ { @@ -38,19 +40,86 @@ def cdk_environment_steps(python_version, cdk_cli_version): "name": "Setup uv", "uses": GITHUB_ACTIONS["setup_uv"], }, + *([aws_credentials] if aws_credentials else []), { "name": "Install dependencies", - "run": "uv sync", + "run": "uv sync --frozen", }, { "name": "Install cdk cli", "run": f"npm install -g aws-cdk@{cdk_cli_version}", }, + *( + [ + { + "name": "Wait for AWS authentication", + "wait": ["configure_aws_credentials"], + } + ] + if aws_credentials + else [] + ), ] -def cdk_validate_workflow(gh, python_version, cdk_cli_version): +def docker_asset_cache_steps(): + """Restore content-addressed bundling images; skip uploads for apps without Docker assets.""" + cache_path = "${{ runner.temp }}/docker-assets" + cache_prefix = "docker-assets-v1-${{ runner.os }}-${{ runner.arch }}-${{ steps.docker_cache_epoch.outputs.week }}-" + restore = [ + { + "name": "Choose Docker cache refresh week", + "id": "docker_cache_epoch", + "run": 'echo "week=$(date -u +%G-%V)" >> "$GITHUB_OUTPUT"', + }, + { + "name": "Restore Docker asset images", + "id": "docker_cache", + "uses": GITHUB_ACTIONS["cache_restore"], + "with": { + "path": cache_path, + "key": cache_prefix + + "${{ hashFiles('uv.lock', 'src/**', 'tests/**') }}", + "restore-keys": cache_prefix, + }, + }, + { + "name": "Load Docker asset images", + "run": 'if [ -f "$RUNNER_TEMP/docker-assets/images.tar" ]; then docker load --input "$RUNNER_TEMP/docker-assets/images.tar"; fi', + }, + ] + save = [ + { + "name": "Export Docker asset images", + "id": "export_docker_images", + "if": "steps.docker_cache.outputs.cache-hit != 'true'", + "shell": "bash", + "run": ( + "images=()\n" + 'while IFS= read -r image; do images+=("$image"); done < <(docker image ls --filter "reference=cdk-*" --format "{{.Repository}}:{{.Tag}}")\n' + 'if [ "${#images[@]}" -gt 0 ]; then\n' + ' mkdir -p "$RUNNER_TEMP/docker-assets"\n' + ' docker save --output "$RUNNER_TEMP/docker-assets/images.tar" "${images[@]}"\n' + ' echo "has-images=true" >> "$GITHUB_OUTPUT"\n' + "fi" + ), + }, + { + "name": "Save Docker asset images", + "if": "steps.export_docker_images.outputs.has-images == 'true'", + "uses": GITHUB_ACTIONS["cache_save"], + "with": { + "path": cache_path, + "key": "${{ steps.docker_cache.outputs.cache-primary-key }}", + }, + }, + ] + return restore, save + + +def cdk_validate_workflow(gh, python_version, cdk_cli_version, environments): """Validate the CDK app offline on pull requests, without AWS credentials.""" + restore_cache, save_cache = docker_asset_cache_steps() workflow = github.GithubWorkflow(gh, "cdk-validate") workflow.on(pull_request={}, workflow_dispatch={}) workflow.add_jobs( @@ -63,10 +132,30 @@ def cdk_validate_workflow(gh, python_version, cdk_cli_version): }, "steps": [ *cdk_environment_steps(python_version, cdk_cli_version), + *restore_cache, + *[ + { + "id": f"validate_{env}", + "name": f"Validate {env} offline", + "background": True, + "run": f"uv run projen {env}:validate --no-online --output cdk.out/{env}", + } + for env in environments + ], { - "name": "Validate CDK app against the default rule set", - "run": "uv run projen validate", + "id": "unit_tests", + "name": "Run tests", + "background": True, + "run": "uv run projen test", }, + { + "name": "Wait for validation and tests", + "wait": [ + *[f"validate_{env}" for env in environments], + "unit_tests", + ], + }, + *save_cache, ], }, } @@ -93,15 +182,20 @@ def github_cicd(gh, account, env, python_version, aws_region, cdk_cli_version): "idToken": github.workflows.JobPermission.WRITE, }, "steps": [ - *cdk_environment_steps(python_version, cdk_cli_version), - { - "name": "Configure AWS credentials", - "uses": GITHUB_ACTIONS["configure_aws_credentials"], - "with": { - "role-to-assume": f"arn:aws:iam::{account}:role/GitHubDeployRole", - "aws-region": aws_region, + *cdk_environment_steps( + python_version, + cdk_cli_version, + aws_credentials={ + "id": "configure_aws_credentials", + "name": "Configure AWS credentials", + "background": True, + "uses": GITHUB_ACTIONS["configure_aws_credentials"], + "with": { + "role-to-assume": f"arn:aws:iam::{account}:role/GitHubDeployRole", + "aws-region": aws_region, + }, }, - }, + ), { "name": f"Validate CDK for the {env.upper()} environment", "run": f"uv run projen {env}:validate", diff --git a/tests/test_cicd_helper.py b/tests/test_cicd_helper.py new file mode 100644 index 0000000..c578f1d --- /dev/null +++ b/tests/test_cicd_helper.py @@ -0,0 +1,72 @@ +import re + +import pytest +from projen.awscdk import AwsCdkPythonApp + +from src.bin.cicd_helper import cdk_validate_workflow, github_cicd + + +@pytest.fixture +def project(tmp_path): + return AwsCdkPythonApp( + name="fixture", + module_name="src", + version="0.0.0", + cdk_version="2.267.0", + author_email="test@example.com", + author_name="Test", + outdir=str(tmp_path), + ) + + +def workflow_yaml(workflow, tmp_path): + # get_job deserializes wait-only steps as the older required run/uses union. + # Inspect the emitted public workflow contract instead. + workflow.file.synthesize() + return (tmp_path / workflow.file.path).read_text() + + +def test_offline_validation_isolates_environments_and_waits_for_checks( + project, tmp_path +): + workflow = cdk_validate_workflow( + project.github, "3.13", "2.1139.0", ["dev", "test"] + ) + output = workflow_yaml(workflow, tmp_path) + checks = [ + block for block in output.split(" - name:") if "background: true" in block + ] + assert [re.findall(r"run: (.+)", block)[0] for block in checks] == [ + "uv run projen dev:validate --no-online --output cdk.out/dev", + "uv run projen test:validate --no-online --output cdk.out/test", + "uv run projen test", + ] + ids = [re.findall(r"id: (.+)", block)[0] for block in checks] + assert len(set(ids)) == 3 + barrier = output.split(" wait:")[-1] + assert re.findall(r" - (.+)", barrier) == ids + assert "continue-on-error: true" not in output + + +def test_deploy_waits_for_authentication_after_installing_dependencies( + project, tmp_path +): + github_cicd(project.github, "123456789012", "dev", "3.13", "us-east-1", "2.1139.0") + workflow = project.github.try_find_workflow("cdk-deploy-dev") + output = workflow_yaml(workflow, tmp_path) + auth = next( + block + for block in output.split(" - name:") + if "Configure AWS credentials" in block + ) + assert "background: true" in auth + assert output.index("Configure AWS credentials") < output.index( + "Install dependencies" + ) + assert output.index("Install dependencies") < output.index( + "Wait for AWS authentication" + ) + assert output.index("Wait for AWS authentication") < output.index( + "Validate CDK for the DEV" + ) + assert " - configure_aws_credentials" in output From 3409801d3ec52f19fc8f56e8f7796fa19496e5be Mon Sep 17 00:00:00 2001 From: Axon Stone Date: Wed, 7 Oct 2026 16:00:03 +0200 Subject: [PATCH 2/2] fix(ci): isolate assemblies and fingerprint all Docker contexts --- .github/workflows/cdk-validate.yml | 12 ++++--- src/bin/cicd_helper.py | 9 +++-- tests/test_cicd_helper.py | 57 ++++++++++++++++++++++++++++-- 3 files changed, 65 insertions(+), 13 deletions(-) diff --git a/.github/workflows/cdk-validate.yml b/.github/workflows/cdk-validate.yml index 5c7cc0e..fdb1b1b 100644 --- a/.github/workflows/cdk-validate.yml +++ b/.github/workflows/cdk-validate.yml @@ -32,24 +32,26 @@ jobs: run: npm install -g aws-cdk@2.1139.0 - name: Choose Docker cache refresh week id: docker_cache_epoch - run: echo "week=$(date -u +%G-%V)" >> "$GITHUB_OUTPUT" + run: |- + echo "week=$(date -u +%G-%V)" >> "$GITHUB_OUTPUT" + echo "source=$(git rev-parse "HEAD^{tree}")" >> "$GITHUB_OUTPUT" - name: Restore Docker asset images id: docker_cache uses: actions/cache/restore@v6 with: path: ${{ runner.temp }}/docker-assets - key: docker-assets-v1-${{ runner.os }}-${{ runner.arch }}-${{ steps.docker_cache_epoch.outputs.week }}-${{ hashFiles('uv.lock', 'src/**', 'tests/**') }} - restore-keys: docker-assets-v1-${{ runner.os }}-${{ runner.arch }}-${{ steps.docker_cache_epoch.outputs.week }}- + key: docker-assets-v2-${{ runner.os }}-${{ runner.arch }}-${{ steps.docker_cache_epoch.outputs.week }}-${{ steps.docker_cache_epoch.outputs.source }} + restore-keys: docker-assets-v2-${{ runner.os }}-${{ runner.arch }}-${{ steps.docker_cache_epoch.outputs.week }}- - name: Load Docker asset images id: load_docker_asset_images run: if [ -f "$RUNNER_TEMP/docker-assets/images.tar" ]; then docker load --input "$RUNNER_TEMP/docker-assets/images.tar"; fi - name: Validate dev offline id: validate_dev - run: uv run projen dev:validate --no-online --output cdk.out/dev + run: uv run projen dev:validate --no-online --output "${{ runner.temp }}/cdk-assemblies/dev" background: true - name: Validate test offline id: validate_test - run: uv run projen test:validate --no-online --output cdk.out/test + run: uv run projen test:validate --no-online --output "${{ runner.temp }}/cdk-assemblies/test" background: true - name: Run tests id: unit_tests diff --git a/src/bin/cicd_helper.py b/src/bin/cicd_helper.py index 2c50a26..523fd45 100644 --- a/src/bin/cicd_helper.py +++ b/src/bin/cicd_helper.py @@ -65,12 +65,12 @@ def cdk_environment_steps(python_version, cdk_cli_version, aws_credentials=None) def docker_asset_cache_steps(): """Restore content-addressed bundling images; skip uploads for apps without Docker assets.""" cache_path = "${{ runner.temp }}/docker-assets" - cache_prefix = "docker-assets-v1-${{ runner.os }}-${{ runner.arch }}-${{ steps.docker_cache_epoch.outputs.week }}-" + cache_prefix = "docker-assets-v2-${{ runner.os }}-${{ runner.arch }}-${{ steps.docker_cache_epoch.outputs.week }}-" restore = [ { "name": "Choose Docker cache refresh week", "id": "docker_cache_epoch", - "run": 'echo "week=$(date -u +%G-%V)" >> "$GITHUB_OUTPUT"', + "run": 'echo "week=$(date -u +%G-%V)" >> "$GITHUB_OUTPUT"\necho "source=$(git rev-parse "HEAD^{tree}")" >> "$GITHUB_OUTPUT"', }, { "name": "Restore Docker asset images", @@ -78,8 +78,7 @@ def docker_asset_cache_steps(): "uses": GITHUB_ACTIONS["cache_restore"], "with": { "path": cache_path, - "key": cache_prefix - + "${{ hashFiles('uv.lock', 'src/**', 'tests/**') }}", + "key": cache_prefix + "${{ steps.docker_cache_epoch.outputs.source }}", "restore-keys": cache_prefix, }, }, @@ -138,7 +137,7 @@ def cdk_validate_workflow(gh, python_version, cdk_cli_version, environments): "id": f"validate_{env}", "name": f"Validate {env} offline", "background": True, - "run": f"uv run projen {env}:validate --no-online --output cdk.out/{env}", + "run": f'uv run projen {env}:validate --no-online --output "${{{{ runner.temp }}}}/cdk-assemblies/{env}"', } for env in environments ], diff --git a/tests/test_cicd_helper.py b/tests/test_cicd_helper.py index c578f1d..ae84cb6 100644 --- a/tests/test_cicd_helper.py +++ b/tests/test_cicd_helper.py @@ -1,9 +1,15 @@ +import os import re +import subprocess import pytest from projen.awscdk import AwsCdkPythonApp -from src.bin.cicd_helper import cdk_validate_workflow, github_cicd +from src.bin.cicd_helper import ( + cdk_validate_workflow, + docker_asset_cache_steps, + github_cicd, +) @pytest.fixture @@ -37,8 +43,8 @@ def test_offline_validation_isolates_environments_and_waits_for_checks( block for block in output.split(" - name:") if "background: true" in block ] assert [re.findall(r"run: (.+)", block)[0] for block in checks] == [ - "uv run projen dev:validate --no-online --output cdk.out/dev", - "uv run projen test:validate --no-online --output cdk.out/test", + 'uv run projen dev:validate --no-online --output "${{ runner.temp }}/cdk-assemblies/dev"', + 'uv run projen test:validate --no-online --output "${{ runner.temp }}/cdk-assemblies/test"', "uv run projen test", ] ids = [re.findall(r"id: (.+)", block)[0] for block in checks] @@ -70,3 +76,48 @@ def test_deploy_waits_for_authentication_after_installing_dependencies( "Validate CDK for the DEV" ) assert " - configure_aws_credentials" in output + + +def test_root_docker_context_changes_primary_cache_key(tmp_path): + def git(*args): + subprocess.run(["git", *args], cwd=tmp_path, check=True, capture_output=True) + + git("init", "--quiet") + (tmp_path / "docker").mkdir() + restore, _ = docker_asset_cache_steps() + epoch = next( + step["run"] for step in restore if step.get("id") == "docker_cache_epoch" + ) + primary = next( + step["with"]["key"] for step in restore if step.get("id") == "docker_cache" + ) + keys = [] + for content in ["FROM scratch\n", "FROM scratch\nLABEL changed=true\n"]: + (tmp_path / "docker/Dockerfile").write_text(content) + git("add", "docker/Dockerfile") + git( + "-c", + "user.name=Fixture", + "-c", + "user.email=fixture@example.com", + "commit", + "--quiet", + "-m", + "fixture", + ) + output = tmp_path / "outputs" + output.write_text("") + subprocess.run( + ["bash", "-e", "-c", epoch], + cwd=tmp_path, + env={**os.environ, "GITHUB_OUTPUT": str(output)}, + check=True, + ) + key = primary + for line in output.read_text().splitlines(): + name, value = line.split("=", 1) + key = key.replace( + "${{ steps.docker_cache_epoch.outputs." + name + " }}", value + ) + keys.append(key) + assert keys[0] != keys[1]