Skip to content

Commit 53344bd

Browse files
Merge pull request #11 from Facets-cloud/feat/module-ci-action
Add module-ci-action: raptor-driven CI for Facets modules repos
2 parents a0691f4 + 1dff7fe commit 53344bd

8 files changed

Lines changed: 879 additions & 8 deletions

File tree

.github/workflows/release.yml

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
name: "Release"
2+
3+
# Versioning for the actions in this repo (they are consumed as
4+
# Facets-cloud/github-actions/<action>@<ref>):
5+
#
6+
# - Exact releases are annotated semver tags: v1.0.0, v1.1.0, ...
7+
# - Each major has a MOVING alias tag (v1, v2, ...) that always points at the
8+
# latest release of that major — the same convention as actions/checkout.
9+
# Consumers (including the workflow the control plane bootstraps into
10+
# customer repos) pin the alias: module-ci-action@v1.
11+
#
12+
# Pushing a semver tag runs this workflow, which force-moves the major alias
13+
# to the new tag and creates a GitHub release for it. To cut a release:
14+
#
15+
# git tag v1.2.0 && git push origin v1.2.0
16+
on:
17+
push:
18+
tags:
19+
- "v[0-9]+.[0-9]+.[0-9]+"
20+
21+
permissions:
22+
contents: write
23+
24+
jobs:
25+
release:
26+
runs-on: ubuntu-latest
27+
steps:
28+
- name: Checkout repository
29+
uses: actions/checkout@v7
30+
31+
- name: Move the major alias tag
32+
run: |
33+
set -euo pipefail
34+
TAG="${GITHUB_REF_NAME}"
35+
MAJOR="${TAG%%.*}" # v1.2.3 -> v1
36+
echo "Pointing ${MAJOR} at ${TAG}"
37+
git config user.name "github-actions[bot]"
38+
git config user.email "github-actions[bot]@users.noreply.github.com"
39+
git tag -f -a "${MAJOR}" -m "Point ${MAJOR} at ${TAG}" "${TAG}^{}"
40+
git push origin "${MAJOR}" --force
41+
42+
- name: Create GitHub release
43+
env:
44+
GH_TOKEN: ${{ github.token }}
45+
run: |
46+
set -euo pipefail
47+
TAG="${GITHUB_REF_NAME}"
48+
if gh release view "${TAG}" --repo "${GITHUB_REPOSITORY}" >/dev/null 2>&1; then
49+
echo "Release ${TAG} already exists; skipping."
50+
else
51+
gh release create "${TAG}" --repo "${GITHUB_REPOSITORY}" \
52+
--title "${TAG}" --generate-notes
53+
fi
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
name: "Test Facets Module CI Action"
2+
3+
# The module-ci-action drives the raptor CLI against a live Control Plane, so it
4+
# cannot be meaningfully exercised end-to-end without CP credentials + a module
5+
# fixture. This workflow instead validates the action definition itself: the
6+
# action.yml must parse, and every embedded bash script must pass shellcheck.
7+
on:
8+
push:
9+
branches:
10+
- master
11+
paths:
12+
- 'module-ci-action/**'
13+
pull_request:
14+
paths:
15+
- 'module-ci-action/**'
16+
workflow_dispatch:
17+
18+
jobs:
19+
lint-action:
20+
runs-on: ubuntu-latest
21+
steps:
22+
- name: Checkout Repository
23+
uses: actions/checkout@v7
24+
25+
- name: Install shellcheck
26+
run: sudo apt-get update && sudo apt-get install -y shellcheck
27+
28+
- name: Validate action.yml parses and shellcheck embedded scripts
29+
run: |
30+
python3 - <<'PY'
31+
import yaml, subprocess, tempfile, os, re, sys
32+
33+
action = "module-ci-action/action.yml"
34+
d = yaml.safe_load(open(action))
35+
steps = d["runs"]["steps"]
36+
print(f"action.yml parsed OK: {len(steps)} steps")
37+
38+
findings = 0
39+
for i, s in enumerate(steps):
40+
run = s.get("run")
41+
if not run:
42+
continue
43+
# ${{ ... }} GitHub expressions are substituted before the shell runs;
44+
# replace them with a token so shellcheck can parse the script.
45+
cleaned = re.sub(r"\$\{\{[^}]*\}\}", "GHEXPR", run)
46+
with tempfile.NamedTemporaryFile("w", suffix=".sh", delete=False) as f:
47+
f.write("#!/usr/bin/env bash\n")
48+
f.write(cleaned)
49+
path = f.name
50+
r = subprocess.run(["shellcheck", "-s", "bash", path],
51+
capture_output=True, text=True)
52+
if r.stdout.strip():
53+
findings += 1
54+
print(f"::group::shellcheck findings — step [{i}] {s.get('name')}")
55+
print(r.stdout)
56+
print("::endgroup::")
57+
os.unlink(path)
58+
59+
if findings:
60+
print(f"::error::{findings} step(s) have shellcheck findings")
61+
sys.exit(1)
62+
print("All embedded scripts pass shellcheck.")
63+
PY

