|
| 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