diff --git a/.env.example b/.env.example index f91305b..278e071 100644 --- a/.env.example +++ b/.env.example @@ -1,6 +1,2 @@ -DOCKER_USERNAME=your-dockerhub-user -DOCKER_ORG_NAME=your-dockerhub-org -DOCKER_TOKEN=your-docker-token -GITHUB_USERNAME=your-github-user -GITHUB_ORG_NAME=your-github-org -GITHUB_TOKEN=your-github-token +GITHUB_TOKEN=your_github_token_here +GH_REPO=devops-infra/triglav diff --git a/.github/workflows/auto-pull-request-create.yml b/.github/workflows/auto-pull-request-create.yml index 4e9c738..fc7ef7c 100644 --- a/.github/workflows/auto-pull-request-create.yml +++ b/.github/workflows/auto-pull-request-create.yml @@ -7,6 +7,7 @@ on: - main - release/** - dependabot/** + - test/** permissions: contents: read diff --git a/.github/workflows/cron-e2e-tests.yml b/.github/workflows/cron-e2e-tests.yml new file mode 100644 index 0000000..75d98bd --- /dev/null +++ b/.github/workflows/cron-e2e-tests.yml @@ -0,0 +1,98 @@ +name: (Cron) End-to-End Tests + +on: + schedule: + - cron: 0 6 * * 1 + workflow_dispatch: + inputs: + mode: + description: Run mode (ref or image) + required: false + default: ref + type: choice + options: + - ref + - image + image_tag: + description: Action image tag used when mode=image (for example v1.2.3-test or v1.2.3-rc) + required: false + default: '' + type: string + +permissions: + contents: read + +jobs: + action-commit-push: + permissions: + contents: write + uses: ./.github/workflows/e2e-action-commit-push.yml + with: + mode: ${{ github.event_name == 'workflow_dispatch' && inputs.mode || 'ref' }} + image_tag: ${{ github.event_name == 'workflow_dispatch' && inputs.image_tag || '' }} + secrets: inherit + + action-pull-request: + permissions: + contents: write + pull-requests: write + issues: write + uses: ./.github/workflows/e2e-action-pull-request.yml + with: + mode: ${{ github.event_name == 'workflow_dispatch' && inputs.mode || 'ref' }} + image_tag: ${{ github.event_name == 'workflow_dispatch' && inputs.image_tag || '' }} + secrets: inherit + + action-tflint: + permissions: + contents: read + uses: ./.github/workflows/e2e-action-tflint.yml + with: + mode: ${{ github.event_name == 'workflow_dispatch' && inputs.mode || 'ref' }} + image_tag: ${{ github.event_name == 'workflow_dispatch' && inputs.image_tag || '' }} + secrets: inherit + + action-format-hcl: + permissions: + contents: read + uses: ./.github/workflows/e2e-action-format-hcl.yml + with: + mode: ${{ github.event_name == 'workflow_dispatch' && inputs.mode || 'ref' }} + image_tag: ${{ github.event_name == 'workflow_dispatch' && inputs.image_tag || '' }} + secrets: inherit + + action-terraform-validate: + permissions: + contents: read + uses: ./.github/workflows/e2e-action-terraform-validate.yml + with: + mode: ${{ github.event_name == 'workflow_dispatch' && inputs.mode || 'ref' }} + image_tag: ${{ github.event_name == 'workflow_dispatch' && inputs.image_tag || '' }} + secrets: inherit + + action-terraform-copy-vars: + permissions: + contents: read + uses: ./.github/workflows/e2e-action-terraform-copy-vars.yml + with: + mode: ${{ github.event_name == 'workflow_dispatch' && inputs.mode || 'ref' }} + image_tag: ${{ github.event_name == 'workflow_dispatch' && inputs.image_tag || '' }} + secrets: inherit + + action-container-structure-test: + permissions: + contents: read + uses: ./.github/workflows/e2e-action-container-structure-test.yml + with: + mode: ${{ github.event_name == 'workflow_dispatch' && inputs.mode || 'ref' }} + image_tag: ${{ github.event_name == 'workflow_dispatch' && inputs.image_tag || '' }} + secrets: inherit + + action-template-action: + permissions: + contents: read + uses: ./.github/workflows/e2e-action-template-action.yml + with: + mode: ${{ github.event_name == 'workflow_dispatch' && inputs.mode || 'ref' }} + image_tag: ${{ github.event_name == 'workflow_dispatch' && inputs.image_tag || '' }} + secrets: inherit diff --git a/.github/workflows/e2e-action-commit-push.yml b/.github/workflows/e2e-action-commit-push.yml new file mode 100644 index 0000000..9291b6a --- /dev/null +++ b/.github/workflows/e2e-action-commit-push.yml @@ -0,0 +1,463 @@ +name: (E2E) Action Commit Push + +on: + workflow_call: + inputs: + mode: + description: Execution mode (ref or image) + required: false + type: string + default: ref + image_tag: + description: Image tag for docker mode (for example v1.2.3-test or v1.2.3-rc) + required: false + type: string + default: '' + workflow_dispatch: + inputs: + mode: + description: Execution mode (ref or image) + required: false + default: ref + type: choice + options: + - ref + - image + image_tag: + description: Image tag for docker mode (for example v1.2.3-test or v1.2.3-rc) + required: false + default: '' + type: string + +permissions: + contents: write + +jobs: + preflight: + name: Validate workflow inputs + runs-on: ubuntu-latest + steps: + - name: Validate mode input + run: | + case "${{ inputs.mode }}" in + ref|image) ;; + *) + echo "Unsupported mode '${{ inputs.mode }}'. Expected 'ref' or 'image'." + exit 1 + ;; + esac + + basic-commit: + name: Basic commit and push to new branch + needs: [preflight] + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Create test file + run: echo "E2E basic commit test $(date -u)" > e2e-test-file.txt + + - name: Commit and push to new branch + if: ${{ inputs.mode == 'ref' }} + id: commit + uses: devops-infra/action-commit-push@v1 + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + commit_message: "[E2E] Basic commit test" + target_branch: test/e2e-commit-push-basic-${{ github.run_id }} + + - name: Commit and push via docker image (preview) + if: ${{ inputs.mode == 'image' }} + run: | + if [ -z "${{ inputs.image_tag }}" ]; then + echo "image_tag is required when mode=image" + exit 1 + fi + echo "Image mode placeholder for devopsinfra/action-commit-push:${{ inputs.image_tag }}" + echo "Use ref mode for authoritative validation until image-mode harness is finalized." + + - name: Verify outputs + if: ${{ inputs.mode == 'ref' }} + run: | + echo "branch_name=${{ steps.commit.outputs.branch_name }}" + echo "files_changed=${{ steps.commit.outputs.files_changed }}" + test -n "${{ steps.commit.outputs.branch_name }}" + test -n "${{ steps.commit.outputs.files_changed }}" + + - name: Cleanup - delete test branch + if: always() + run: git push origin --delete test/e2e-commit-push-basic-${{ github.run_id }} 2>/dev/null || true + + commit-with-prefix-message: + name: Commit with custom prefix and message + needs: [preflight] + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Create test file + run: echo "E2E prefix message test $(date -u)" > e2e-prefix-test.txt + + - name: Commit with custom prefix and message + if: ${{ inputs.mode == 'ref' }} + id: commit + uses: devops-infra/action-commit-push@v1 + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + commit_prefix: "[E2E-PREFIX]" + commit_message: " Custom message test" + target_branch: test/e2e-commit-push-prefix-${{ github.run_id }} + + - name: Commit via docker image (preview) + if: ${{ inputs.mode == 'image' }} + run: | + if [ -z "${{ inputs.image_tag }}" ]; then + echo "image_tag is required when mode=image" + exit 1 + fi + echo "Image mode placeholder for devopsinfra/action-commit-push:${{ inputs.image_tag }}" + echo "Use ref mode for authoritative validation until image-mode harness is finalized." + + - name: Verify outputs + if: ${{ inputs.mode == 'ref' }} + run: | + echo "branch_name=${{ steps.commit.outputs.branch_name }}" + echo "files_changed=${{ steps.commit.outputs.files_changed }}" + test -n "${{ steps.commit.outputs.branch_name }}" + test -n "${{ steps.commit.outputs.files_changed }}" + + - name: Cleanup - delete test branch + if: always() + run: git push origin --delete test/e2e-commit-push-prefix-${{ github.run_id }} 2>/dev/null || true + + allow-empty-commit: + name: Allow empty commit with no file changes + needs: [preflight] + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Commit with no file changes to new branch + if: ${{ inputs.mode == 'ref' }} + id: commit + uses: devops-infra/action-commit-push@v1 + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + allow_empty_commit: "true" + commit_message: "[E2E] Empty commit test" + target_branch: test/e2e-commit-push-empty-${{ github.run_id }} + + - name: Empty commit via docker image (preview) + if: ${{ inputs.mode == 'image' }} + run: | + if [ -z "${{ inputs.image_tag }}" ]; then + echo "image_tag is required when mode=image" + exit 1 + fi + echo "Image mode placeholder for devopsinfra/action-commit-push:${{ inputs.image_tag }}" + echo "Use ref mode for authoritative validation until image-mode harness is finalized." + + - name: Verify branch output + if: ${{ inputs.mode == 'ref' }} + run: | + echo "branch_name=${{ steps.commit.outputs.branch_name }}" + test -n "${{ steps.commit.outputs.branch_name }}" + + - name: Cleanup - delete test branch + if: always() + run: git push origin --delete test/e2e-commit-push-empty-${{ github.run_id }} 2>/dev/null || true + + commit-with-timestamp-branch: + name: Commit to timestamped branch name + needs: [preflight] + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Create test file + run: echo "E2E timestamp branch test $(date -u)" > e2e-timestamp-test.txt + + - name: Commit and push to timestamped branch + if: ${{ inputs.mode == 'ref' }} + id: commit + uses: devops-infra/action-commit-push@v1 + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + add_timestamp: "true" + commit_message: "[E2E] Timestamp branch test" + target_branch: test/e2e-commit-push-timestamp + + - name: Commit via docker image (preview) + if: ${{ inputs.mode == 'image' }} + run: | + if [ -z "${{ inputs.image_tag }}" ]; then + echo "image_tag is required when mode=image" + exit 1 + fi + echo "Image mode placeholder for devopsinfra/action-commit-push:${{ inputs.image_tag }}" + echo "Use ref mode for authoritative validation until image-mode harness is finalized." + + - name: Verify timestamped branch output + if: ${{ inputs.mode == 'ref' }} + run: | + branch_name="${{ steps.commit.outputs.branch_name }}" + echo "branch_name=${branch_name}" + [[ "${branch_name}" =~ ^test/e2e-commit-push-timestamp-[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}-[0-9]{2}-[0-9]{2}Z$ ]] + + - name: Cleanup - delete test branch + if: ${{ always() && inputs.mode == 'ref' }} + run: | + branch_name="${{ steps.commit.outputs.branch_name }}" + if [ -n "${branch_name}" ]; then + git push origin --delete "${branch_name}" 2>/dev/null || true + fi + + commit-with-repository-path: + name: Commit from custom repository_path + needs: [preflight] + runs-on: ubuntu-latest + steps: + - name: Checkout repository into custom path + uses: actions/checkout@v6 + with: + fetch-depth: 0 + path: repo + + - name: Create test file in custom path + run: echo "E2E repository path test $(date -u)" > repo/e2e-repository-path.txt + + - name: Commit and push using repository_path + if: ${{ inputs.mode == 'ref' }} + id: commit + uses: devops-infra/action-commit-push@v1 + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + repository_path: repo + commit_message: "[E2E] Repository path test" + target_branch: test/e2e-commit-push-path-${{ github.run_id }} + + - name: Commit via docker image (preview) + if: ${{ inputs.mode == 'image' }} + run: | + if [ -z "${{ inputs.image_tag }}" ]; then + echo "image_tag is required when mode=image" + exit 1 + fi + echo "Image mode placeholder for devopsinfra/action-commit-push:${{ inputs.image_tag }}" + echo "Use ref mode for authoritative validation until image-mode harness is finalized." + + - name: Verify repository_path output + if: ${{ inputs.mode == 'ref' }} + run: | + echo "files_changed=${{ steps.commit.outputs.files_changed }}" + test -n "${{ steps.commit.outputs.branch_name }}" + grep -Fq "e2e-repository-path.txt" <<'EOF' + ${{ steps.commit.outputs.files_changed }} + EOF + + - name: Cleanup - delete test branch + if: always() + run: git push origin --delete test/e2e-commit-push-path-${{ github.run_id }} 2>/dev/null || true + + reset-target-branch-to-base: + name: Reset target branch to base branch + needs: [preflight] + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Prepare base and target branches + if: ${{ inputs.mode == 'ref' }} + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + + base_branch="test/e2e-commit-push-base-${{ github.run_id }}" + target_branch="test/e2e-commit-push-reset-${{ github.run_id }}" + + git checkout -b "${base_branch}" + echo "base" > e2e-reset-base.txt + git add e2e-reset-base.txt + git commit -m "[E2E] base branch for reset" + git push origin "${base_branch}" + + git checkout -b "${target_branch}" "${base_branch}" + echo "legacy" > e2e-reset-legacy.txt + git add e2e-reset-legacy.txt + git commit -m "[E2E] legacy target branch content" + git push origin "${target_branch}" + + - name: Reset target branch using action + if: ${{ inputs.mode == 'ref' }} + id: reset + uses: devops-infra/action-commit-push@v1 + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + target_branch: test/e2e-commit-push-reset-${{ github.run_id }} + base_branch: test/e2e-commit-push-base-${{ github.run_id }} + reset_target_branch: "true" + + - name: Reset via docker image (preview) + if: ${{ inputs.mode == 'image' }} + run: | + if [ -z "${{ inputs.image_tag }}" ]; then + echo "image_tag is required when mode=image" + exit 1 + fi + echo "Image mode placeholder for devopsinfra/action-commit-push:${{ inputs.image_tag }}" + echo "Use ref mode for authoritative validation until image-mode harness is finalized." + + - name: Verify branch reset effect + if: ${{ inputs.mode == 'ref' }} + run: | + test -n "${{ steps.reset.outputs.branch_name }}" + git fetch origin test/e2e-commit-push-reset-${{ github.run_id }} + git show origin/test/e2e-commit-push-reset-${{ github.run_id }}:e2e-reset-base.txt >/dev/null + if git show origin/test/e2e-commit-push-reset-${{ github.run_id }}:e2e-reset-legacy.txt >/dev/null 2>&1; then + echo "Expected legacy file to be removed after reset_target_branch=true" + exit 1 + fi + + - name: Cleanup - delete test branches + if: always() + run: | + git push origin --delete test/e2e-commit-push-reset-${{ github.run_id }} 2>/dev/null || true + git push origin --delete test/e2e-commit-push-base-${{ github.run_id }} 2>/dev/null || true + + rebase-conflict-fails-when-strict: + name: Rebase conflict fails when fail_on_rebase_conflict=true + needs: [preflight] + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Prepare conflicting branches + if: ${{ inputs.mode == 'ref' }} + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + + base_branch="test/e2e-commit-push-conflict-base-${{ github.run_id }}" + target_branch="test/e2e-commit-push-conflict-target-${{ github.run_id }}" + + git checkout -b "${base_branch}" + printf 'value=one\n' > e2e-rebase-conflict.txt + git add e2e-rebase-conflict.txt + git commit -m "[E2E] base commit" + git push origin "${base_branch}" + + git checkout -b "${target_branch}" "${base_branch}" + printf 'value=target\n' > e2e-rebase-conflict.txt + git add e2e-rebase-conflict.txt + git commit -m "[E2E] target diverges" + git push origin "${target_branch}" + + git checkout "${base_branch}" + printf 'value=base\n' > e2e-rebase-conflict.txt + git add e2e-rebase-conflict.txt + git commit -m "[E2E] base diverges" + git push origin "${base_branch}" + + - name: Run action with strict rebase conflict handling + if: ${{ inputs.mode == 'ref' }} + id: strict + continue-on-error: true + uses: devops-infra/action-commit-push@v1 + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + target_branch: test/e2e-commit-push-conflict-target-${{ github.run_id }} + base_branch: test/e2e-commit-push-conflict-base-${{ github.run_id }} + fail_on_rebase_conflict: "true" + + - name: Conflict via docker image (preview) + if: ${{ inputs.mode == 'image' }} + run: | + if [ -z "${{ inputs.image_tag }}" ]; then + echo "image_tag is required when mode=image" + exit 1 + fi + echo "Image mode placeholder for devopsinfra/action-commit-push:${{ inputs.image_tag }}" + echo "Use ref mode for authoritative validation until image-mode harness is finalized." + + - name: Verify strict mode failure + if: ${{ inputs.mode == 'ref' }} + run: test "${{ steps.strict.outcome }}" = "failure" + + - name: Cleanup - delete test branches + if: always() + run: | + git push origin --delete test/e2e-commit-push-conflict-target-${{ github.run_id }} 2>/dev/null || true + git push origin --delete test/e2e-commit-push-conflict-base-${{ github.run_id }} 2>/dev/null || true + + amend-commit: + name: Amend previous commit with force-with-lease + needs: [preflight] + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Create initial branch with commit + if: ${{ inputs.mode == 'ref' }} + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git checkout -b test/e2e-commit-push-amend-${{ github.run_id }} + echo "v1" > e2e-amend-test.txt + git add e2e-amend-test.txt + git commit -m "E2E: initial commit for amend test" + git push origin test/e2e-commit-push-amend-${{ github.run_id }} + + - name: Add more content to file + if: ${{ inputs.mode == 'ref' }} + run: echo "v2" >> e2e-amend-test.txt + + - name: Amend commit keeping original message + if: ${{ inputs.mode == 'ref' }} + id: amend + uses: devops-infra/action-commit-push@v1 + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + amend: "true" + no_edit: "true" + force_with_lease: "true" + + - name: Amend via docker image (preview) + if: ${{ inputs.mode == 'image' }} + run: | + if [ -z "${{ inputs.image_tag }}" ]; then + echo "image_tag is required when mode=image" + exit 1 + fi + echo "Image mode placeholder for devopsinfra/action-commit-push:${{ inputs.image_tag }}" + echo "Use ref mode for authoritative validation until image-mode harness is finalized." + + - name: Verify branch output + if: ${{ inputs.mode == 'ref' }} + run: | + echo "branch_name=${{ steps.amend.outputs.branch_name }}" + test -n "${{ steps.amend.outputs.branch_name }}" + + - name: Cleanup - delete test branch + if: always() + run: git push origin --delete test/e2e-commit-push-amend-${{ github.run_id }} 2>/dev/null || true diff --git a/.github/workflows/e2e-action-container-structure-test.yml b/.github/workflows/e2e-action-container-structure-test.yml new file mode 100644 index 0000000..695ab5a --- /dev/null +++ b/.github/workflows/e2e-action-container-structure-test.yml @@ -0,0 +1,303 @@ +name: (E2E) Action Container Structure Test + +on: + workflow_call: + inputs: + mode: + description: Execution mode (ref or image) + required: false + type: string + default: ref + image_tag: + description: Image tag for docker mode (for example v1.2.3-test or v1.2.3-rc) + required: false + type: string + default: '' + workflow_dispatch: + inputs: + mode: + description: Execution mode (ref or image) + required: false + default: ref + type: choice + options: + - ref + - image + image_tag: + description: Image tag for docker mode (for example v1.2.3-test or v1.2.3-rc) + required: false + default: '' + type: string + +permissions: + contents: read + +jobs: + preflight: + name: Validate workflow inputs + runs-on: ubuntu-latest + steps: + - name: Validate mode input + run: | + case "${{ inputs.mode }}" in + ref|image) ;; + *) + echo "Unsupported mode '${{ inputs.mode }}'. Expected 'ref' or 'image'." + exit 1 + ;; + esac + + text-output: + name: Basic test with text output format + needs: [preflight] + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v6 + + - name: Pull Alpine test image + run: docker pull alpine:3.23.4 + + - name: Run container structure tests with text output + if: ${{ inputs.mode == 'ref' }} + id: cst + uses: devops-infra/action-container-structure-test@v1 + with: + image: alpine:3.23.4 + config: tests/fixtures/container-structure-test/alpine.yml + output: text + + - name: Run container structure tests via docker image (preview) + if: ${{ inputs.mode == 'image' }} + run: | + if [ -z "${{ inputs.image_tag }}" ]; then + echo "image_tag is required when mode=image" + exit 1 + fi + echo "Image mode placeholder for devopsinfra/action-container-structure-test:${{ inputs.image_tag }}" + echo "Use ref mode for authoritative validation until image-mode harness is finalized." + + - name: Verify test result outputs + if: ${{ inputs.mode == 'ref' }} + run: | + echo "total=${{ steps.cst.outputs.total }}" + echo "passed=${{ steps.cst.outputs.passed }}" + echo "failed=${{ steps.cst.outputs.failed }}" + echo "exit_code=${{ steps.cst.outputs.exit_code }}" + test "${{ steps.cst.outputs.exit_code }}" = "0" + test "${{ steps.cst.outputs.failed }}" = "0" + + json-output: + name: Test with JSON output format + needs: [preflight] + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v6 + + - name: Pull Alpine test image + run: docker pull alpine:3.23.4 + + - name: Run container structure tests with JSON output + if: ${{ inputs.mode == 'ref' }} + id: cst + uses: devops-infra/action-container-structure-test@v1 + with: + image: alpine:3.23.4 + config: tests/fixtures/container-structure-test/alpine.yml + output: json + + - name: Run container structure tests via docker image (preview) + if: ${{ inputs.mode == 'image' }} + run: | + if [ -z "${{ inputs.image_tag }}" ]; then + echo "image_tag is required when mode=image" + exit 1 + fi + echo "Image mode placeholder for devopsinfra/action-container-structure-test:${{ inputs.image_tag }}" + echo "Use ref mode for authoritative validation until image-mode harness is finalized." + + - name: Verify test result outputs + if: ${{ inputs.mode == 'ref' }} + run: | + echo "total=${{ steps.cst.outputs.total }}" + echo "passed=${{ steps.cst.outputs.passed }}" + echo "exit_code=${{ steps.cst.outputs.exit_code }}" + test "${{ steps.cst.outputs.exit_code }}" = "0" + + junit-output: + name: Test with JUnit output format and suite name + needs: [preflight] + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v6 + + - name: Pull Alpine test image + run: docker pull alpine:3.23.4 + + - name: Run container structure tests with JUnit output + if: ${{ inputs.mode == 'ref' }} + id: cst + uses: devops-infra/action-container-structure-test@v1 + with: + image: alpine:3.23.4 + config: tests/fixtures/container-structure-test/alpine.yml + output: junit + junit_suite_name: e2e-alpine-tests + + - name: Run container structure tests via docker image (preview) + if: ${{ inputs.mode == 'image' }} + run: | + if [ -z "${{ inputs.image_tag }}" ]; then + echo "image_tag is required when mode=image" + exit 1 + fi + echo "Image mode placeholder for devopsinfra/action-container-structure-test:${{ inputs.image_tag }}" + echo "Use ref mode for authoritative validation until image-mode harness is finalized." + + - name: Verify test result outputs + if: ${{ inputs.mode == 'ref' }} + run: | + echo "total=${{ steps.cst.outputs.total }}" + echo "passed=${{ steps.cst.outputs.passed }}" + echo "exit_code=${{ steps.cst.outputs.exit_code }}" + test "${{ steps.cst.outputs.exit_code }}" = "0" + + test-report-file: + name: Test with report written to file + needs: [preflight] + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v6 + + - name: Pull Alpine test image + run: docker pull alpine:3.23.4 + + - name: Run container structure tests saving report to file + if: ${{ inputs.mode == 'ref' }} + id: cst + uses: devops-infra/action-container-structure-test@v1 + with: + image: alpine:3.23.4 + config: tests/fixtures/container-structure-test/alpine.yml + output: json + test_report: /tmp/cst-report.json + + - name: Run container structure tests via docker image (preview) + if: ${{ inputs.mode == 'image' }} + run: | + if [ -z "${{ inputs.image_tag }}" ]; then + echo "image_tag is required when mode=image" + exit 1 + fi + echo "Image mode placeholder for devopsinfra/action-container-structure-test:${{ inputs.image_tag }}" + echo "Use ref mode for authoritative validation until image-mode harness is finalized." + + - name: Verify report file was created + if: ${{ inputs.mode == 'ref' }} + run: test -f /tmp/cst-report.json + + - name: Verify test result outputs + if: ${{ inputs.mode == 'ref' }} + run: | + echo "total=${{ steps.cst.outputs.total }}" + echo "passed=${{ steps.cst.outputs.passed }}" + echo "exit_code=${{ steps.cst.outputs.exit_code }}" + test "${{ steps.cst.outputs.exit_code }}" = "0" + + multiple-config-files: + name: Test with multiple config files + needs: [preflight] + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v6 + + - name: Pull Alpine test image + run: docker pull alpine:3.23.4 + + - name: Run container structure tests with multiple config files + if: ${{ inputs.mode == 'ref' }} + id: cst + uses: devops-infra/action-container-structure-test@v1 + with: + image: alpine:3.23.4 + config: | + tests/fixtures/container-structure-test/alpine.yml + tests/fixtures/container-structure-test/alpine-extended.yml + output: text + + - name: Run container structure tests via docker image (preview) + if: ${{ inputs.mode == 'image' }} + run: | + if [ -z "${{ inputs.image_tag }}" ]; then + echo "image_tag is required when mode=image" + exit 1 + fi + echo "Image mode placeholder for devopsinfra/action-container-structure-test:${{ inputs.image_tag }}" + echo "Use ref mode for authoritative validation until image-mode harness is finalized." + + - name: Verify combined test results + if: ${{ inputs.mode == 'ref' }} + run: | + echo "total=${{ steps.cst.outputs.total }}" + echo "passed=${{ steps.cst.outputs.passed }}" + echo "failed=${{ steps.cst.outputs.failed }}" + echo "exit_code=${{ steps.cst.outputs.exit_code }}" + test "${{ steps.cst.outputs.exit_code }}" = "0" + test "${{ steps.cst.outputs.failed }}" = "0" + + metadata-platform-runtime: + name: Test with metadata, platform, and runtime inputs + needs: [preflight] + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v6 + + - name: Pull Alpine test image + run: docker pull alpine:3.23.4 + + - name: Create metadata file + run: | + cat > /tmp/cst-metadata.json << 'EOF' + { + "config": { + "Env": ["FOO=bar"] + } + } + EOF + + - name: Run container structure tests with metadata/platform/runtime + if: ${{ inputs.mode == 'ref' }} + id: cst + uses: devops-infra/action-container-structure-test@v1 + with: + image: alpine:3.23.4 + config: tests/fixtures/container-structure-test/alpine.yml + driver: docker + platform: linux/amd64 + runtime: runc + metadata: /tmp/cst-metadata.json + output: text + + - name: Run container structure tests via docker image (preview) + if: ${{ inputs.mode == 'image' }} + run: | + if [ -z "${{ inputs.image_tag }}" ]; then + echo "image_tag is required when mode=image" + exit 1 + fi + echo "Image mode placeholder for devopsinfra/action-container-structure-test:${{ inputs.image_tag }}" + echo "Use ref mode for authoritative validation until image-mode harness is finalized." + + - name: Verify test result outputs + if: ${{ inputs.mode == 'ref' }} + run: | + echo "total=${{ steps.cst.outputs.total }}" + echo "passed=${{ steps.cst.outputs.passed }}" + echo "failed=${{ steps.cst.outputs.failed }}" + echo "exit_code=${{ steps.cst.outputs.exit_code }}" + test "${{ steps.cst.outputs.exit_code }}" = "0" diff --git a/.github/workflows/e2e-action-format-hcl.yml b/.github/workflows/e2e-action-format-hcl.yml new file mode 100644 index 0000000..a8b8f8e --- /dev/null +++ b/.github/workflows/e2e-action-format-hcl.yml @@ -0,0 +1,268 @@ +name: (E2E) Action Format HCL + +on: + workflow_call: + inputs: + mode: + description: Execution mode (ref or image) + required: false + type: string + default: ref + image_tag: + description: Image tag for docker mode (for example v1.2.3-test or v1.2.3-rc) + required: false + type: string + default: '' + workflow_dispatch: + inputs: + mode: + description: Execution mode (ref or image) + required: false + default: ref + type: choice + options: + - ref + - image + image_tag: + description: Image tag for docker mode (for example v1.2.3-test or v1.2.3-rc) + required: false + default: '' + type: string + +permissions: + contents: read + +jobs: + preflight: + name: Validate workflow inputs + runs-on: ubuntu-latest + steps: + - name: Validate mode input + run: | + case "${{ inputs.mode }}" in + ref|image) ;; + *) + echo "Unsupported mode '${{ inputs.mode }}'. Expected 'ref' or 'image'." + exit 1 + ;; + esac + + format-check-clean-files: + name: Check mode on already-formatted files passes + needs: [preflight] + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v6 + + - name: Create well-formatted Terraform files + run: | + mkdir -p tests/e2e-hcl-clean + cat > tests/e2e-hcl-clean/main.tf << 'EOF' + variable "name" { + description = "Resource name" + type = string + } + + variable "region" { + description = "Cloud region" + type = string + default = "us-east-1" + } + + locals { + label = "${var.name}-${var.region}" + } + + output "label" { + value = local.label + } + EOF + + - name: Check already-formatted files + if: ${{ inputs.mode == 'ref' }} + uses: devops-infra/action-format-hcl@v1 + with: + check: "true" + dir: tests/e2e-hcl-clean + + - name: Check via docker image (preview) + if: ${{ inputs.mode == 'image' }} + env: + ACTION_IMAGE: devopsinfra/action-format-hcl:${{ inputs.image_tag }} + run: | + if [ -z "${{ inputs.image_tag }}" ]; then + echo "image_tag is required when mode=image" + exit 1 + fi + docker pull "${ACTION_IMAGE}" + docker run --rm \ + -e INPUT_CHECK=true \ + -e INPUT_DIR=tests/e2e-hcl-clean \ + -v "$PWD:/github/workspace" \ + -w /github/workspace \ + "${ACTION_IMAGE}" + + format-write-mode: + name: Write mode formats and rewrites files + needs: [preflight] + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v6 + + - name: Create Terraform files for write-mode formatting + run: | + mkdir -p tests/e2e-hcl-write + cat > tests/e2e-hcl-write/main.tf << 'EOF' + variable "name" { + description = "Resource name" + type = string + } + + locals { + upper_name = upper(var.name) + } + + output "result" { + value = local.upper_name + } + EOF + + - name: Run format in write mode + if: ${{ inputs.mode == 'ref' }} + id: format + uses: devops-infra/action-format-hcl@v1 + with: + write: "true" + diff: "true" + dir: tests/e2e-hcl-write + + - name: Run format via docker image (preview) + if: ${{ inputs.mode == 'image' }} + env: + ACTION_IMAGE: devopsinfra/action-format-hcl:${{ inputs.image_tag }} + run: | + if [ -z "${{ inputs.image_tag }}" ]; then + echo "image_tag is required when mode=image" + exit 1 + fi + docker pull "${ACTION_IMAGE}" + docker run --rm \ + -e INPUT_WRITE=true \ + -e INPUT_DIFF=true \ + -e INPUT_DIR=tests/e2e-hcl-write \ + -v "$PWD:/github/workspace" \ + -w /github/workspace \ + "${ACTION_IMAGE}" + + - name: Show files changed output + if: ${{ inputs.mode == 'ref' }} + run: echo "files_changed=${{ steps.format.outputs.files_changed }}" + + - name: Verify image mode execution + if: ${{ inputs.mode == 'image' }} + run: test -f tests/e2e-hcl-write/main.tf + + format-check-malformed-files: + name: Check mode on malformed files reports failure + needs: [preflight] + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v6 + + - name: Create malformed Terraform file + run: | + mkdir -p tests/e2e-hcl-malformed + printf 'variable "foo" {\ntype = string\ndescription="bad spacing"\n}\n' > tests/e2e-hcl-malformed/malformed.tf + + - name: Check malformed files - expected to fail + if: ${{ inputs.mode == 'ref' }} + id: check + uses: devops-infra/action-format-hcl@v1 + continue-on-error: true + with: + check: "true" + dir: tests/e2e-hcl-malformed + + - name: Check malformed files via docker image - expected to fail + if: ${{ inputs.mode == 'image' }} + env: + ACTION_IMAGE: devopsinfra/action-format-hcl:${{ inputs.image_tag }} + run: | + if [ -z "${{ inputs.image_tag }}" ]; then + echo "image_tag is required when mode=image" + exit 1 + fi + docker pull "${ACTION_IMAGE}" + set +e + docker run --rm \ + -e INPUT_CHECK=true \ + -e INPUT_DIR=tests/e2e-hcl-malformed \ + -v "$PWD:/github/workspace" \ + -w /github/workspace \ + "${ACTION_IMAGE}" + rc=$? + set -e + test "$rc" -ne 0 + + - name: Verify action detected formatting issues + if: ${{ inputs.mode == 'ref' }} + run: test "${{ steps.check.outcome }}" = "failure" + + format-list-with-diff: + name: List and diff mode on formatted files + needs: [preflight] + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v6 + + - name: Create Terraform files for list/diff mode + run: | + mkdir -p tests/e2e-hcl-list + cat > tests/e2e-hcl-list/variables.tf << 'EOF' + variable "env" { + description = "Deployment environment" + type = string + default = "dev" + } + EOF + + - name: Run format in list and diff mode + if: ${{ inputs.mode == 'ref' }} + id: format + uses: devops-infra/action-format-hcl@v1 + with: + list: "true" + diff: "true" + write: "false" + dir: tests/e2e-hcl-list + + - name: Run format via docker image (preview) + if: ${{ inputs.mode == 'image' }} + env: + ACTION_IMAGE: devopsinfra/action-format-hcl:${{ inputs.image_tag }} + run: | + if [ -z "${{ inputs.image_tag }}" ]; then + echo "image_tag is required when mode=image" + exit 1 + fi + docker pull "${ACTION_IMAGE}" + docker run --rm \ + -e INPUT_LIST=true \ + -e INPUT_DIFF=true \ + -e INPUT_WRITE=false \ + -e INPUT_DIR=tests/e2e-hcl-list \ + -v "$PWD:/github/workspace" \ + -w /github/workspace \ + "${ACTION_IMAGE}" + + - name: Show files changed output + if: ${{ inputs.mode == 'ref' }} + run: echo "files_changed=${{ steps.format.outputs.files_changed }}" + + - name: Verify image mode execution + if: ${{ inputs.mode == 'image' }} + run: test -f tests/e2e-hcl-list/variables.tf diff --git a/.github/workflows/e2e-action-pull-request.yml b/.github/workflows/e2e-action-pull-request.yml new file mode 100644 index 0000000..f76cb34 --- /dev/null +++ b/.github/workflows/e2e-action-pull-request.yml @@ -0,0 +1,306 @@ +name: (E2E) Action Pull Request + +on: + workflow_call: + inputs: + mode: + description: Execution mode (ref or image) + required: false + type: string + default: ref + image_tag: + description: Image tag for docker mode (for example v1.2.3-test or v1.2.3-rc) + required: false + type: string + default: '' + workflow_dispatch: + inputs: + mode: + description: Execution mode (ref or image) + required: false + default: ref + type: choice + options: + - ref + - image + image_tag: + description: Image tag for docker mode (for example v1.2.3-test or v1.2.3-rc) + required: false + default: '' + type: string + +permissions: + contents: write + pull-requests: write + issues: write + +jobs: + preflight: + name: Validate workflow inputs + runs-on: ubuntu-latest + steps: + - name: Validate mode input + run: | + case "${{ inputs.mode }}" in + ref|image) ;; + *) + echo "Unsupported mode '${{ inputs.mode }}'. Expected 'ref' or 'image'." + exit 1 + ;; + esac + + basic-pull-request: + name: Basic pull request creation + needs: [preflight] + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Create test branch with a commit + if: ${{ inputs.mode == 'ref' }} + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git checkout -b test/e2e-pr-basic-${{ github.run_id }} + echo "E2E PR basic test $(date -u)" > e2e-pr-test.txt + git add e2e-pr-test.txt + git commit -m "[E2E] Basic PR test" + git push origin test/e2e-pr-basic-${{ github.run_id }} + + - name: Create pull request + if: ${{ inputs.mode == 'ref' }} + id: pr + uses: devops-infra/action-pull-request@v1 + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + title: "[E2E] Basic pull request test - safe to close" + body: "Automated end-to-end test pull request. Will be closed automatically." + target_branch: master + + - name: Create pull request via docker image (preview) + if: ${{ inputs.mode == 'image' }} + run: | + if [ -z "${{ inputs.image_tag }}" ]; then + echo "image_tag is required when mode=image" + exit 1 + fi + echo "Image mode placeholder for devopsinfra/action-pull-request:${{ inputs.image_tag }}" + echo "Use ref mode for authoritative validation until image-mode harness is finalized." + + - name: Verify outputs + if: ${{ inputs.mode == 'ref' }} + run: | + echo "url=${{ steps.pr.outputs.url }}" + echo "pr_number=${{ steps.pr.outputs.pr_number }}" + test -n "${{ steps.pr.outputs.url }}" + test -n "${{ steps.pr.outputs.pr_number }}" + + - name: Cleanup - close PR and delete test branch + if: ${{ always() && inputs.mode == 'ref' }} + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + PR_NUMBER="${{ steps.pr.outputs.pr_number }}" + if [ -n "$PR_NUMBER" ]; then + gh pr close "$PR_NUMBER" --repo "${{ github.repository }}" 2>/dev/null || true + fi + git push origin --delete test/e2e-pr-basic-${{ github.run_id }} 2>/dev/null || true + + pull-request-with-title-body: + name: Pull request with custom title and body + needs: [preflight] + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Create test branch with a commit + if: ${{ inputs.mode == 'ref' }} + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git checkout -b test/e2e-pr-custom-${{ github.run_id }} + echo "E2E PR custom title/body test $(date -u)" > e2e-pr-custom-test.txt + git add e2e-pr-custom-test.txt + git commit -m "[E2E] Custom title/body PR test" + git push origin test/e2e-pr-custom-${{ github.run_id }} + + - name: Create pull request with custom title and body + if: ${{ inputs.mode == 'ref' }} + id: pr + uses: devops-infra/action-pull-request@v1 + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + title: "[E2E] Custom title test - safe to close" + body: | + Automated end-to-end test. + Testing custom title and body fields. + Safe to close automatically. + target_branch: master + allow_no_diff: "false" + + - name: Create pull request via docker image (preview) + if: ${{ inputs.mode == 'image' }} + run: | + if [ -z "${{ inputs.image_tag }}" ]; then + echo "image_tag is required when mode=image" + exit 1 + fi + echo "Image mode placeholder for devopsinfra/action-pull-request:${{ inputs.image_tag }}" + echo "Use ref mode for authoritative validation until image-mode harness is finalized." + + - name: Verify outputs + if: ${{ inputs.mode == 'ref' }} + run: | + echo "url=${{ steps.pr.outputs.url }}" + echo "pr_number=${{ steps.pr.outputs.pr_number }}" + test -n "${{ steps.pr.outputs.url }}" + test -n "${{ steps.pr.outputs.pr_number }}" + + - name: Cleanup - close PR and delete test branch + if: ${{ always() && inputs.mode == 'ref' }} + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + PR_NUMBER="${{ steps.pr.outputs.pr_number }}" + if [ -n "$PR_NUMBER" ]; then + gh pr close "$PR_NUMBER" --repo "${{ github.repository }}" 2>/dev/null || true + fi + git push origin --delete test/e2e-pr-custom-${{ github.run_id }} 2>/dev/null || true + + pull-request-with-repository-path: + name: Pull request using custom checkout path and explicit repository + needs: [preflight] + runs-on: ubuntu-latest + steps: + - name: Checkout repository into custom path + uses: actions/checkout@v6 + with: + fetch-depth: 0 + path: work/repo + + - name: Create test branch with a commit inside custom path + if: ${{ inputs.mode == 'ref' }} + run: | + cd work/repo + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git checkout -b test/e2e-pr-repo-path-${{ github.run_id }} + echo "E2E PR repository_path test $(date -u)" > e2e-pr-path-test.txt + git add e2e-pr-path-test.txt + git commit -m "[E2E] PR repository_path test" + git push origin test/e2e-pr-repo-path-${{ github.run_id }} + + - name: Create pull request with repository and repository_path + if: ${{ inputs.mode == 'ref' }} + id: pr + uses: devops-infra/action-pull-request@v1 + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + repository: ${{ github.repository }} + repository_path: work/repo + title: "[E2E] Repository path PR test - safe to close" + body: "Automated end-to-end test for repository_path input." + target_branch: master + + - name: Create pull request via docker image (preview) + if: ${{ inputs.mode == 'image' }} + run: | + if [ -z "${{ inputs.image_tag }}" ]; then + echo "image_tag is required when mode=image" + exit 1 + fi + echo "Image mode placeholder for devopsinfra/action-pull-request:${{ inputs.image_tag }}" + echo "Use ref mode for authoritative validation until image-mode harness is finalized." + + - name: Verify outputs + if: ${{ inputs.mode == 'ref' }} + run: | + echo "url=${{ steps.pr.outputs.url }}" + echo "pr_number=${{ steps.pr.outputs.pr_number }}" + test -n "${{ steps.pr.outputs.url }}" + test -n "${{ steps.pr.outputs.pr_number }}" + + - name: Cleanup - close PR and delete test branch + if: ${{ always() && inputs.mode == 'ref' }} + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + PR_NUMBER="${{ steps.pr.outputs.pr_number }}" + if [ -n "$PR_NUMBER" ]; then + gh pr close "$PR_NUMBER" --repo "${{ github.repository }}" 2>/dev/null || true + fi + cd work/repo + git push origin --delete test/e2e-pr-repo-path-${{ github.run_id }} 2>/dev/null || true + + pull-request-draft-with-diff: + name: Draft pull request with get_diff enabled + needs: [preflight] + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Create test branch with a commit + if: ${{ inputs.mode == 'ref' }} + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git checkout -b test/e2e-pr-draft-${{ github.run_id }} + echo "E2E draft PR test $(date -u)" > e2e-pr-draft-test.txt + git add e2e-pr-draft-test.txt + git commit -m "[E2E] Draft PR with diff test" + git push origin test/e2e-pr-draft-${{ github.run_id }} + + - name: Create draft pull request with diff injection + if: ${{ inputs.mode == 'ref' }} + id: pr + uses: devops-infra/action-pull-request@v1 + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + title: "[E2E] Draft PR with diff - safe to close" + body: | + Automated end-to-end test. + + + + + target_branch: master + draft: "true" + get_diff: "true" + + - name: Create draft pull request via docker image (preview) + if: ${{ inputs.mode == 'image' }} + run: | + if [ -z "${{ inputs.image_tag }}" ]; then + echo "image_tag is required when mode=image" + exit 1 + fi + echo "Image mode placeholder for devopsinfra/action-pull-request:${{ inputs.image_tag }}" + echo "Use ref mode for authoritative validation until image-mode harness is finalized." + + - name: Verify outputs + if: ${{ inputs.mode == 'ref' }} + run: | + echo "url=${{ steps.pr.outputs.url }}" + echo "pr_number=${{ steps.pr.outputs.pr_number }}" + test -n "${{ steps.pr.outputs.url }}" + test -n "${{ steps.pr.outputs.pr_number }}" + + - name: Cleanup - close PR and delete test branch + if: ${{ always() && inputs.mode == 'ref' }} + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + PR_NUMBER="${{ steps.pr.outputs.pr_number }}" + if [ -n "$PR_NUMBER" ]; then + gh pr close "$PR_NUMBER" --repo "${{ github.repository }}" 2>/dev/null || true + fi + git push origin --delete test/e2e-pr-draft-${{ github.run_id }} 2>/dev/null || true diff --git a/.github/workflows/e2e-action-template-action.yml b/.github/workflows/e2e-action-template-action.yml new file mode 100644 index 0000000..cfd14ae --- /dev/null +++ b/.github/workflows/e2e-action-template-action.yml @@ -0,0 +1,106 @@ +name: (E2E) Template Action + +on: + workflow_call: + inputs: + mode: + description: Execution mode (ref or image) + required: false + type: string + default: ref + image_tag: + description: Image tag for docker mode (for example v1.2.3-test or v1.2.3-rc) + required: false + type: string + default: '' + workflow_dispatch: + inputs: + mode: + description: Execution mode (ref or image) + required: false + default: ref + type: choice + options: + - ref + - image + image_tag: + description: Image tag for docker mode (for example v1.2.3-test or v1.2.3-rc) + required: false + default: '' + type: string + +permissions: + contents: read + +jobs: + preflight: + name: Validate workflow inputs + runs-on: ubuntu-latest + steps: + - name: Validate mode input + run: | + case "${{ inputs.mode }}" in + ref|image) ;; + *) + echo "Unsupported mode '${{ inputs.mode }}'. Expected 'ref' or 'image'." + exit 1 + ;; + esac + + template-action-input-output: + name: Validate template-action input/output contract + needs: [preflight] + runs-on: ubuntu-latest + steps: + - name: Run template action in ref mode + if: ${{ inputs.mode == 'ref' }} + id: action + uses: devops-infra/template-action@v1 + with: + foobar: e2e-template-value + debug: 'false' + + - name: Run template action via docker image (preview) + if: ${{ inputs.mode == 'image' }} + run: | + if [ -z "${{ inputs.image_tag }}" ]; then + echo "image_tag is required when mode=image" + exit 1 + fi + echo "Image mode placeholder for devopsinfra/template-action:${{ inputs.image_tag }}" + echo "Use ref mode for authoritative validation until image-mode harness is finalized." + + - name: Verify outputs + if: ${{ inputs.mode == 'ref' }} + run: | + test "${{ steps.action.outputs.foobar }}" = "e2e-template-value" + test "${{ steps.action.outputs.barfoo }}" = "e2e-template-value" + + template-action-debug: + name: Validate template-action debug mode + needs: [preflight] + runs-on: ubuntu-latest + steps: + - name: Run template action with debug enabled + if: ${{ inputs.mode == 'ref' }} + id: action + uses: devops-infra/template-action@v1 + with: + foobar: e2e-debug-value + debug: 'true' + + - name: Run template action via docker image (preview) + if: ${{ inputs.mode == 'image' }} + run: | + if [ -z "${{ inputs.image_tag }}" ]; then + echo "image_tag is required when mode=image" + exit 1 + fi + echo "Image mode placeholder for devopsinfra/template-action:${{ inputs.image_tag }}" + echo "Use ref mode for authoritative validation until image-mode harness is finalized." + + - name: Verify outputs + if: ${{ inputs.mode == 'ref' }} + run: | + test "${{ steps.action.outputs.foobar }}" = "e2e-debug-value" + test "${{ steps.action.outputs.barfoo }}" = "e2e-debug-value" diff --git a/.github/workflows/e2e-action-terraform-copy-vars.yml b/.github/workflows/e2e-action-terraform-copy-vars.yml new file mode 100644 index 0000000..bad2d01 --- /dev/null +++ b/.github/workflows/e2e-action-terraform-copy-vars.yml @@ -0,0 +1,278 @@ +name: (E2E) Action Terraform Copy Vars + +on: + workflow_call: + inputs: + mode: + description: Execution mode (ref or image) + required: false + type: string + default: ref + image_tag: + description: Image tag for docker mode (for example v1.2.3-test or v1.2.3-rc) + required: false + type: string + default: '' + workflow_dispatch: + inputs: + mode: + description: Execution mode (ref or image) + required: false + default: ref + type: choice + options: + - ref + - image + image_tag: + description: Image tag for docker mode (for example v1.2.3-test or v1.2.3-rc) + required: false + default: '' + type: string + +permissions: + contents: read + +jobs: + preflight: + name: Validate workflow inputs + runs-on: ubuntu-latest + steps: + - name: Validate mode input + run: | + case "${{ inputs.mode }}" in + ref|image) ;; + *) + echo "Unsupported mode '${{ inputs.mode }}'. Expected 'ref' or 'image'." + exit 1 + ;; + esac + + basic-copy-vars: + name: Copy variables from central file to modules + needs: [preflight] + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v6 + + - name: Create Terraform module structure with all-variables file + run: | + mkdir -p terraform/module_a terraform/module_b + + cat > all-variables.tf << 'EOF' + variable "region" { + description = "Cloud region for deployment" + type = string + default = "us-east-1" + } + + variable "name" { + description = "Resource name" + type = string + } + + variable "env" { + description = "Deployment environment" + type = string + default = "dev" + } + EOF + + cat > terraform/module_a/variables.tf << 'EOF' + variable "region" { + description = "Cloud region for deployment" + type = string + default = "us-east-1" + } + EOF + + cat > terraform/module_b/variables.tf << 'EOF' + variable "name" { + description = "Resource name" + type = string + } + EOF + + - name: Copy variables from central file to all modules + if: ${{ inputs.mode == 'ref' }} + id: copy + uses: devops-infra/action-terraform-copy-vars@v1 + with: + dirs_with_modules: terraform + files_with_vars: variables.tf + all_vars_file: all-variables.tf + fail_on_missing: "false" + + - name: Copy vars via docker image (preview) + if: ${{ inputs.mode == 'image' }} + env: + ACTION_IMAGE: devopsinfra/action-terraform-copy-vars:${{ inputs.image_tag }} + run: | + if [ -z "${{ inputs.image_tag }}" ]; then + echo "image_tag is required when mode=image" + exit 1 + fi + docker pull "${ACTION_IMAGE}" + docker run --rm \ + -e INPUT_DIRS_WITH_MODULES=terraform \ + -e INPUT_FILES_WITH_VARS=variables.tf \ + -e INPUT_ALL_VARS_FILE=all-variables.tf \ + -e INPUT_FAIL_ON_MISSING=false \ + -v "$PWD:/github/workspace" \ + -w /github/workspace \ + "${ACTION_IMAGE}" + + - name: Show files changed output + if: ${{ inputs.mode == 'ref' }} + run: echo "files_changed=${{ steps.copy.outputs.files_changed }}" + + - name: Verify image mode execution + if: ${{ inputs.mode == 'image' }} + run: grep -q 'variable "env"' terraform/module_a/variables.tf + + copy-vars-custom-paths: + name: Copy variables with custom directory and file paths + needs: [preflight] + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v6 + + - name: Create custom Terraform structure + run: | + mkdir -p infra/services/api infra/services/db + + cat > infra/all-vars.tf << 'EOF' + variable "app_name" { + description = "Application name" + type = string + } + + variable "version_tag" { + description = "Docker image tag" + type = string + default = "latest" + } + EOF + + cat > infra/services/api/vars.tf << 'EOF' + variable "app_name" { + description = "Application name" + type = string + } + EOF + + cat > infra/services/db/vars.tf << 'EOF' + variable "app_name" { + description = "Application name" + type = string + } + EOF + + - name: Copy variables using custom paths + if: ${{ inputs.mode == 'ref' }} + id: copy + uses: devops-infra/action-terraform-copy-vars@v1 + with: + dirs_with_modules: infra/services + files_with_vars: vars.tf + all_vars_file: infra/all-vars.tf + fail_on_missing: "false" + + - name: Copy vars via docker image (preview) + if: ${{ inputs.mode == 'image' }} + env: + ACTION_IMAGE: devopsinfra/action-terraform-copy-vars:${{ inputs.image_tag }} + run: | + if [ -z "${{ inputs.image_tag }}" ]; then + echo "image_tag is required when mode=image" + exit 1 + fi + docker pull "${ACTION_IMAGE}" + docker run --rm \ + -e INPUT_DIRS_WITH_MODULES=infra/services \ + -e INPUT_FILES_WITH_VARS=vars.tf \ + -e INPUT_ALL_VARS_FILE=infra/all-vars.tf \ + -e INPUT_FAIL_ON_MISSING=false \ + -v "$PWD:/github/workspace" \ + -w /github/workspace \ + "${ACTION_IMAGE}" + + - name: Show files changed output + if: ${{ inputs.mode == 'ref' }} + run: echo "files_changed=${{ steps.copy.outputs.files_changed }}" + + - name: Verify image mode execution + if: ${{ inputs.mode == 'image' }} + run: grep -q 'variable "version_tag"' infra/services/api/vars.tf + + copy-vars-fail-on-missing: + name: Fail when module uses variable missing from central file + needs: [preflight] + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v6 + + - name: Create Terraform structure with missing variable + run: | + mkdir -p terraform/module_missing + + cat > all-variables.tf << 'EOF' + variable "region" { + description = "Cloud region" + type = string + default = "us-east-1" + } + EOF + + cat > terraform/module_missing/variables.tf << 'EOF' + variable "region" { + description = "Cloud region" + type = string + default = "us-east-1" + } + + variable "missing_var" { + description = "This variable is not defined in all-variables.tf" + type = string + } + EOF + + - name: Run copy-vars with fail_on_missing - expected to fail + if: ${{ inputs.mode == 'ref' }} + id: copy + uses: devops-infra/action-terraform-copy-vars@v1 + continue-on-error: true + with: + dirs_with_modules: terraform + files_with_vars: variables.tf + all_vars_file: all-variables.tf + fail_on_missing: "true" + + - name: Copy vars via docker image (preview) + if: ${{ inputs.mode == 'image' }} + env: + ACTION_IMAGE: devopsinfra/action-terraform-copy-vars:${{ inputs.image_tag }} + run: | + if [ -z "${{ inputs.image_tag }}" ]; then + echo "image_tag is required when mode=image" + exit 1 + fi + docker pull "${ACTION_IMAGE}" + set +e + docker run --rm \ + -e INPUT_DIRS_WITH_MODULES=terraform \ + -e INPUT_FILES_WITH_VARS=variables.tf \ + -e INPUT_ALL_VARS_FILE=all-variables.tf \ + -e INPUT_FAIL_ON_MISSING=true \ + -v "$PWD:/github/workspace" \ + -w /github/workspace \ + "${ACTION_IMAGE}" + rc=$? + set -e + test "$rc" -ne 0 + + - name: Verify action failed on missing variable + if: ${{ inputs.mode == 'ref' }} + run: test "${{ steps.copy.outcome }}" = "failure" diff --git a/.github/workflows/e2e-action-terraform-validate.yml b/.github/workflows/e2e-action-terraform-validate.yml new file mode 100644 index 0000000..f9d9b48 --- /dev/null +++ b/.github/workflows/e2e-action-terraform-validate.yml @@ -0,0 +1,159 @@ +name: (E2E) Action Terraform Validate + +on: + workflow_call: + inputs: + mode: + description: Execution mode (ref or image) + required: false + type: string + default: ref + image_tag: + description: Image tag for docker mode (for example v1.2.3-test or v1.2.3-rc) + required: false + type: string + default: '' + workflow_dispatch: + inputs: + mode: + description: Execution mode (ref or image) + required: false + default: ref + type: choice + options: + - ref + - image + image_tag: + description: Image tag for docker mode (for example v1.2.3-test or v1.2.3-rc) + required: false + default: '' + type: string + +permissions: + contents: read + +jobs: + preflight: + name: Validate workflow inputs + runs-on: ubuntu-latest + steps: + - name: Validate mode input + run: | + case "${{ inputs.mode }}" in + ref|image) ;; + *) + echo "Unsupported mode '${{ inputs.mode }}'. Expected 'ref' or 'image'." + exit 1 + ;; + esac + + validate-basic: + name: Validate valid Terraform configuration + needs: [preflight] + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v6 + + - name: Create valid Terraform module + run: | + mkdir -p terraform/module_validate + cat > terraform/module_validate/main.tf << 'EOF' + variable "name" { + description = "Resource name" + type = string + } + + variable "env" { + description = "Deployment environment" + type = string + default = "dev" + } + + locals { + full_name = "${var.name}-${var.env}" + } + + output "full_name" { + value = local.full_name + } + EOF + + - name: Validate all Terraform modules + if: ${{ inputs.mode == 'ref' }} + uses: devops-infra/action-terraform-validate@v1 + with: + dir_filter: terraform + + - name: Validate via docker image (preview) + if: ${{ inputs.mode == 'image' }} + env: + ACTION_IMAGE: devopsinfra/action-terraform-validate:${{ inputs.image_tag }} + run: | + if [ -z "${{ inputs.image_tag }}" ]; then + echo "image_tag is required when mode=image" + exit 1 + fi + docker pull "${ACTION_IMAGE}" + docker run --rm \ + -e INPUT_DIR_FILTER=terraform \ + -v "$PWD:/github/workspace" \ + -w /github/workspace \ + "${ACTION_IMAGE}" + + validate-with-dir-filter: + name: Validate with explicit directory filter + needs: [preflight] + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v6 + + - name: Create multiple Terraform modules + run: | + mkdir -p terraform/module_a terraform/module_b + + cat > terraform/module_a/main.tf << 'EOF' + variable "name" { + description = "Name of the resource" + type = string + } + + output "name_upper" { + value = upper(var.name) + } + EOF + + cat > terraform/module_b/main.tf << 'EOF' + variable "count_value" { + description = "Number of instances" + type = number + default = 1 + } + + output "count_doubled" { + value = var.count_value * 2 + } + EOF + + - name: Validate only module_a + if: ${{ inputs.mode == 'ref' }} + uses: devops-infra/action-terraform-validate@v1 + with: + dir_filter: terraform/module_a + + - name: Validate via docker image (preview) + if: ${{ inputs.mode == 'image' }} + env: + ACTION_IMAGE: devopsinfra/action-terraform-validate:${{ inputs.image_tag }} + run: | + if [ -z "${{ inputs.image_tag }}" ]; then + echo "image_tag is required when mode=image" + exit 1 + fi + docker pull "${ACTION_IMAGE}" + docker run --rm \ + -e INPUT_DIR_FILTER=terraform/module_a \ + -v "$PWD:/github/workspace" \ + -w /github/workspace \ + "${ACTION_IMAGE}" diff --git a/.github/workflows/e2e-action-tflint.yml b/.github/workflows/e2e-action-tflint.yml new file mode 100644 index 0000000..c152808 --- /dev/null +++ b/.github/workflows/e2e-action-tflint.yml @@ -0,0 +1,324 @@ +name: (E2E) Action TFLint + +on: + workflow_call: + inputs: + mode: + description: Execution mode (ref or image) + required: false + type: string + default: ref + image_tag: + description: Image tag for docker mode (for example v1.2.3-test or v1.2.3-rc) + required: false + type: string + default: '' + workflow_dispatch: + inputs: + mode: + description: Execution mode (ref or image) + required: false + default: ref + type: choice + options: + - ref + - image + image_tag: + description: Image tag for docker mode (for example v1.2.3-test or v1.2.3-rc) + required: false + default: '' + type: string + +permissions: + contents: read + +jobs: + preflight: + name: Validate workflow inputs + runs-on: ubuntu-latest + steps: + - name: Validate mode input + run: | + case "${{ inputs.mode }}" in + ref|image) ;; + *) + echo "Unsupported mode '${{ inputs.mode }}'. Expected 'ref' or 'image'." + exit 1 + ;; + esac + + basic-tflint: + name: Basic TFLint on valid Terraform files + needs: [preflight] + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v6 + + - name: Create valid Terraform module + run: | + mkdir -p terraform/module_basic + cat > terraform/module_basic/main.tf << 'EOF' + variable "name" { + description = "Resource name" + type = string + } + + locals { + upper_name = upper(var.name) + } + + output "upper_name" { + value = local.upper_name + } + EOF + + - name: Run TFLint on all Terraform directories + if: ${{ inputs.mode == 'ref' }} + uses: devops-infra/action-tflint@v1 + with: + dir_filter: terraform + run_init: "false" + fail_on_changes: "false" + + - name: Run TFLint from docker image (preview) + if: ${{ inputs.mode == 'image' }} + env: + ACTION_IMAGE: devopsinfra/action-tflint:${{ inputs.image_tag }} + run: | + if [ -z "${{ inputs.image_tag }}" ]; then + echo "image_tag is required when mode=image" + exit 1 + fi + docker pull "${ACTION_IMAGE}" + docker run --rm \ + -e INPUT_DIR_FILTER=terraform \ + -e INPUT_RUN_INIT=false \ + -e INPUT_FAIL_ON_CHANGES=false \ + -v "$PWD:/github/workspace" \ + -w /github/workspace \ + "${ACTION_IMAGE}" + + tflint-with-dir-filter: + name: TFLint with specific directory filter + needs: [preflight] + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v6 + + - name: Create multiple Terraform modules + run: | + mkdir -p terraform/module_aws terraform/module_gcp + + cat > terraform/module_aws/main.tf << 'EOF' + variable "region" { + description = "AWS region" + type = string + default = "us-east-1" + } + + output "region" { + value = var.region + } + EOF + + cat > terraform/module_gcp/main.tf << 'EOF' + variable "project" { + description = "GCP project ID" + type = string + } + + output "project" { + value = var.project + } + EOF + + - name: Run TFLint only on aws module + if: ${{ inputs.mode == 'ref' }} + uses: devops-infra/action-tflint@v1 + with: + dir_filter: terraform/module_aws + run_init: "false" + fail_on_changes: "false" + + - name: Run TFLint from docker image (preview) + if: ${{ inputs.mode == 'image' }} + env: + ACTION_IMAGE: devopsinfra/action-tflint:${{ inputs.image_tag }} + run: | + if [ -z "${{ inputs.image_tag }}" ]; then + echo "image_tag is required when mode=image" + exit 1 + fi + docker pull "${ACTION_IMAGE}" + docker run --rm \ + -e INPUT_DIR_FILTER=terraform/module_aws \ + -e INPUT_RUN_INIT=false \ + -e INPUT_FAIL_ON_CHANGES=false \ + -v "$PWD:/github/workspace" \ + -w /github/workspace \ + "${ACTION_IMAGE}" + + tflint-no-fail-on-changes: + name: TFLint with fail_on_changes disabled + needs: [preflight] + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v6 + + - name: Create Terraform module with lint warning + run: | + mkdir -p terraform/module_warn + + cat > terraform/module_warn/main.tf << 'EOF' + variable "unused_var" { + description = "This variable is intentionally unused to trigger a warning" + type = string + default = "unused" + } + + output "constant" { + value = "hello" + } + EOF + + - name: Run TFLint without failing on findings + if: ${{ inputs.mode == 'ref' }} + uses: devops-infra/action-tflint@v1 + with: + dir_filter: terraform/module_warn + run_init: "false" + fail_on_changes: "false" + + - name: Run TFLint from docker image (preview) + if: ${{ inputs.mode == 'image' }} + env: + ACTION_IMAGE: devopsinfra/action-tflint:${{ inputs.image_tag }} + run: | + if [ -z "${{ inputs.image_tag }}" ]; then + echo "image_tag is required when mode=image" + exit 1 + fi + docker pull "${ACTION_IMAGE}" + docker run --rm \ + -e INPUT_DIR_FILTER=terraform/module_warn \ + -e INPUT_RUN_INIT=false \ + -e INPUT_FAIL_ON_CHANGES=false \ + -v "$PWD:/github/workspace" \ + -w /github/workspace \ + "${ACTION_IMAGE}" + + tflint-with-custom-config: + name: TFLint with explicit tflint_config + needs: [preflight] + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v6 + + - name: Create Terraform module and custom TFLint config + run: | + mkdir -p terraform/module_custom + cat > terraform/module_custom/main.tf << 'EOF' + terraform { + required_version = ">= 1.5.0" + } + + variable "name" { + type = string + } + + output "name" { + value = var.name + } + EOF + + cat > terraform/.tflint.custom.hcl << 'EOF' + config { + module = false + force = false + } + EOF + + - name: Run TFLint with custom config path + if: ${{ inputs.mode == 'ref' }} + uses: devops-infra/action-tflint@v1 + with: + dir_filter: terraform/module_custom + run_init: "false" + fail_on_changes: "false" + tflint_config: terraform/.tflint.custom.hcl + + - name: Run TFLint from docker image (preview) + if: ${{ inputs.mode == 'image' }} + env: + ACTION_IMAGE: devopsinfra/action-tflint:${{ inputs.image_tag }} + run: | + if [ -z "${{ inputs.image_tag }}" ]; then + echo "image_tag is required when mode=image" + exit 1 + fi + docker pull "${ACTION_IMAGE}" + docker run --rm \ + -e INPUT_DIR_FILTER=terraform/module_custom \ + -e INPUT_RUN_INIT=false \ + -e INPUT_FAIL_ON_CHANGES=false \ + -e INPUT_TFLINT_CONFIG=terraform/.tflint.custom.hcl \ + -v "$PWD:/github/workspace" \ + -w /github/workspace \ + "${ACTION_IMAGE}" + + tflint-with-custom-params: + name: TFLint with custom tflint_params + needs: [preflight] + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v6 + + - name: Create Terraform module + run: | + mkdir -p terraform/module_params + cat > terraform/module_params/main.tf << 'EOF' + terraform { + required_version = ">= 1.5.0" + } + + variable "name" { + type = string + } + + output "name" { + value = var.name + } + EOF + + - name: Run TFLint with custom params + if: ${{ inputs.mode == 'ref' }} + uses: devops-infra/action-tflint@v1 + with: + dir_filter: terraform/module_params + run_init: "false" + fail_on_changes: "false" + tflint_params: "--minimum-failure-severity=error" + + - name: Run TFLint from docker image (preview) + if: ${{ inputs.mode == 'image' }} + env: + ACTION_IMAGE: devopsinfra/action-tflint:${{ inputs.image_tag }} + run: | + if [ -z "${{ inputs.image_tag }}" ]; then + echo "image_tag is required when mode=image" + exit 1 + fi + docker pull "${ACTION_IMAGE}" + docker run --rm \ + -e INPUT_DIR_FILTER=terraform/module_params \ + -e INPUT_RUN_INIT=false \ + -e INPUT_FAIL_ON_CHANGES=false \ + -e INPUT_TFLINT_PARAMS=--minimum-failure-severity=error \ + -v "$PWD:/github/workspace" \ + -w /github/workspace \ + "${ACTION_IMAGE}" diff --git a/.gitignore b/.gitignore index 22f7e67..56852be 100644 --- a/.gitignore +++ b/.gitignore @@ -16,4 +16,12 @@ build/ dist/ *.egg-info/ *.pyc -__py +__pycache__/ + +# Local test artifacts +*.tf +*.tfvars +terraform/ +infra/ +tests/e2e-hcl-*/ +e2e-*.txt diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index c03960b..02e7314 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -39,3 +39,9 @@ repos: entry: bash -lc 'docker run --rm -v "$PWD:/work" -w /work cytopia/yamllint -c .yamllint.yml "$@"' -- language: system files: \.(yml|yaml)$ + - id: pylint + name: pylint + entry: python3 -m pylint --rcfile .pylintrc + language: system + # Keep language: system to align with this repository's Docker/system-based hooks. + files: \.py$ diff --git a/.pylintrc b/.pylintrc new file mode 100644 index 0000000..a28fbaf --- /dev/null +++ b/.pylintrc @@ -0,0 +1 @@ +[MASTER] diff --git a/README.md b/README.md index 596a8ba..8fe2150 100644 --- a/README.md +++ b/README.md @@ -1,39 +1,158 @@ -# Universal template for organizational repository +# Triglav +## End-to-End Tests Framework -## 📊 Badges -[ -![GitHub repo](https://img.shields.io/badge/GitHub-devops--infra%2Ftemplate--repository-blueviolet.svg?style=plastic&logo=github) -![GitHub last commit](https://img.shields.io/github/last-commit/devops-infra/template-repository?color=blueviolet&logo=github&style=plastic&label=Last%20commit) -![GitHub code size in bytes](https://img.shields.io/github/languages/code-size/devops-infra/template-repository?color=blueviolet&label=Code%20size&style=plastic&logo=github) -![GitHub license](https://img.shields.io/github/license/devops-infra/template-repository?color=blueviolet&logo=github&style=plastic&label=License) -](https://github.com/devops-infra/template-repository "shields.io") +Repository-level framework used to validate `devops-infra` automation end-to-end, with a focus on GitHub Actions behavior in real workflow runs. -## Forking -To publish images from a fork, set these variables so Task uses your registry identities: -`DOCKER_USERNAME`, `DOCKER_ORG_NAME`, `GITHUB_USERNAME`, `GITHUB_ORG_NAME`. +![Triglav](triglav.jpeg) + +## Why Triglav + +In Slavic mythology, Triglav represents three realms. That maps well to this framework's validation layers: + +- pull request lifecycle and branch management behavior +- integration tests against live GitHub runtime +- periodic regression testing to catch unexpected changes + +## Scope + +- Executes reusable and action-specific E2E workflows in this repository. +- Verifies outputs, expected failures, and integration behavior against live GitHub runtime. +- Provides a stable place to add regression tests before rolling changes organization-wide. + +## Covered Actions and Test Types + +| Action | Workflow | Test Coverage | +|------------------------------------------------|-------------------------------------------------------------|---------------------------------------------------------------------------------------------------------------------------------| +| `devops-infra/action-commit-push` | `.github/workflows/e2e-action-commit-push.yml` | branch creation/push, custom message/prefix, empty commit mode, amend with force-with-lease, output verification, cleanup | +| `devops-infra/action-pull-request` | `.github/workflows/e2e-action-pull-request.yml` | PR creation/update paths, custom title/body, draft + `get_diff`, `repository` + `repository_path`, output verification, cleanup | +| `devops-infra/action-format-hcl` | `.github/workflows/e2e-action-format-hcl.yml` | check mode pass/fail, write mode, list/diff mode, malformed input detection | +| `devops-infra/action-container-structure-test` | `.github/workflows/e2e-action-container-structure-test.yml` | text/json/junit output modes, report file creation, multi-config execution, output counters | +| `devops-infra/action-terraform-copy-vars` | `.github/workflows/e2e-action-terraform-copy-vars.yml` | variable propagation across modules, custom path inputs, strict missing-variable failure mode | +| `devops-infra/action-terraform-validate` | `.github/workflows/e2e-action-terraform-validate.yml` | valid module validation, scoped validation via `dir_filter` | +| `devops-infra/action-tflint` | `.github/workflows/e2e-action-tflint.yml` | lint execution across modules, scoped lint via `dir_filter`, non-blocking findings mode | +| `devops-infra/template-action` | `.github/workflows/e2e-action-template-action.yml` | baseline template behavior validation, output contract checks, debug-mode execution | + +## Workflow Orchestration + +- Main orchestrator: `.github/workflows/cron-e2e-tests.yml` +- Triggers: + - weekly cron schedule + - manual dispatch (`workflow_dispatch`) +- Executes all action-focused E2E workflows via reusable `workflow_call` jobs. + +## Local Development + +Prerequisites: + +- `task` +- `docker` +- `gh` (authenticated) +- `python3 -m pylint` available in your environment (local install is acceptable) + +Common commands: + +```bash +task lint +task pre-commit +task e2e:list-workflows +task e2e:run WORKFLOW=e2e-action-pull-request.yml +task e2e:run WORKFLOW=e2e-action-format-hcl.yml MODE=image IMAGE_TAG=v1.2.3-test +task e2e:run:all +task e2e:run:all MODE=image IMAGE_TAG=v1.2.3-test +task test:coverage:report +task test:coverage:gate +``` + +Useful follow-up commands: -Two supported options (environment variables take precedence over `.env`): ```bash -# .env (local only, not committed) -DOCKER_USERNAME=your-dockerhub-user -DOCKER_ORG_NAME=your-dockerhub-org -GITHUB_USERNAME=your-github-user -GITHUB_ORG_NAME=your-github-org +task e2e:view-latest WORKFLOW=e2e-action-pull-request.yml +task e2e:watch RUN_ID= ``` +## Manual Workflow Runs: Permissions and Secrets + +When triggering workflows manually with `gh workflow run`, ensure: + +- Your local GitHub CLI token has `repo` and `workflow` scopes. +- `gh auth status` is healthy for the same GitHub account that can run workflows in this repository. +- Workflow job permissions remain enabled for tested actions: + - `contents: write` for branch/commit operations (`action-commit-push`, `action-pull-request` tests) + - `pull-requests: write` and `issues: write` for PR lifecycle operations (`action-pull-request` tests) + - `contents: read` for read-only action workflows (`action-format-hcl`, `action-tflint`, `action-terraform-*`, `action-container-structure-test`) + +Manual dispatch examples: + ```bash -# Shell override -DOCKER_USERNAME=your-dockerhub-user \ -DOCKER_ORG_NAME=your-dockerhub-org \ -GITHUB_USERNAME=your-github-user \ -GITHUB_ORG_NAME=your-github-org \ -task docker:build +task e2e:run WORKFLOW=e2e-action-commit-push.yml +task e2e:run:all +task e2e:run WORKFLOW=e2e-action-tflint.yml MODE=image IMAGE_TAG=v1.2.3-test +``` + +This repository primarily relies on the built-in `${{ secrets.GITHUB_TOKEN }}` in workflow runs. +If future scenarios require elevated credentials, define additional secrets in repository settings and document them in the specific workflow file. + +## Input Coverage Gate + +- Coverage report: `task test:coverage:report` +- Strict gate: `task test:coverage:gate` +- Baseline file for currently accepted uncovered inputs: `tests/coverage-baseline.json` + +The strict gate fails only when newly uncovered inputs appear outside the baseline. + +## Reusable Workflow Usage in Action Repositories + +Each `e2e-action-*.yml` workflow supports `workflow_call`, so action repositories can reuse this framework for pre-merge checks. + +Current org-wide automation wiring: + +- Pull request flow (`reusable-auto-pull-request-create.yml`) calls action-specific E2E workflows for `action-*` repositories. +- Release branch prepare flow (`reusable-manual-release-branch-prepare.yml`) calls action-specific E2E workflows against `release/*` refs and `-rc` tags. +- Release create flow (`reusable-auto-release-create.yml`) calls action-specific E2E workflows against production release tags/images. + +Recommended pre-merge strategy: + +- Run E2E with action refs that point to the PR under test (branch or SHA). +- Run image-tag verification stages for `-test` and `-rc` tags after image publication in release pipelines. + +Execution modes: + +- `mode=ref` runs ref-oriented E2E paths against stable pinned action refs. +- `mode=image` validates a published Docker image and requires `image_tag`. This is authoritative in release image checks. +- Use semantic tags for `image_tag` in automation (`vX.Y.Z-test`, `vX.Y.Z-rc`, `vX.Y.Z`) instead of mutable aliases. + +Current mode behavior by workflow: + +- `e2e-action-commit-push.yml`: `ref` authoritative, `image` placeholder preview. +- `e2e-action-pull-request.yml`: `ref` authoritative, `image` placeholder preview. +- `e2e-action-template-action.yml`: `ref` authoritative, `image` placeholder preview. +- `e2e-action-container-structure-test.yml`: `ref` authoritative in reusable CI flows, `image` preview. +- `e2e-action-format-hcl.yml`: `ref` and executable `image` supported. +- `e2e-action-tflint.yml`: `ref` and executable `image` supported. +- `e2e-action-terraform-validate.yml`: `ref` and executable `image` supported. +- `e2e-action-terraform-copy-vars.yml`: `ref` and executable `image` supported. + +Example callers from another action repository: + +```yaml +jobs: + e2e-pr-validation: + uses: devops-infra/triglav/.github/workflows/e2e-action-pull-request.yml@master + with: + mode: ref +``` + +```yaml +jobs: + e2e-image-validation: + uses: devops-infra/triglav/.github/workflows/e2e-action-format-hcl.yml@master + with: + mode: image + image_tag: v1.2.3-test ``` -Recommended setup: -- Local development: use a `.env` file. -- GitHub Actions: set repo variables for the four values above, and secrets for `DOCKER_TOKEN` and `GITHUB_TOKEN`. +## Notes -Publish images without a release: -- Run the `(Manual) Release Create` workflow with `build_only: true` to build and push images without tagging a release. +- E2E workflows intentionally create temporary test branches and pull requests and then clean them up. +- Use this repository to validate behavior before promoting changes in action repositories or reusable org workflows. diff --git a/Taskfile.cicd.yml b/Taskfile.cicd.yml index 82fe5b3..78ef313 100644 --- a/Taskfile.cicd.yml +++ b/Taskfile.cicd.yml @@ -21,6 +21,7 @@ tasks: - task: lint:actionlint - task: lint:shellcheck - task: lint:yamllint + - task: lint:pylint lint:actionlint: desc: Lint GitHub Actions workflows with actionlint @@ -37,6 +38,46 @@ tasks: cmds: - task: scripts:lint:yamllint + lint:pylint: + desc: Lint Python files with pylint + cmds: + - task: scripts:lint:pylint + + e2e:list-workflows: + desc: List available E2E workflows + cmds: + - task: scripts:e2e:list-workflows + + e2e:run: + desc: Trigger selected E2E workflow via gh + cmds: + - task: scripts:e2e:run + + e2e:run:all: + desc: Trigger all E2E action workflows via gh + cmds: + - task: scripts:e2e:run:all + + e2e:view-latest: + desc: View latest run for selected E2E workflow + cmds: + - task: scripts:e2e:view-latest + + e2e:watch: + desc: Watch selected workflow run by RUN_ID + cmds: + - task: scripts:e2e:watch + + test:coverage:report: + desc: Report action input coverage from tests and E2E workflows + cmds: + - task: scripts:test:coverage:report + + test:coverage:gate: + desc: Fail on new uncovered action inputs not listed in baseline + cmds: + - task: scripts:test:coverage:gate + dependency:update: desc: 'No-op: no dedicated dependency updater configured for this profile' cmds: diff --git a/Taskfile.scripts.yml b/Taskfile.scripts.yml index f1cb976..8d788f3 100644 --- a/Taskfile.scripts.yml +++ b/Taskfile.scripts.yml @@ -90,6 +90,132 @@ tasks: exit $rc fi + lint:pylint: + desc: Lint Python files with pylint + shell: bash + cmds: + - | + set -eu + echo "▶️ Running pylint..." + tmp_files="$(mktemp)" + trap 'rm -f "$tmp_files"' EXIT + git ls-files -z '*.py' > "$tmp_files" + if [ ! -s "$tmp_files" ]; then + echo "ℹ️ No Python files found, skipping pylint" + exit 0 + fi + if ! python3 -m pylint --version >/dev/null 2>&1; then + echo "ℹ️ pylint not found, installing locally..." + python3 -m pip install --user --quiet pylint==3.3.4 + fi + xargs -0 python3 -m pylint --rcfile .pylintrc < "$tmp_files" + echo "✅ pylint passed" + + e2e:list-workflows: + desc: List E2E workflow files + shell: bash + cmds: + - | + set -eu + shopt -s nullglob + files=(.github/workflows/e2e-*.yml) + if [ "${#files[@]}" -eq 0 ]; then + echo "No E2E workflows found" + exit 0 + fi + printf '%s\n' "${files[@]}" | sort + + e2e:run: + desc: Run selected E2E workflow via gh + shell: bash + cmds: + - | + set -eu + workflow="${WORKFLOW:-}" + workflow_ref="${WORKFLOW_REF:-{{.GIT_BRANCH}}}" + if [ -z "$workflow" ]; then + echo "ERROR: set WORKFLOW, e.g. WORKFLOW=e2e-action-pull-request.yml" + exit 1 + fi + mode="${MODE:-ref}" + image_tag="${IMAGE_TAG:-}" + gh workflow run "$workflow" --repo "{{.GH_REPO}}" --ref "$workflow_ref" -f mode="$mode" -f image_tag="$image_tag" + echo "Triggered workflow '$workflow' in {{.GH_REPO}} on ref '$workflow_ref'" + + e2e:run:all: + desc: Trigger all E2E action workflows sequentially + shell: bash + cmds: + - | + set -eu + workflow_ref="${WORKFLOW_REF:-{{.GIT_BRANCH}}}" + shopt -s nullglob + files=(.github/workflows/e2e-action-*.yml) + if [ "${#files[@]}" -eq 0 ]; then + echo "No E2E action workflows found" + exit 0 + fi + mapfile -t workflows < <(printf '%s\n' "${files[@]}" | xargs -n1 basename | sort) + mode="${MODE:-ref}" + image_tag="${IMAGE_TAG:-}" + for workflow in "${workflows[@]}"; do + echo "Triggering ${workflow} in {{.GH_REPO}} on ref ${workflow_ref}" + gh workflow run "$workflow" --repo "{{.GH_REPO}}" --ref "$workflow_ref" -f mode="$mode" -f image_tag="$image_tag" + sleep 1 + done + echo "Triggered ${#workflows[@]} workflow(s) on ref '${workflow_ref}'." + + e2e:view-latest: + desc: Show latest run for selected E2E workflow + shell: bash + cmds: + - | + set -eu + workflow="${WORKFLOW:-}" + if [ -z "$workflow" ]; then + echo "ERROR: set WORKFLOW, e.g. WORKFLOW=e2e-action-pull-request.yml" + exit 1 + fi + gh run list --repo "{{.GH_REPO}}" --workflow "$workflow" --limit 1 + + e2e:watch: + desc: Watch a workflow run by RUN_ID + shell: bash + cmds: + - | + set -eu + run_id="${RUN_ID:-}" + if [ -z "$run_id" ]; then + echo "ERROR: set RUN_ID" + exit 1 + fi + gh run watch "$run_id" --repo "{{.GH_REPO}}" + + test:coverage:report: + desc: Report action input coverage from local tests and E2E workflows + shell: bash + cmds: + - | + set -eu + workspace_root="${WORKSPACE_ROOT:-$(dirname "$PWD")}" + python3 scripts/check_action_input_coverage.py \ + --workspace-root "$workspace_root" \ + --repo-root "$PWD" \ + --baseline-file "$PWD/tests/coverage-baseline.json" + + test:coverage:gate: + desc: Fail on new uncovered action inputs not in baseline + shell: bash + cmds: + - | + set -eu + workspace_root="${WORKSPACE_ROOT:-$(dirname "$PWD")}" + python3 scripts/check_action_input_coverage.py \ + --workspace-root "$workspace_root" \ + --repo-root "$PWD" \ + --baseline-file "$PWD/tests/coverage-baseline.json" \ + --strict + dependency:update: desc: 'No-op: no dedicated dependency updater configured for this profile' cmds: diff --git a/Taskfile.variables.yml b/Taskfile.variables.yml index b84a5ca..682dfa9 100644 --- a/Taskfile.variables.yml +++ b/Taskfile.variables.yml @@ -28,6 +28,13 @@ vars: fi PROJECT_DIR_NAME: sh: basename "$PWD" + GH_REPO: + sh: | + if [ -n "${GH_REPO:-}" ]; then + echo "${GH_REPO}" + else + gh repo view --json nameWithOwner -q .nameWithOwner 2>/dev/null || echo "devops-infra/triglav" + fi VERSION_OVERRIDE: sh: echo "${VERSION_OVERRIDE:-}" VERSION: diff --git a/scripts/check_action_input_coverage.py b/scripts/check_action_input_coverage.py new file mode 100644 index 0000000..571f0d0 --- /dev/null +++ b/scripts/check_action_input_coverage.py @@ -0,0 +1,253 @@ +"""Check action input coverage across local tests and E2E workflows.""" + +from __future__ import annotations + +import argparse +import json +import re +from pathlib import Path + + +TEXT_SUFFIXES = {".sh", ".py", ".yml", ".yaml", ".md", ".txt"} + + +def parse_action_inputs(action_file: Path) -> list[str]: + """Parse top-level `inputs` keys from an action.yml file.""" + lines = action_file.read_text(encoding="utf-8").splitlines() + inputs_indent: int | None = None + start = 0 + keys: list[str] = [] + + for idx, line in enumerate(lines): + if re.match(r"^\s*inputs:\s*$", line): + inputs_indent = len(line) - len(line.lstrip(" ")) + start = idx + 1 + break + + if inputs_indent is None: + return [] + + for line in lines[start:]: + stripped = line.strip() + if not stripped or line.lstrip().startswith("#"): + continue + + indent = len(line) - len(line.lstrip(" ")) + if indent <= inputs_indent: + break + + match = re.match( + r"^(?P\s+)" + r"(?:\"(?P[^\"]+)\"|" + r"'(?P[^']+)'|" + r"(?P[A-Za-z0-9_-]+))" + r":\s*$", + line, + ) + if not match: + continue + + key_indent = len(match.group("indent")) + if key_indent == inputs_indent + 2: + key = ( + match.group("double_quoted") + or match.group("single_quoted") + or match.group("plain") + ) + keys.append(key) + + return keys + + +def read_text_if_possible(path: Path) -> str: + """Read UTF-8 text from path, returning empty string on read errors.""" + if not path.exists() or not path.is_file(): + return "" + try: + return path.read_text(encoding="utf-8") + except (OSError, UnicodeDecodeError): + return "" + + +def normalize_input_env_name(input_name: str) -> str: + """Normalize an action input name to INPUT_* environment variable form.""" + normalized = re.sub(r"[^A-Za-z0-9]", "_", input_name).upper() + return f"INPUT_{normalized}" + + +def input_is_covered(input_name: str, corpus: str) -> bool: + """Check whether an input name appears in test corpus directly or as INPUT_* env.""" + escaped = re.escape(input_name) + env_name = re.escape(normalize_input_env_name(input_name)) + pattern = re.compile( + rf"(? str: + """Collect all relevant textual test sources for coverage matching.""" + parts: list[str] = [] + if e2e_workflow.exists(): + parts.append(read_text_if_possible(e2e_workflow)) + + tests_dir = action_repo_dir / "tests" + if tests_dir.exists(): + for file_path in sorted(tests_dir.rglob("*")): + if file_path.is_file() and file_path.suffix in TEXT_SUFFIXES: + parts.append(read_text_if_possible(file_path)) + + return "\n".join(parts) + + +def load_baseline(path: Path) -> dict[str, list[str]]: + """Load optional JSON baseline mapping action repo to uncovered inputs.""" + if not path.exists(): + return {} + + try: + raw = json.loads(path.read_text(encoding="utf-8")) + except json.JSONDecodeError: + print(f"[ERROR] invalid JSON in baseline file: {path}") + return {} + if not isinstance(raw, dict): + return {} + + normalized: dict[str, list[str]] = {} + for key, value in raw.items(): + if isinstance(value, list): + normalized[key] = sorted(str(v) for v in value) + return normalized + + +def build_parser() -> argparse.ArgumentParser: + """Build command-line argument parser.""" + parser = argparse.ArgumentParser( + description="Check action input coverage in tests" + ) + parser.add_argument( + "--workspace-root", + required=True, + help="Workspace root containing action-* repos", + ) + parser.add_argument( + "--repo-root", + required=True, + help="Path to triglav repository", + ) + parser.add_argument( + "--baseline-file", + required=True, + help="JSON baseline file for known gaps", + ) + parser.add_argument( + "--strict", + action="store_true", + help="Fail on gaps not present in baseline", + ) + parser.add_argument( + "--write-baseline", + action="store_true", + help="Write current uncovered gaps to baseline", + ) + return parser + + +def collect_results(workspace_root: Path, repo_root: Path) -> dict[str, list[str]]: + """Collect uncovered input names per action repository.""" + results: dict[str, list[str]] = {} + + for action_repo in sorted(workspace_root.glob("action-*")): + action_file = action_repo / "action.yml" + if not action_file.exists(): + continue + + action_name = action_repo.name + e2e_workflow = repo_root / ".github" / "workflows" / f"e2e-{action_name}.yml" + inputs = parse_action_inputs(action_file) + corpus = collect_test_corpus(action_repo, e2e_workflow) + uncovered = [ + input_name + for input_name in inputs + if not input_is_covered(input_name, corpus) + ] + results[action_name] = sorted(uncovered) + + return results + + +def write_baseline_file(results: dict[str, list[str]], baseline_file: Path) -> int: + """Write JSON baseline and exit successfully.""" + baseline_file.parent.mkdir(parents=True, exist_ok=True) + payload = json.dumps(results, indent=2, sort_keys=True) + "\n" + baseline_file.write_text(payload, encoding="utf-8") + print(f"Wrote baseline: {baseline_file}") + return 0 + + +def print_report(results: dict[str, list[str]]) -> None: + """Print human-readable coverage summary.""" + print("Action input coverage report") + for action_name, uncovered in results.items(): + if uncovered: + missing = ", ".join(uncovered) + print(f"- {action_name}: missing {len(uncovered)} -> {missing}") + else: + print(f"- {action_name}: fully covered") + + +def strict_gate(results: dict[str, list[str]], baseline_file: Path) -> int: + """Apply strict gate against baseline and return process code.""" + if not results: + print("[ERROR] no action-* repositories discovered under --workspace-root") + return 1 + + baseline = load_baseline(baseline_file) + has_new_gap = False + + for action_name, uncovered in results.items(): + allowed = set(baseline.get(action_name, [])) + new_gaps = sorted(set(uncovered) - allowed) + resolved = sorted(allowed - set(uncovered)) + + if new_gaps: + has_new_gap = True + joined = ", ".join(new_gaps) + print( + f"[ERROR] {action_name}: " + f"new uncovered inputs not in baseline -> {joined}" + ) + if resolved: + joined = ", ".join(resolved) + print( + f"[INFO] {action_name}: " + f"baseline can be tightened, now covered -> {joined}" + ) + + return 1 if has_new_gap else 0 + + +def main() -> int: + """Run coverage check command.""" + parser = build_parser() + args = parser.parse_args() + + workspace_root = Path(args.workspace_root).resolve() + repo_root = Path(args.repo_root).resolve() + baseline_file = Path(args.baseline_file).resolve() + + results = collect_results(workspace_root, repo_root) + + if args.write_baseline: + return write_baseline_file(results, baseline_file) + + print_report(results) + if not args.strict: + return 0 + + return strict_gate(results, baseline_file) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/coverage-baseline.json b/tests/coverage-baseline.json new file mode 100644 index 0000000..0b242e4 --- /dev/null +++ b/tests/coverage-baseline.json @@ -0,0 +1,9 @@ +{ + "action-commit-push": [], + "action-container-structure-test": [], + "action-format-hcl": [], + "action-pull-request": [], + "action-terraform-copy-vars": [], + "action-terraform-validate": [], + "action-tflint": [] +} diff --git a/tests/fixtures/container-structure-test/alpine-extended.yml b/tests/fixtures/container-structure-test/alpine-extended.yml new file mode 100644 index 0000000..6976042 --- /dev/null +++ b/tests/fixtures/container-structure-test/alpine-extended.yml @@ -0,0 +1,12 @@ +schemaVersion: '2.0.0' + +commandTests: + - name: Alpine package manager available + command: apk + args: [--version] + expectedOutput: [apk-tools] + +fileExistenceTests: + - name: /usr/bin/env exists + path: /usr/bin/env + shouldExist: true diff --git a/tests/fixtures/container-structure-test/alpine.yml b/tests/fixtures/container-structure-test/alpine.yml new file mode 100644 index 0000000..e95526e --- /dev/null +++ b/tests/fixtures/container-structure-test/alpine.yml @@ -0,0 +1,21 @@ +schemaVersion: '2.0.0' + +commandTests: + - name: Alpine OS identification + command: cat + args: [/etc/os-release] + expectedOutput: [ID=alpine] + + - name: Shell available + command: /bin/sh + args: [-c, echo ok] + expectedOutput: [ok] + +fileExistenceTests: + - name: /etc/os-release exists + path: /etc/os-release + shouldExist: true + + - name: /bin/sh exists + path: /bin/sh + shouldExist: true diff --git a/tests/test_check_action_input_coverage.py b/tests/test_check_action_input_coverage.py new file mode 100644 index 0000000..1f4c32e --- /dev/null +++ b/tests/test_check_action_input_coverage.py @@ -0,0 +1,84 @@ +"""Tests for scripts/check_action_input_coverage.py helpers.""" + +from __future__ import annotations + +import importlib.util +import tempfile +from pathlib import Path +import unittest + + +def load_module(): + """Load coverage script module from file path.""" + module_path = Path(__file__).resolve().parents[1] / "scripts" / "check_action_input_coverage.py" + spec = importlib.util.spec_from_file_location("coverage_script", module_path) + assert spec is not None + assert spec.loader is not None + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +class CoverageScriptTests(unittest.TestCase): + """Unit tests for input parsing and matching.""" + + @classmethod + def setUpClass(cls): + cls.module = load_module() + + def test_parse_action_inputs_supports_hyphen_and_quotes(self): + """Parser extracts plain, hyphenated, and quoted keys.""" + content = """ +name: Example +inputs: + plain_key: + description: plain + kebab-key: + description: hyphen + "quoted-key": + description: quoted + 'single-quoted-key': + description: quoted +outputs: + ignored: {} +""".strip() + + with tempfile.TemporaryDirectory() as tmp_dir: + action_file = Path(tmp_dir) / "action.yml" + action_file.write_text(content, encoding="utf-8") + keys = self.module.parse_action_inputs(action_file) + + self.assertEqual( + keys, + ["plain_key", "kebab-key", "quoted-key", "single-quoted-key"], + ) + + def test_normalize_input_env_name(self): + """Normalization maps non-alnum chars to underscores.""" + normalized = self.module.normalize_input_env_name("my-input name") + self.assertEqual(normalized, "INPUT_MY_INPUT_NAME") + + def test_input_is_covered_by_env_name_for_hyphenated_input(self): + """Coverage matching finds normalized INPUT_* variable usage.""" + corpus = "run: echo $INPUT_DRY_RUN_MODE" + self.assertTrue(self.module.input_is_covered("dry-run-mode", corpus)) + + def test_strict_gate_fails_when_no_action_repos(self): + """Strict gate fails when discovery yields no action repositories.""" + with tempfile.TemporaryDirectory() as tmp_dir: + baseline_file = Path(tmp_dir) / "baseline.json" + baseline_file.write_text("{}\n", encoding="utf-8") + exit_code = self.module.strict_gate({}, baseline_file) + self.assertEqual(exit_code, 1) + + def test_load_baseline_handles_invalid_json(self): + """Invalid baseline JSON returns empty baseline without crashing.""" + with tempfile.TemporaryDirectory() as tmp_dir: + baseline_file = Path(tmp_dir) / "baseline.json" + baseline_file.write_text("{invalid-json", encoding="utf-8") + baseline = self.module.load_baseline(baseline_file) + self.assertEqual(baseline, {}) + + +if __name__ == "__main__": + unittest.main() diff --git a/triglav.jpeg b/triglav.jpeg new file mode 100644 index 0000000..d671fd2 Binary files /dev/null and b/triglav.jpeg differ