.github/workflows/test-module-preview.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ jobs:
1616
runs-on: ubuntu-latest
1717
steps:
1818
- name: Checkout Repository
19-
uses: actions/checkout@v4
19+
uses: actions/checkout@v7
2020

2121
- name: Run Facets Module Preview Action (Local)
2222
uses: ./module-preview-action # Run from local repo

README.md

Lines changed: 30 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,41 @@
11
# Facets GitHub Actions
22

3+
Actions in this repo are consumed as `Facets-cloud/github-actions/<action>@<version>`.
4+
Releases are semver tags with a moving major alias (`v1` always points at the latest
5+
`v1.x.y`) — pin the major alias unless you need an exact version.
36

4-
## 1. Facets Module Preview & Security Scan
7+
## 1. Facets Module CI (current)
58

6-
The **Facets Module Preview & Security Scan** GitHub Action automates validation and security checks for Facets
7-
Terraform modules. It performs the following tasks:
9+
The **Facets Module CI** GitHub Action provides end-to-end CI for a Facets **modules
10+
repository** (`modules/{intent}/{flavor}/{version}`) using the **`raptor`** CLI. It runs
11+
in three modes, derived automatically from the triggering event:
12+
13+
[module-ci-action README](./module-ci-action/README.md).
14+
15+
- **Preview (on pull request)**: Validates each changed module, then registers an
16+
unpublishable feature-branch preview pinned to the PR head commit (optionally posts a
17+
PR comment).
18+
- **Publish (on push)**: Uploads and publishes each changed module (PREVIEW → PUBLISHED).
19+
- **Cleanup (on pull request close)**: Deletes each changed module's preview, but only
20+
the previews this PR's own commits created (ownership-checked).
21+
22+
This is the action the Facets control plane wires into bootstrapped modules
23+
repositories, and the recommended CI for all modules repos.
24+
25+
## 2. Facets Module Preview & Security Scan (deprecated)
26+
27+
> ⚠️ **Deprecated.** This action is based on the legacy `ftf` CLI and is kept only for
28+
> existing workflows that still use it. It receives no new features. New repos should
29+
> use [**module-ci-action**](./module-ci-action/README.md) above; to migrate, replace
30+
> the `module-preview-action` step with `module-ci-action` (raptor-based inputs — see
31+
> its README) and adopt the `modules/{intent}/{flavor}/{version}` layout.
32+
33+
The **Facets Module Preview & Security Scan** GitHub Action automates validation and
34+
security checks for Facets Terraform modules:
835

936
[module-preview-action README](./module-preview-action/README.md).
1037

1138
- **Terraform Formatting Checks**: Ensures Terraform files are formatted correctly.
1239
- **Terraform Validation**: Verifies the correctness of Terraform configurations.
1340
- **Checkov Security Scanning**: Identifies security vulnerabilities in Terraform code.
1441
- **Facets Module Preview**: Registers a Preview only module with your Facets Control Plane.
15-

module-ci-action/README.md

Lines changed: 195 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,195 @@
1+
# Facets Module CI GitHub Action
2+
3+
CI for a **Facets modules repository** — a repo whose Terraform IaC modules follow
4+
the Facets layout and are managed through the [`raptor`](https://github.com/Facets-cloud/raptor-releases)
5+
CLI (not `ftf`, not Python).
6+
7+
The action runs in one of three **modes**, normally derived automatically from the
8+
triggering event:
9+
10+
| Event | Mode | What it does |
11+
|-------|------|--------------|
12+
| `pull_request` opened / synchronize / reopened | **preview** | Validates each changed module, then registers a **feature-branch preview** pinned to the PR head commit. Optionally upserts a PR comment. |
13+
| `push` (to your default branch) | **publish** | Uploads each changed module and **publishes** it (PREVIEW → PUBLISHED). |
14+
| `pull_request` **closed without merge** | **cleanup** | Deletes the preview each changed module owns — **only if** the preview belongs to a commit from this PR. |
15+
| `pull_request` **closed by merge** | **no-op** | Skipped: the concurrent `push` (publish) run re-uploads and publishes the module at the merge commit, so it owns the slot. Running cleanup here would be redundant and could race the publish. |
16+
17+
> This action supersedes the **deprecated** `ftf`-based
18+
> [`module-preview-action`](../module-preview-action), which is kept only for
19+
> existing workflows.
20+
21+
## Modules-repo layout
22+
23+
Each module lives at a versioned path and contains a `facets.yaml` plus its Terraform:
24+
25+
```
26+
modules/
27+
<intent>/
28+
<flavor>/
29+
<version>/
30+
facets.yaml # intent:, flavor:, version: (+ inputs/outputs/spec)
31+
main.tf
32+
variables.tf
33+
outputs.tf
34+
```
35+
36+
The action discovers a module as **any directory under `modules/` that contains a
37+
`facets.yaml`**. `intent`, `flavor`, and `version` are read from that `facets.yaml`
38+
and form the module reference `intent/flavor/version` used by `raptor`.
39+
40+
If your modules tree is not at the repo root, set `path-prefix` (e.g. `infra/` when
41+
modules live at `infra/modules/...`).
42+
43+
## Requirements
44+
45+
- **Runner:** Ubuntu (the action installs Linux amd64 `raptor`, and — for preview/publish
46+
`terraform` and `trivy`, which raptor's module validation and security scan require;
47+
ubuntu-latest ships none of them). Versions are pinnable via the inputs below.
48+
- **raptor version:** preview and cleanup depend on newer `raptor` capabilities:
49+
- **preview** uses `raptor create iac-module --feature-branch --git-ref <sha>` — the
50+
`--feature-branch` flag marks the preview **unpublishable**, and `--git-ref` pins it
51+
to the PR head commit (required, since the PR checkout is a merge commit). Both must
52+
be present in the `raptor` release you install.
53+
- **cleanup** reads module git provenance (`gitRef` / `previewGitRef`) from
54+
`raptor get iac-module -o json` for its ownership check, and deletes with
55+
`raptor delete iac-module --stage PREVIEW`, which targets **only** the preview doc
56+
(so a module that has both a published and a preview version never loses its live
57+
published doc).
58+
59+
These require a `raptor` release that ships all of them (`--feature-branch`, `--git-ref`,
60+
row-level provenance in the list JSON, and `delete --stage`). Pin `raptor_version` if
61+
`latest` ever lags. Publish works on any recent `raptor`; cleanup safely no-ops when the
62+
provenance fields are absent.
63+
- **Trigger:** use `pull_request` (not `pull_request_target`). `pull_request_target`
64+
checks out the base ref, which would produce an empty changed-file diff and incorrect
65+
git provenance; the action rejects it with an explicit error.
66+
67+
## Inputs
68+
69+
| Input | Required | Default | Description |
70+
|-------|----------|---------|-------------|
71+
| `control_plane_url` | yes || Facets Control Plane URL. Pass the `CONTROL_PLANE_URL` secret. |
72+
| `username` | yes || Facets username. Pass the `FACETS_USERNAME` secret. |
73+
| `token` | yes || Facets API token. Pass the `FACETS_TOKEN` secret. |
74+
| `github_token` | no | `""` | GitHub token. Enables the PR **preview comment** and, in **cleanup**, reading the PR's commit SHAs for the ownership check. When absent, those are skipped (cleanup does nothing, safely). |
75+
| `raptor_version` | no | `latest` | `latest`, or an exact release tag such as `v1.2.3`. Ignored when `raptor-download-url` is set. |
76+
| `raptor-download-url` | no | `""` | Exact URL to download the raptor `linux-amd64` binary from, bypassing the default `Facets-cloud/raptor-releases` location. When set, `raptor_version` is ignored. An escape hatch for testing / pre-release raptor builds and enterprise mirrors. |
77+
| `terraform-version` | no | `1.5.7` | Terraform version installed (from `releases.hashicorp.com`) for raptor's module validation. Not installed in **cleanup** mode. |
78+
| `trivy-version` | no | `0.72.0` | Trivy version installed (from `aquasecurity/trivy` releases, no `v` prefix) for raptor's module security scan. Not installed in **cleanup** mode. |
79+
| `all-modules` | no | `false` | When `true`, operate on **every** module under `<path-prefix>modules/` instead of only the ones the event changed. |
80+
| `mode` | no | `auto` | `auto` \| `preview` \| `publish` \| `cleanup`. `auto` derives the mode from the event (see the table above). Set explicitly to override. |
81+
| `path-prefix` | no | `""` | Sub-path to the `modules/` tree relative to the repo root (e.g. `infra/`). Empty means `modules/` is at the root. |
82+
83+
### Secrets
84+
85+
These three secrets are exactly what the Control Plane bootstrap provisions for a
86+
modules repo. They are exported as the environment variables `raptor` reads directly
87+
(its highest-priority auth — there is no non-interactive `raptor login`):
88+
89+
| Secret | Env var | Purpose |
90+
|--------|---------|---------|
91+
| `CONTROL_PLANE_URL` | `CONTROL_PLANE_URL` | Control Plane base URL |
92+
| `FACETS_USERNAME` | `FACETS_USERNAME` | Username |
93+
| `FACETS_TOKEN` | `FACETS_TOKEN` | API token |
94+
95+
## Example workflow
96+
97+
A single workflow wiring up all three triggers:
98+
99+
```yaml
100+
name: Facets Module CI
101+
102+
on:
103+
pull_request:
104+
paths:
105+
- 'modules/**'
106+
types: [opened, synchronize, reopened, closed] # closed drives cleanup
107+
push:
108+
branches: [main]
109+
paths:
110+
- 'modules/**'
111+
112+
permissions:
113+
contents: read
114+
pull-requests: write # for the preview comment
115+
116+
jobs:
117+
module-ci:
118+
runs-on: ubuntu-latest
119+
steps:
120+
- name: Facets Module CI
121+
uses: Facets-cloud/github-actions/module-ci-action@v1
122+
with:
123+
control_plane_url: ${{ secrets.CONTROL_PLANE_URL }}
124+
username: ${{ secrets.FACETS_USERNAME }}
125+
token: ${{ secrets.FACETS_TOKEN }}
126+
github_token: ${{ secrets.GITHUB_TOKEN }}
127+
# raptor_version: v1.2.3 # pin if 'latest' lags the --feature-branch flag
128+
# path-prefix: infra/ # if modules live at infra/modules/...
129+
```
130+
131+
If you prefer separate workflow files, split the three triggers apart and let `mode`
132+
stay `auto`; the action derives `preview` / `publish` / `cleanup` from each event.
133+
134+
## Versioning
135+
136+
Reference this action by version, not by branch:
137+
138+
- **`@v1`** — the moving major alias; always points at the latest `v1.x.y` release.
139+
This is what the control plane's bootstrapped workflow uses and the right default.
140+
- **`@v1.2.3`** — an exact release, if you need to pin harder.
141+
142+
Releases are cut by pushing a semver tag (`git tag v1.2.0 && git push origin v1.2.0`);
143+
the repo's `release.yml` workflow then moves the major alias and publishes the GitHub
144+
release. Breaking changes to the action's inputs or behavior get a new major
145+
(`v2`, with a `v2` alias) — the `v1` alias never picks them up.
146+
147+
## Single-preview-slot semantics
148+
149+
Each module (`intent/flavor/version`) has exactly **one preview slot** on the Control
150+
Plane. When a PR previews a module, the action registers the preview against the PR's
151+
**head commit** (`--git-ref`), so the slot records which commit owns it.
152+
153+
- **Concurrent PRs on the same module:** last write wins. Whichever PR most recently
154+
ran preview owns the slot; an earlier PR's preview is overwritten.
155+
- **Merged PRs skip cleanup.** When a PR is closed **by merging**, the concurrent `push`
156+
run publishes the module at the merge commit and owns the slot, so cleanup is skipped
157+
entirely (it would be redundant and could race the publish). Cleanup runs only for PRs
158+
**closed without merging** (abandoned PRs), where the preview would otherwise be orphaned.
159+
- **Cleanup is ownership-checked.** On such a close, the action reads each module's owning
160+
commit from `raptor get iac-module -o json` and deletes the preview **only if** that
161+
commit is one of this PR's commit SHAs. If the slot was taken over by another branch,
162+
this PR leaves it untouched — it never deletes a preview owned by someone else.
163+
(Cleanup needs `github_token` to read the PR's commits; without it, cleanup is skipped.)
164+
Immediately before deleting, the action re-fetches the module and re-verifies the owning
165+
commit still matches, so a slot overwritten between the initial check and the delete is
166+
skipped. This narrows but does not fully eliminate the window: a **residual race**
167+
remains between that final re-verify and the delete call itself, so in a rare
168+
interleaving a preview another branch published in that instant could still be removed.
169+
170+
The owning-commit field depends on the module's stage, because the Control Plane
171+
stores preview provenance in two different places:
172+
173+
- a **brand-new, never-published** module *is* its own preview (stage `PREVIEW`) —
174+
its owning commit is the row's plain `gitRef`. This is the common PR case.
175+
- a **published** module that also has a live preview sibling (its `previewModuleId`
176+
is set) exposes the preview's commit as `previewGitRef` on the `PUBLISHED` row.
177+
- anything else has no preview slot, so cleanup skips it.
178+
179+
Reading only `previewGitRef` would miss every brand-new-module preview (where it is
180+
null), so the ownership check consults `gitRef` or `previewGitRef` by stage.
181+
182+
## Behavior details
183+
184+
- **Which modules:** by default only the modules changed by the event (PR: `base..HEAD`;
185+
push: `before..after`, with an all-zeros `before` falling back to the pushed commit).
186+
Set `all-modules: true` to process the whole tree.
187+
- **Validation gate (preview):** each module is first run through
188+
`raptor create iac-module -f <dir> --dry-run` (schema + Terraform + security checks)
189+
before the feature-branch registration.
190+
- **Provenance:** preview passes the PR head SHA explicitly because the PR checkout is a
191+
merge commit; publish relies on auto-detected provenance (on a push the checked-out
192+
`HEAD` *is* the pushed commit). The git remote URL is auto-detected from the work tree.
193+
- **Failure handling:** the action keeps going across modules and prints a summary of
194+
which passed and which failed, then **exits non-zero** if any module's validate /
195+
upload / publish / delete failed.

0 commit comments

Comments
 (0)