diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..fecd44d --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,9 @@ +version: 2 +updates: + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly + groups: + actions: + patterns: ["*"] diff --git a/.github/workflows/release.yml b/.github/workflows/version.yml similarity index 50% rename from .github/workflows/release.yml rename to .github/workflows/version.yml index fd354af..89671ec 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/version.yml @@ -1,27 +1,34 @@ -name: Release +name: Version + +# Reusable workflow that cuts a SemVer release with go-semantic-release. It owns +# the rc-vs-stable decision by detecting the repository's default branch, so +# callers cannot mis-trigger an rc on the default branch. It runs in its own job +# with `contents: write` (needed to push tags / the changelog), isolating that +# privilege from the rest of the pipeline. -# Reusable release pipeline. Generic and environment-agnostic: it runs -# go-semantic-release with the options it is given. It has no knowledge of any -# branch or environment. The calling repo decides, per branch, whether a run is -# a stable release or an rc prerelease. on: workflow_call: inputs: - maintained-version: - description: 'rc prerelease line passed to go-semantic-release (e.g. 1-rc). Empty produces a stable release.' + rc-line: + description: 'rc prerelease line used ONLY on non-default branches (e.g. 1-rc). Empty = stable on all branches.' type: string required: false default: '' - changelog: - description: 'Generate CHANGELOG.md and commit it back to the branch.' + changelog-on-default: + description: 'Generate + commit CHANGELOG.md on the default branch.' type: boolean required: false - default: false + default: true semrel-version: description: 'go-semantic-release binary version.' type: string required: false default: v2.31.0 + semrel-sha256: + description: 'Expected SHA-256 of the semantic-release linux_amd64 binary for semrel-version. Empty skips verification (logs a warning).' + type: string + required: false + default: '' outputs: version: description: 'The version produced, or empty if no release was cut.' @@ -37,28 +44,37 @@ jobs: version: ${{ steps.version.outputs.version }} steps: - name: Checkout (full history and tags) - uses: actions/checkout@v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: fetch-depth: 0 - name: Install go-semantic-release env: SEMREL_VERSION: ${{ inputs.semrel-version }} + SEMREL_SHA256: ${{ inputs.semrel-sha256 }} run: | - curl -sSL "https://github.com/go-semantic-release/semantic-release/releases/download/${SEMREL_VERSION}/semantic-release_${SEMREL_VERSION}_linux_amd64" -o ./semantic-release + url="https://github.com/go-semantic-release/semantic-release/releases/download/${SEMREL_VERSION}/semantic-release_${SEMREL_VERSION}_linux_amd64" + curl -sSL "$url" -o ./semantic-release + if [ -n "$SEMREL_SHA256" ]; then + echo "${SEMREL_SHA256} ./semantic-release" | sha256sum -c - + else + echo "::warning::semrel-sha256 not provided; skipping binary checksum verification" + fi chmod +x ./semantic-release - name: Release env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - RC_LINE: ${{ inputs.maintained-version }} - WITH_CHANGELOG: ${{ inputs.changelog }} + DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} + CURRENT_REF: ${{ github.ref_name }} + RC_LINE: ${{ inputs.rc-line }} + WITH_CHANGELOG: ${{ inputs.changelog-on-default }} run: | set -- --token "$GH_TOKEN" --version-file --allow-no-changes - if [ -n "$RC_LINE" ]; then + if [ "$CURRENT_REF" != "$DEFAULT_BRANCH" ] && [ -n "$RC_LINE" ]; then set -- "$@" --prerelease --maintained-version "$RC_LINE" fi - if [ "$WITH_CHANGELOG" = "true" ]; then + if [ "$CURRENT_REF" = "$DEFAULT_BRANCH" ] && [ "$WITH_CHANGELOG" = "true" ]; then set -- "$@" --changelog CHANGELOG.md --prepend-changelog fi ./semantic-release "$@" @@ -69,7 +85,7 @@ jobs: if [ -f .version ]; then echo "version=$(cat .version)" >> "$GITHUB_OUTPUT"; fi - name: Commit changelog - if: inputs.changelog + if: inputs.changelog-on-default env: BRANCH: ${{ github.ref_name }} run: | diff --git a/README.md b/README.md index fa60ee4..d236c3d 100644 --- a/README.md +++ b/README.md @@ -1,261 +1,131 @@ # ci-workflows -Reusable CI/CD building blocks for Nurdsoft projects. +Reusable, function-named CI/CD building blocks for Nurdsoft projects. -This repository is a shared, version-pinned library of GitHub Actions workflows -and composite actions. The goal is straightforward: any project, whatever its -shape or stack, should be able to assemble a complete, best-practice pipeline — -release, build, deploy, infrastructure, notifications — from pieces that are -written once, reviewed once, and maintained in one place. - -Keeping that logic here means individual repositories do not reinvent it, drift -apart, or quietly fall behind good practice. A consuming repository wires the -pieces together and supplies its own values; everything in this repository -stays generic and reusable. +Each action is named for the **pipeline function** it performs, not a language +or tool — so the implementation underneath is pluggable while the public +interface stays stable. The repo ships three concerns and keeps only the +generic two: it **provisions** (auth, toolchain install) and **orchestrates** +(artifacts, PR comments, notifications); the project-specific **commands** live +behind a runner contract you control (or pass inline via `run`). ## Contents -| Path | Type | Purpose | -| ---- | ---- | ------- | -| `.github/workflows/release.yml` | Reusable workflow | Tag a SemVer release | -| `actions/frontend-build` | Composite action | Build a frontend project and upload the output | -| `actions/frontend-deploy` | Composite action | Publish the build to a bucket and invalidate the CDN | -| `actions/backend-build` | Composite action | Build a container image (and optionally push) | -| `actions/backend-deploy` | Composite action | Deploy a container image to a managed service runtime | -| `actions/infra-plan` | Composite action | Plan infrastructure changes | -| `actions/infra-apply` | Composite action | Apply a saved infrastructure plan | -| `actions/mobile-checks` | Composite action | Install deps and run lint / type-check / tests for an Expo project | -| `actions/mobile-build` | Composite action | Build an Expo / React Native app with EAS Build | -| `actions/mobile-deploy` | Composite action | Submit an EAS build to the stores or publish an over-the-air update | -| `actions/announce` | Composite action | Post a deployment result to Slack | - -## `release.yml` — SemVer release pipeline +| Path | Type | Function | +|------|------|----------| +| `.github/workflows/version.yml` | Reusable workflow | Cut a SemVer release | +| `actions/auth` | Action | Obtain cloud credentials (OIDC) | +| `actions/setup` | Action | Install runtime + deps (+ EAS login) | +| `actions/verify` | Action | Lint / type-check / test | +| `actions/build` | Action | Produce a deployable artifact | +| `actions/deploy` | Action | Ship the artifact | +| `actions/plan` | Action | Preview an infrastructure change | +| `actions/apply` | Action | Apply an infrastructure change | +| `actions/notify` | Action | Post the pipeline result | -A generic, environment-agnostic reusable workflow that tags a commit with -Semantic Versioning via [go-semantic-release](https://github.com/go-semantic-release/semantic-release). -It does not build, test, or deploy, and it has no knowledge of any branch or -environment. The calling repo decides, per branch, what each run should do. +## Design -### Inputs +- **Function-named directories** — swap Node→Bun or GCP→AWS by changing inputs, + not the directory. +- **No forced Makefile** — phase actions take a `run` command (or a `runner`); + `make` is only the default our repos opt into (see [Runner contract](#runner-contract)). +- **Provision vs execute vs orchestrate** — credentials and tools are installed + here; the commands that use them live in the consumer. +- **Reusable workflow for release** — `version.yml` is a workflow (not an + action) because cutting a release needs its own `contents: write` job. -| Input | Required | Default | Purpose | -| -------------------- | -------- | ----------- | ------- | -| `maintained-version` | no | `''` | rc prerelease line (e.g. `1-rc`). Empty produces a stable release. | -| `changelog` | no | `false` | Generate `CHANGELOG.md` and commit it back to the branch. | -| `semrel-version` | no | `v2.31.0` | go-semantic-release binary version. | +## Usage -### Usage +Callers wire the actions into a job graph and supply their own values. Two +illustrative shapes: -The caller maps its own branches to release behavior. - -**Single release branch.** Every push to `main` cuts a stable release with a -changelog: +**App pipeline — verify, release, build, deploy** ```yaml -name: Release - -on: - push: - branches: [main] - jobs: - release: - uses: nurdsoft/ci-workflows/.github/workflows/release.yml@v1 - permissions: - contents: write - with: - changelog: true -``` - -**Release branch plus feature branches.** The release branch cuts stable -releases; feature branches cut rc prereleases with no changelog: - -```yaml -name: Release - -on: - push: - branches: [main, "feature/**"] + verify: + runs-on: ubuntu-latest + steps: + - uses: nurdsoft/ci-workflows/actions/verify@v2 -jobs: - release: - uses: nurdsoft/ci-workflows/.github/workflows/release.yml@v1 - permissions: - contents: write + version: + needs: [verify] + uses: nurdsoft/ci-workflows/.github/workflows/version.yml@v2 + permissions: { contents: write } with: - maintained-version: ${{ github.ref_name == 'main' && '' || '1-rc' }} - changelog: ${{ github.ref_name == 'main' }} -``` - -go-semantic-release plugin configuration (commit-analyzer rules, etc.) is read -from each repo's own `.semrelrc`. - -## Composite actions - -The composite actions are the steps of a release pipeline. Each plugs into one -job of the caller's workflow, so the caller keeps a flat, readable run graph and -owns the job DAG (`needs:` / `if:`), triggers and permissions. - -### `frontend-build` - -| Input | Required | Default | Purpose | -| ----- | -------- | ------- | ------- | -| `env` | yes | — | Target environment for the build. | -| `node-version` | no | `20` | Node.js version. | -| `app-version` | no | `''` | Version string injected into the build (as the env var named by `version-env-var`). | -| `version-env-var` | no | `NEXT_PUBLIC_APP_VERSION` | Name of the env var that receives `app-version`. | -| `build-output-path` | no | `out` | Build output directory uploaded as the artifact. | -| `artifact-name` | no | `build-output` | Name of the uploaded artifact. | -| `artifact-retention-days` | no | `7` | Artifact retention. | - -### `frontend-deploy` - -| Input | Required | Default | Purpose | -| ----- | -------- | ------- | ------- | -| `env` | yes | — | Target environment for the deploy. | -| `wif-provider` | no | `''` | GCP WIF provider resource name. Empty skips GCP auth. | -| `wif-service-account` | no | `''` | Service account impersonated via WIF. Required when `wif-provider` is set. | -| `aws-role-arn` | no | `''` | AWS IAM role to assume via OIDC. Empty skips AWS auth. | -| `aws-region` | no | `''` | AWS region for the assumed role. Required when `aws-role-arn` is set. | -| `artifact-name` | no | `build-output` | Artifact to download. | -| `build-output-path` | no | `out` | Local path the artifact is unpacked to. | -| `app-url` | no | `''` | Public URL shown in the run summary. | -| `app-version` | no | `''` | Version shown in the run summary. | - -The caller passes whichever cloud auth applies; the unused auth step is skipped automatically. - -### `backend-build` - -| Input | Required | Default | Purpose | -| ----- | -------- | ------- | ------- | -| `tags` | yes | — | Image tags to apply (one per line; each must be a full `registry/image:tag` URI). | -| `push` | no | `false` | Push the image after building. Set `false` for PR validation. | -| `registry` | no | `''` | Container registry hostname for docker login. Required when `push` is true. | -| `dockerfile` | no | `Dockerfile` | Path to the Dockerfile. | -| `context` | no | `.` | Build context directory. | -| `wif-provider` | no | `''` | GCP WIF provider resource name. Empty skips GCP auth. | -| `wif-service-account` | no | `''` | Service account impersonated via WIF. Required when `wif-provider` is set. | -| `aws-role-arn` | no | `''` | AWS IAM role to assume via OIDC. Empty skips AWS auth. | -| `aws-region` | no | `''` | AWS region for the assumed role. | - -### `backend-deploy` - -| Input | Required | Default | Purpose | -| ----- | -------- | ------- | ------- | -| `image` | yes | — | Full image URI to deploy, including tag (e.g. `registry/image:1.2.3`). | -| `service-name` | yes | — | Name of the target service. | -| `region` | yes | — | Region the service runs in. | -| `env-secret` | no | `''` | Full resource path of a Secret Manager secret to load as runtime env vars. Empty skips the fetch. | -| `vpc-connector` | no | `''` | VPC connector name to attach to the service. Empty skips. | -| `extra-flags` | no | `''` | Additional deploy flags (one per line). | -| `env-vars-update-strategy` | no | `overwrite` | Strategy for updating env vars (`merge` or `overwrite`). | -| `wif-provider` | no | `''` | GCP WIF provider resource name. | -| `wif-service-account` | no | `''` | Service account impersonated via WIF. Required when `wif-provider` is set. | - -Currently targets Google Cloud Run. Other runtimes can be added behind the same action later. - -### `infra-plan` - -| Input | Required | Default | Purpose | -| ----- | -------- | ------- | ------- | -| `env` | yes | — | Target environment for the Terraform run. | -| `target` | no | `app` | Terraform target directory under `deploy/`. | -| `tf-version` | no | `1.x` | Terraform version. | -| `wif-provider` | no | `''` | GCP WIF provider resource name. Empty skips GCP auth. | -| `wif-service-account` | no | `''` | Service account impersonated via WIF. Required when `wif-provider` is set. | -| `aws-role-arn` | no | `''` | IAM role to assume via OIDC. Empty skips AWS auth. | -| `aws-region` | no | `''` | Region for the assumed role. | -| `github-token` | no | `''` | Token to post the plan as a PR comment. Empty skips the comment. | -| `plan-artifact-name` | no | `terraform-plan` | Name of the uploaded plan artifact. | -| `plan-extra-paths` | no | `''` | Extra paths (one per line) to ship alongside `plan.tfplan` — e.g. a Lambda module's `builds/` directory. | - -On a pull request the plan is posted as a sticky PR comment. On a push the plan -file is uploaded as an artifact for the apply action to consume in the same run. - -### `infra-apply` + rc-line: "1-rc" # rc off non-default branches; stable on default -| Input | Required | Default | Purpose | -| ----- | -------- | ------- | ------- | -| `env` | yes | — | Target environment for the Terraform run. | -| `target` | no | `app` | Terraform target directory under `deploy/`. | -| `tf-version` | no | `1.x` | Terraform version. | -| `wif-provider` | no | `''` | GCP WIF provider resource name. Empty skips GCP auth. | -| `wif-service-account` | no | `''` | Service account impersonated via WIF. Required when `wif-provider` is set. | -| `aws-role-arn` | no | `''` | IAM role to assume via OIDC. Empty skips AWS auth. | -| `aws-region` | no | `''` | Region for the assumed role. | -| `plan-artifact-name` | no | `terraform-plan` | Plan artifact to download. Must match `infra-plan`. | - -### `mobile-checks` - -| Input | Required | Default | Purpose | -| ----- | -------- | ------- | ------- | -| `node-version` | no | `20` | Node.js version. | - -Runs `npm ci`, then `tsc --noEmit`, the `lint` script and the `test` script. Lint and test run only if the project defines those scripts (`npm run … --if-present`), and all three checks are informational — they never fail the job. Tests are forced into non-interactive CI mode so a watch-mode default can't hang. - -### `mobile-build` - -| Input | Required | Default | Purpose | -| ----- | -------- | ------- | ------- | -| `expo-token` | yes | — | Expo access token with access to the project. | -| `profile` | yes | — | EAS build profile from `eas.json` (e.g. `production`, `preview`). | -| `platform` | no | `all` | Target platform: `android`, `ios`, or `all`. | -| `node-version` | no | `20` | Node.js version. | -| `wait` | no | `true` | Wait for the build to finish. Set `false` to queue and return immediately. | - -Runs `eas build` for the given profile and platform. Waits by default so a later `mobile-deploy` (submit) step can pick up the finished build. - -### `mobile-deploy` - -| Input | Required | Default | Purpose | -| ----- | -------- | ------- | ------- | -| `expo-token` | yes | — | Expo access token with access to the project. | -| `mode` | yes | — | `submit` (store submission) or `update` (over-the-air update). | -| `profile` | no | `production` | EAS profile for `submit` mode. | -| `platform` | no | `all` | Platform for `submit` mode: `android`, `ios`, or `all`. | -| `branch` | no | `''` | EAS Update branch for `update` mode (required in that mode). | -| `message` | no | `''` | Update message for `update` mode. Defaults to the commit SHA. | -| `node-version` | no | `20` | Node.js version. | - -In `submit` mode runs `eas submit --latest` to send the most recent build to the stores. In `update` mode runs `eas update` to publish an over-the-air JS/asset update to the given channel. - -### `announce` - -| Input | Required | Default | Purpose | -| ----- | -------- | ------- | ------- | -| `result` | yes | — | Deploy result (`success` / `failure`). | -| `webhook-url` | no | `''` | Slack incoming webhook. Empty makes the action a no-op. | -| `label` | no | `Deployment` | Short label for the message. | -| `version` | no | `''` | Version shown in the message. | - -Best-effort: a missing webhook never fails the pipeline. + build: + needs: [version] + runs-on: ubuntu-latest + steps: + - uses: nurdsoft/ci-workflows/actions/build@v2 + with: + run: # or rely on `make build` + output: artifact -### Usage + deploy: + needs: [build] + runs-on: ubuntu-latest + steps: + - uses: nurdsoft/ci-workflows/actions/deploy@v2 + with: + run: # or rely on `make deploy` + download-artifact: "true" + gcp-wif-provider: ${{ secrets.WIF_PROVIDER }} + gcp-service-account: ${{ secrets.SERVICE_ACCOUNT }} +``` -Each action is referenced as a single step of a job. Project-specific values -are supplied by the caller (often from its own `env:` block or secrets): +**Infrastructure pipeline — plan then apply the same plan** ```yaml jobs: - build: + plan: runs-on: ubuntu-latest steps: - - uses: nurdsoft/ci-workflows/actions/frontend-build@v1 + - uses: nurdsoft/ci-workflows/actions/plan@v2 with: env: - app-version: ${{ needs.version.outputs.version }} + aws-role-arn: ${{ secrets.AWS_ROLE_ARN }} + aws-region: ${{ secrets.AWS_REGION }} + github-token: ${{ secrets.GITHUB_TOKEN }} + + apply: + needs: [plan] + runs-on: ubuntu-latest + steps: + - uses: nurdsoft/ci-workflows/actions/apply@v2 + with: + env: + aws-role-arn: ${{ secrets.AWS_ROLE_ARN }} + aws-region: ${{ secrets.AWS_REGION }} ``` +## Runner contract + +Phase actions (`build`, `deploy`, `plan`, `apply`) run a command — supply it any +of three ways: pass `run`/`run-*` directly (no Makefile), set `runner` to your +tool (`just`, `task`, `npm run`), or implement the default `make` targets. + +| Phase | Default command(s) | Env provided | +|--------|-------------------------------------|-------------------| +| build | `make build` | ENV, APP_VERSION* | +| deploy | `make deploy` | ENV | +| plan | `make tf-init`, `make tf-plan` (+ `tf-fmt`/`tf-validate` for terraform) | ENV, TARGET | +| apply | `make tf-init`, `make tf-apply` | ENV, TARGET | + +\* `build` exports `APP_VERSION` under the name given by `version-env-var`. + +Self-contained — no contract, no Makefile: `auth`, `setup`, `verify`, `notify`, +and the `version.yml` reusable workflow. + ## Versioning -Callers pin to the major tag (`@v1`) for both the reusable workflow and the -composite actions. That tag is moved on each non-breaking release so updates -reach consumers deliberately. A breaking change is published under a new major -tag, leaving `@v1` in place for un-migrated callers. +Pin to the major tag (`@v2`). Breaking changes ship under a new major; the +previous major stays in place for un-migrated callers. Third-party actions are +SHA-pinned and bumped by Dependabot. ## Contributing -Contributions are welcome. See [CONTRIBUTING.md](CONTRIBUTING.md) for the full -flow. In short: open a pull request with a Conventional Commit title, keep every -change generic and backward-compatible, and validate it against a real consumer -before merge. +See [CONTRIBUTING.md](CONTRIBUTING.md). PRs are linted with `actionlint` (and +shellcheck over composite `run:` blocks) via `.github/workflows/ci.yml`. diff --git a/actions/apply/action.yml b/actions/apply/action.yml new file mode 100644 index 0000000..cdf26e1 --- /dev/null +++ b/actions/apply/action.yml @@ -0,0 +1,113 @@ +name: Apply +description: >- + Apply the saved plan produced by the plan action in the same run, so what is + applied is exactly what was planned. Installs the IaC tool, authenticates, + downloads the plan artifact, and applies via the runner contract (or override). + +inputs: + run-init: + description: Explicit init command. Empty falls back to " tf-init". + required: false + default: "" + run-apply: + description: Explicit apply command. Empty falls back to " tf-apply". + required: false + default: "" + runner: + description: Task runner for the default contract targets. + required: false + default: make + env: + description: Target environment. + required: true + target: + description: Target directory under deploy/. + required: false + default: app + tool: + description: "IaC tool to install: terraform | none." + required: false + default: terraform + tf-version: + description: Terraform version (when tool is terraform). + required: false + default: "1.x" + gcp-wif-provider: + description: GCP WIF provider. Empty skips GCP auth. + required: false + default: "" + gcp-service-account: + description: Service account impersonated via WIF. + required: false + default: "" + aws-role-arn: + description: AWS role to assume via OIDC. Empty skips AWS auth. + required: false + default: "" + aws-region: + description: AWS region for the assumed role. + required: false + default: "" + plan-artifact-name: + description: Plan artifact to download (must match plan). + required: false + default: terraform-plan + +runs: + using: composite + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Setup Terraform + if: inputs.tool == 'terraform' + uses: hashicorp/setup-terraform@dfe3c3f87815947d99a8997f908cb6525fc44e9e # v4.0.1 + with: + terraform_version: ${{ inputs.tf-version }} + terraform_wrapper: true + + - name: Authenticate + if: inputs.gcp-wif-provider != '' || inputs.aws-role-arn != '' + uses: nurdsoft/ci-workflows/actions/auth@v2 + with: + gcp-wif-provider: ${{ inputs.gcp-wif-provider }} + gcp-service-account: ${{ inputs.gcp-service-account }} + aws-role-arn: ${{ inputs.aws-role-arn }} + aws-region: ${{ inputs.aws-region }} + + - name: Download plan artifact + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: ${{ inputs.plan-artifact-name }} + path: deploy/${{ inputs.target }} + + - name: Init + shell: bash + env: + RUN_INIT: ${{ inputs.run-init }} + RUNNER: ${{ inputs.runner }} + ENV: ${{ inputs.env }} + TARGET: ${{ inputs.target }} + run: | + if [ -n "$RUN_INIT" ]; then bash -c "$RUN_INIT"; else $RUNNER tf-init ENV="$ENV" TARGET="$TARGET"; fi + + - name: Apply + shell: bash + env: + RUN_APPLY: ${{ inputs.run-apply }} + RUNNER: ${{ inputs.runner }} + ENV: ${{ inputs.env }} + TARGET: ${{ inputs.target }} + run: | + if [ -n "$RUN_APPLY" ]; then bash -c "$RUN_APPLY"; else $RUNNER tf-apply ENV="$ENV" TARGET="$TARGET"; fi + + - name: Summary + shell: bash + env: + TARGET: ${{ inputs.target }} + SHA: ${{ github.sha }} + run: | + { + echo "### Infrastructure applied — \`deploy/$TARGET\`" + echo "" + echo "- Commit: \`$SHA\`" + } >> "$GITHUB_STEP_SUMMARY" diff --git a/actions/auth/action.yml b/actions/auth/action.yml new file mode 100644 index 0000000..081d8ac --- /dev/null +++ b/actions/auth/action.yml @@ -0,0 +1,48 @@ +name: Auth +description: >- + Obtain cloud credentials via keyless OIDC. Pass only the provider you use; the + others are skipped. Credentials persist in the job environment for later steps + and any CLI they invoke. + +inputs: + gcp-wif-provider: + description: GCP Workload Identity Federation provider resource name. Empty skips GCP. + required: false + default: "" + gcp-service-account: + description: Service account impersonated via WIF. Required when gcp-wif-provider is set. + required: false + default: "" + setup-gcloud: + description: Install the gcloud CLI after authenticating. + required: false + default: "true" + aws-role-arn: + description: AWS IAM role to assume via OIDC. Empty skips AWS. + required: false + default: "" + aws-region: + description: AWS region for the assumed role. Required when aws-role-arn is set. + required: false + default: "" + +runs: + using: composite + steps: + - name: Authenticate to Google Cloud + if: inputs.gcp-wif-provider != '' + uses: google-github-actions/auth@7c6bc770dae815cd3e89ee6cdf493a5fab2cc093 # v3 + with: + workload_identity_provider: ${{ inputs.gcp-wif-provider }} + service_account: ${{ inputs.gcp-service-account }} + + - name: Setup gcloud + if: inputs.gcp-wif-provider != '' && inputs.setup-gcloud == 'true' + uses: google-github-actions/setup-gcloud@aa5489c8933f4cc7a4f7d45035b3b1440c9c10db # v3.0.1 + + - name: Authenticate to AWS + if: inputs.aws-role-arn != '' + uses: aws-actions/configure-aws-credentials@d979d5b3a71173a29b74b5b88418bfda9437d885 # v6.1.1 + with: + role-to-assume: ${{ inputs.aws-role-arn }} + aws-region: ${{ inputs.aws-region }} diff --git a/actions/backend-build/action.yml b/actions/backend-build/action.yml deleted file mode 100644 index 885c8ac..0000000 --- a/actions/backend-build/action.yml +++ /dev/null @@ -1,105 +0,0 @@ -name: Backend Build -description: >- - Build a container image and, optionally, push it to a registry. Authenticates - to Google Cloud and/or AWS via keyless OIDC and configures docker for the - registry before push. The build itself is wrapped around - docker/build-push-action. - -inputs: - tags: - description: Image tags to apply (one per line; each must be a full `registry/image:tag` URI). - required: true - push: - description: Push the image after building. Set false for PR validation. - required: false - default: "false" - registry: - description: Container registry hostname for docker login (e.g. `us-central1-docker.pkg.dev`). Required when push is true. - required: false - default: "" - dockerfile: - description: Path to the Dockerfile. - required: false - default: Dockerfile - context: - description: Build context directory. - required: false - default: . - wif-provider: - description: Google Cloud Workload Identity Federation provider resource name. Leave empty to skip GCP authentication. - required: false - default: "" - wif-service-account: - description: Service account email impersonated via WIF. Required when wif-provider is set. - required: false - default: "" - aws-role-arn: - description: AWS IAM role ARN to assume via OIDC. Leave empty to skip AWS authentication. - required: false - default: "" - aws-region: - description: AWS region for the assumed role. - required: false - default: "" - -runs: - using: composite - steps: - - uses: actions/checkout@v6 - - - uses: docker/setup-buildx-action@v3 - - - name: Authenticate to Google Cloud - if: inputs.wif-provider != '' - uses: google-github-actions/auth@v3 - with: - workload_identity_provider: ${{ inputs.wif-provider }} - service_account: ${{ inputs.wif-service-account }} - - - name: Setup gcloud - if: inputs.wif-provider != '' - uses: google-github-actions/setup-gcloud@v3 - - - name: Authenticate to AWS - if: inputs.aws-role-arn != '' - uses: aws-actions/configure-aws-credentials@v6 - with: - role-to-assume: ${{ inputs.aws-role-arn }} - aws-region: ${{ inputs.aws-region }} - - - name: Docker login (Google Artifact Registry) - if: inputs.push == 'true' && inputs.wif-provider != '' && inputs.registry != '' - shell: bash - run: gcloud auth configure-docker ${{ inputs.registry }} --quiet - - - name: Docker login (AWS ECR) - if: inputs.push == 'true' && inputs.aws-role-arn != '' && inputs.registry != '' - shell: bash - env: - REGION: ${{ inputs.aws-region }} - REGISTRY: ${{ inputs.registry }} - run: aws ecr get-login-password --region "$REGION" | docker login --username AWS --password-stdin "$REGISTRY" - - - name: Build and (optionally) push image - uses: docker/build-push-action@v5 - with: - context: ${{ inputs.context }} - file: ${{ inputs.dockerfile }} - push: ${{ inputs.push }} - tags: ${{ inputs.tags }} - - - name: Build summary - shell: bash - env: - PUSHED: ${{ inputs.push }} - TAGS: ${{ inputs.tags }} - run: | - echo "### Image built" >> "$GITHUB_STEP_SUMMARY" - echo "" >> "$GITHUB_STEP_SUMMARY" - echo "- Pushed: \`$PUSHED\`" >> "$GITHUB_STEP_SUMMARY" - echo "- Commit: \`${{ github.sha }}\`" >> "$GITHUB_STEP_SUMMARY" - echo "" >> "$GITHUB_STEP_SUMMARY" - echo "Tags:" >> "$GITHUB_STEP_SUMMARY" - printf '%s\n' "$TAGS" | while IFS= read -r t; do - if [ -n "$t" ]; then echo "- \`$t\`" >> "$GITHUB_STEP_SUMMARY"; fi - done diff --git a/actions/backend-deploy/action.yml b/actions/backend-deploy/action.yml deleted file mode 100644 index 4049e6b..0000000 --- a/actions/backend-deploy/action.yml +++ /dev/null @@ -1,120 +0,0 @@ -name: Backend Deploy -description: >- - Deploy a pre-built container image to a managed service runtime. - Authenticates to Google Cloud via keyless OIDC and, optionally, loads - runtime environment variables from a Secret Manager secret. Currently - targets Google Cloud Run; the inputs are generic enough to add other - runtimes behind the same action later. - -inputs: - image: - description: Full image URI to deploy, including tag (e.g. `registry/image:1.2.3`). - required: true - service-name: - description: Name of the target service (e.g. Cloud Run service name). - required: true - region: - description: Region the service runs in. - required: true - env-secret: - description: Full resource path of a Secret Manager secret to load as runtime env vars (e.g. `projects//secrets//versions/latest`). Leave empty to skip. - required: false - default: "" - vpc-connector: - description: VPC connector name to attach to the service. Leave empty to skip. - required: false - default: "" - extra-flags: - description: Additional flags to pass to the deploy (one per line). - required: false - default: "" - env-vars-update-strategy: - description: Strategy for updating environment variables (`merge` or `overwrite`). - required: false - default: overwrite - wif-provider: - description: Google Cloud Workload Identity Federation provider resource name. - required: false - default: "" - wif-service-account: - description: Service account email impersonated via WIF. Required when wif-provider is set. - required: false - default: "" - aws-role-arn: - description: AWS IAM role ARN. Reserved for future runtime support. - required: false - default: "" - aws-region: - description: AWS region. Reserved for future runtime support. - required: false - default: "" - -runs: - using: composite - steps: - - name: Authenticate to Google Cloud - if: inputs.wif-provider != '' - uses: google-github-actions/auth@v3 - with: - workload_identity_provider: ${{ inputs.wif-provider }} - service_account: ${{ inputs.wif-service-account }} - - - name: Setup gcloud - if: inputs.wif-provider != '' - uses: google-github-actions/setup-gcloud@v3 - - - name: Authenticate to AWS - if: inputs.aws-role-arn != '' - uses: aws-actions/configure-aws-credentials@v6 - with: - role-to-assume: ${{ inputs.aws-role-arn }} - aws-region: ${{ inputs.aws-region }} - - - name: Fetch runtime env vars from Secret Manager - id: env-secret - if: inputs.env-secret != '' - uses: google-github-actions/get-secretmanager-secrets@v2 - with: - secrets: | - ENV_VARS:${{ inputs.env-secret }} - - - name: Format env vars for the deploy - id: format-env - if: inputs.env-secret != '' - shell: bash - env: - SECRETS_JSON: ${{ steps.env-secret.outputs.ENV_VARS }} - run: | - if [ -z "$SECRETS_JSON" ] || [ "$SECRETS_JSON" = "null" ]; then - echo "::error::Failed to fetch env vars from secret" - exit 1 - fi - printf '%s' "$SECRETS_JSON" | jq -r 'to_entries[] | "::add-mask::\(.value)"' - formatted=$(printf '%s' "$SECRETS_JSON" | jq -r 'to_entries[] | "\(.key)=\(.value)"' | paste -sd, -) - echo "vars=$formatted" >> "$GITHUB_OUTPUT" - - - name: Deploy to Cloud Run - uses: google-github-actions/deploy-cloudrun@v2 - with: - service: ${{ inputs.service-name }} - image: ${{ inputs.image }} - region: ${{ inputs.region }} - flags: | - ${{ inputs.vpc-connector != '' && format('--vpc-connector={0}', inputs.vpc-connector) || '' }} - ${{ inputs.extra-flags }} - env_vars: ${{ steps.format-env.outputs.vars }} - env_vars_update_strategy: ${{ inputs.env-vars-update-strategy }} - - - name: Deploy summary - shell: bash - env: - IMAGE: ${{ inputs.image }} - SERVICE: ${{ inputs.service-name }} - REGION: ${{ inputs.region }} - run: | - echo "### Service deployed" >> "$GITHUB_STEP_SUMMARY" - echo "" >> "$GITHUB_STEP_SUMMARY" - echo "- Service: \`$SERVICE\`" >> "$GITHUB_STEP_SUMMARY" - echo "- Region: \`$REGION\`" >> "$GITHUB_STEP_SUMMARY" - echo "- Image: \`$IMAGE\`" >> "$GITHUB_STEP_SUMMARY" - echo "- Commit: \`${{ github.sha }}\`" >> "$GITHUB_STEP_SUMMARY" diff --git a/actions/build/action.yml b/actions/build/action.yml new file mode 100644 index 0000000..75e5fce --- /dev/null +++ b/actions/build/action.yml @@ -0,0 +1,128 @@ +name: Build +description: >- + Produce a deployable artifact. Runs `run` if given, otherwise ` build` + (default make) — so no Makefile is required when `run` is supplied. Optionally + prepares a Node/Expo toolchain and authenticates to a cloud (for registry + pushes performed by the build command). Can upload the output as an artifact. + +inputs: + run: + description: Explicit build command. Empty falls back to " build". + required: false + default: "" + runner: + description: Task runner for the default contract target. + required: false + default: make + env: + description: Target environment, passed to the build as ENV. + required: false + default: "" + node-version: + description: If set, install the Node/Expo toolchain before building. + required: false + default: "" + expo-token: + description: Expo token, forwarded to setup for EAS builds. + required: false + default: "" + app-version: + description: Version string injected into the build as version-env-var. + required: false + default: "" + version-env-var: + description: Name of the env var that receives app-version. + required: false + default: "" + gcp-wif-provider: + description: GCP WIF provider. Empty skips GCP auth. + required: false + default: "" + gcp-service-account: + description: Service account impersonated via WIF. + required: false + default: "" + aws-role-arn: + description: AWS role to assume via OIDC. Empty skips AWS auth. + required: false + default: "" + aws-region: + description: AWS region for the assumed role. + required: false + default: "" + output: + description: "Artifact handling: artifact | none." + required: false + default: none + artifact-name: + description: Name of the uploaded artifact (when output is artifact). + required: false + default: build-output + build-output-path: + description: Directory uploaded as the artifact. + required: false + default: out + artifact-retention-days: + description: Artifact retention. + required: false + default: "7" + +runs: + using: composite + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Setup toolchain + if: inputs.node-version != '' + uses: nurdsoft/ci-workflows/actions/setup@v2 + with: + node-version: ${{ inputs.node-version }} + expo-token: ${{ inputs.expo-token }} + + - name: Authenticate + if: inputs.gcp-wif-provider != '' || inputs.aws-role-arn != '' + uses: nurdsoft/ci-workflows/actions/auth@v2 + with: + gcp-wif-provider: ${{ inputs.gcp-wif-provider }} + gcp-service-account: ${{ inputs.gcp-service-account }} + aws-role-arn: ${{ inputs.aws-role-arn }} + aws-region: ${{ inputs.aws-region }} + + - name: Build + shell: bash + env: + USER_RUN: ${{ inputs.run }} + RUNNER: ${{ inputs.runner }} + ENV: ${{ inputs.env }} + APP_VERSION: ${{ inputs.app-version }} + APP_VERSION_VAR: ${{ inputs.version-env-var }} + run: | + if [ -n "$APP_VERSION_VAR" ]; then export "$APP_VERSION_VAR=$APP_VERSION"; fi + if [ -n "$USER_RUN" ]; then + bash -c "$USER_RUN" + else + $RUNNER build ENV="$ENV" + fi + + - name: Upload artifact + if: inputs.output == 'artifact' + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: ${{ inputs.artifact-name }} + path: ${{ inputs.build-output-path }} + retention-days: ${{ inputs.artifact-retention-days }} + + - name: Summary + shell: bash + env: + ENV: ${{ inputs.env }} + OUTPUT: ${{ inputs.output }} + SHA: ${{ github.sha }} + run: | + { + echo "### Build" + echo "" + echo "- Env: \`${ENV:-n/a}\`" + echo "- Output: \`$OUTPUT\`" + echo "- Commit: \`$SHA\`" + } >> "$GITHUB_STEP_SUMMARY" diff --git a/actions/deploy/action.yml b/actions/deploy/action.yml new file mode 100644 index 0000000..fddff28 --- /dev/null +++ b/actions/deploy/action.yml @@ -0,0 +1,109 @@ +name: Deploy +description: >- + Ship the build. Runs `run` if given, otherwise ` deploy` (default make) + — no Makefile required when `run` is supplied. Optionally downloads a build + artifact, prepares a Node/Expo toolchain, and authenticates to a cloud. + +inputs: + run: + description: Explicit deploy command. Empty falls back to " deploy". + required: false + default: "" + runner: + description: Task runner for the default contract target. + required: false + default: make + env: + description: Target environment, passed to the deploy as ENV. + required: false + default: "" + node-version: + description: If set, install the Node/Expo toolchain before deploying. + required: false + default: "" + expo-token: + description: Expo token, forwarded to setup for EAS submit/update. + required: false + default: "" + download-artifact: + description: Download a build artifact before deploying. + required: false + default: "false" + artifact-name: + description: Artifact to download. + required: false + default: build-output + build-output-path: + description: Local path the artifact is unpacked to. + required: false + default: out + gcp-wif-provider: + description: GCP WIF provider. Empty skips GCP auth. + required: false + default: "" + gcp-service-account: + description: Service account impersonated via WIF. + required: false + default: "" + aws-role-arn: + description: AWS role to assume via OIDC. Empty skips AWS auth. + required: false + default: "" + aws-region: + description: AWS region for the assumed role. + required: false + default: "" + +runs: + using: composite + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Download artifact + if: inputs.download-artifact == 'true' + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: ${{ inputs.artifact-name }} + path: ${{ inputs.build-output-path }} + + - name: Setup toolchain + if: inputs.node-version != '' + uses: nurdsoft/ci-workflows/actions/setup@v2 + with: + node-version: ${{ inputs.node-version }} + expo-token: ${{ inputs.expo-token }} + + - name: Authenticate + if: inputs.gcp-wif-provider != '' || inputs.aws-role-arn != '' + uses: nurdsoft/ci-workflows/actions/auth@v2 + with: + gcp-wif-provider: ${{ inputs.gcp-wif-provider }} + gcp-service-account: ${{ inputs.gcp-service-account }} + aws-role-arn: ${{ inputs.aws-role-arn }} + aws-region: ${{ inputs.aws-region }} + + - name: Deploy + shell: bash + env: + USER_RUN: ${{ inputs.run }} + RUNNER: ${{ inputs.runner }} + ENV: ${{ inputs.env }} + run: | + if [ -n "$USER_RUN" ]; then + bash -c "$USER_RUN" + else + $RUNNER deploy ENV="$ENV" + fi + + - name: Summary + shell: bash + env: + ENV: ${{ inputs.env }} + SHA: ${{ github.sha }} + run: | + { + echo "### Deploy" + echo "" + echo "- Env: \`${ENV:-n/a}\`" + echo "- Commit: \`$SHA\`" + } >> "$GITHUB_STEP_SUMMARY" diff --git a/actions/frontend-build/action.yml b/actions/frontend-build/action.yml deleted file mode 100644 index 0c14e3f..0000000 --- a/actions/frontend-build/action.yml +++ /dev/null @@ -1,77 +0,0 @@ -name: Build -description: >- - Install dependencies, lint, type-check and build the project, then upload - the build output as an artifact. Lint and type-check are informational and do - not fail the build. - -inputs: - env: - description: Target environment for the build. - required: true - node-version: - description: Node.js version used for the build. - required: false - default: "20" - app-version: - description: Version string injected into the build as the value of `version-env-var`. - required: false - default: "" - version-env-var: - description: Name of the environment variable that receives `app-version` during the build. - required: false - default: NEXT_PUBLIC_APP_VERSION - build-output-path: - description: Path of the build output directory uploaded as the artifact. - required: false - default: out - artifact-name: - description: Name of the uploaded build-output artifact. - required: false - default: build-output - artifact-retention-days: - description: Retention period, in days, for the build-output artifact. - required: false - default: "7" - -runs: - using: composite - steps: - - uses: actions/checkout@v6 - - - uses: actions/setup-node@v6 - with: - node-version: ${{ inputs.node-version }} - cache: npm - - - name: Install - shell: bash - run: make install - - - name: Lint - shell: bash - run: make lint - continue-on-error: true - - - name: Type-check - shell: bash - run: make typecheck - continue-on-error: true - - - name: Build - shell: bash - env: - APP_VERSION_VAR: ${{ inputs.version-env-var }} - APP_VERSION_VAL: ${{ inputs.app-version }} - ENV: ${{ inputs.env }} - run: | - if [ -n "$APP_VERSION_VAR" ]; then - export "$APP_VERSION_VAR=$APP_VERSION_VAL" - fi - make build ENV="$ENV" - - - name: Upload build artifact - uses: actions/upload-artifact@v7 - with: - name: ${{ inputs.artifact-name }} - path: ${{ inputs.build-output-path }} - retention-days: ${{ inputs.artifact-retention-days }} diff --git a/actions/frontend-deploy/action.yml b/actions/frontend-deploy/action.yml deleted file mode 100644 index dddcd51..0000000 --- a/actions/frontend-deploy/action.yml +++ /dev/null @@ -1,94 +0,0 @@ -name: Deploy -description: >- - Download a build-output artifact and publish it, then invalidate the CDN. - Authenticates to Google Cloud and/or AWS via keyless OIDC; the caller passes - whichever auth applies and the unused auth step is skipped. - -inputs: - env: - description: Target environment for the deploy. - required: true - wif-provider: - description: Google Cloud Workload Identity Federation provider resource name. Leave empty to skip GCP authentication. - required: false - default: "" - wif-service-account: - description: Service account email impersonated via Workload Identity Federation. Required when wif-provider is set. - required: false - default: "" - aws-role-arn: - description: AWS IAM role ARN to assume via OIDC. Leave empty to skip AWS authentication. - required: false - default: "" - aws-region: - description: AWS region for the assumed role. Required when aws-role-arn is set. - required: false - default: "" - artifact-name: - description: Name of the build-output artifact to download. - required: false - default: build-output - build-output-path: - description: Local path the artifact is unpacked to. - required: false - default: out - app-url: - description: Public URL of the deployed app, shown in the run summary. Optional. - required: false - default: "" - app-version: - description: Version string shown in the run summary. Optional. - required: false - default: "" - -runs: - using: composite - steps: - - uses: actions/checkout@v6 - - - name: Download build artifact - uses: actions/download-artifact@v8 - with: - name: ${{ inputs.artifact-name }} - path: ${{ inputs.build-output-path }} - - - name: Authenticate to Google Cloud - if: inputs.wif-provider != '' - uses: google-github-actions/auth@v3 - with: - workload_identity_provider: ${{ inputs.wif-provider }} - service_account: ${{ inputs.wif-service-account }} - - - name: Setup gcloud - if: inputs.wif-provider != '' - uses: google-github-actions/setup-gcloud@v3 - - - name: Authenticate to AWS - if: inputs.aws-role-arn != '' - uses: aws-actions/configure-aws-credentials@v6 - with: - role-to-assume: ${{ inputs.aws-role-arn }} - aws-region: ${{ inputs.aws-region }} - - - name: Publish to bucket - shell: bash - run: make rsync ENV=${{ inputs.env }} - - - name: Invalidate CDN - shell: bash - run: make cdn-invalidate ENV=${{ inputs.env }} - - - name: Deploy summary - shell: bash - env: - APP_URL: ${{ inputs.app-url }} - APP_VERSION: ${{ inputs.app-version }} - run: | - if [ -n "$APP_URL" ]; then - echo "### Deployed to $APP_URL" >> "$GITHUB_STEP_SUMMARY" - else - echo "### Deployed" >> "$GITHUB_STEP_SUMMARY" - fi - echo "" >> "$GITHUB_STEP_SUMMARY" - echo "- Version: \`${APP_VERSION:-n/a}\`" >> "$GITHUB_STEP_SUMMARY" - echo "- Commit: \`${{ github.sha }}\`" >> "$GITHUB_STEP_SUMMARY" diff --git a/actions/infra-apply/action.yml b/actions/infra-apply/action.yml deleted file mode 100644 index febb083..0000000 --- a/actions/infra-apply/action.yml +++ /dev/null @@ -1,89 +0,0 @@ -name: Infrastructure Apply -description: >- - Download a saved Terraform plan produced by the infra-plan action and apply - it. Applying the saved plan guarantees what is applied is exactly what was - planned in the same run. Authenticates to Google Cloud and/or AWS via - keyless OIDC; the caller passes whichever auth applies and the unused auth - step is skipped. - -inputs: - env: - description: Target environment for the Terraform run. - required: true - target: - description: Terraform target directory under `deploy/` (e.g. app, setup). - required: false - default: app - tf-version: - description: Terraform version to install. - required: false - default: "1.x" - wif-provider: - description: Google Cloud Workload Identity Federation provider resource name. Leave empty to skip GCP authentication. - required: false - default: "" - wif-service-account: - description: Service account email impersonated via Workload Identity Federation. Required when wif-provider is set. - required: false - default: "" - aws-role-arn: - description: IAM role ARN to assume via OIDC. Leave empty to skip AWS authentication. - required: false - default: "" - aws-region: - description: AWS region for the assumed role. Required only when aws-role-arn is set. - required: false - default: "" - plan-artifact-name: - description: Name of the plan artifact to download (must match the plan action). - required: false - default: terraform-plan - -runs: - using: composite - steps: - - uses: actions/checkout@v6 - - - uses: hashicorp/setup-terraform@v4 - with: - terraform_version: ${{ inputs.tf-version }} - terraform_wrapper: true - - - name: Authenticate to Google Cloud - if: inputs.wif-provider != '' - uses: google-github-actions/auth@v3 - with: - workload_identity_provider: ${{ inputs.wif-provider }} - service_account: ${{ inputs.wif-service-account }} - - - name: Setup gcloud - if: inputs.wif-provider != '' - uses: google-github-actions/setup-gcloud@v3 - - - name: Authenticate to AWS - if: inputs.aws-role-arn != '' - uses: aws-actions/configure-aws-credentials@v6 - with: - role-to-assume: ${{ inputs.aws-role-arn }} - aws-region: ${{ inputs.aws-region }} - - - name: Download plan artifact - uses: actions/download-artifact@v8 - with: - name: ${{ inputs.plan-artifact-name }} - path: deploy/${{ inputs.target }} - - - name: Init - shell: bash - run: make tf-init ENV=${{ inputs.env }} TARGET=${{ inputs.target }} - - - name: Apply - shell: bash - run: make tf-apply ENV=${{ inputs.env }} TARGET=${{ inputs.target }} - - - name: Apply summary - shell: bash - run: | - echo "### Infrastructure applied — \`deploy/${{ inputs.target }}\`" >> "$GITHUB_STEP_SUMMARY" - echo "" >> "$GITHUB_STEP_SUMMARY" - echo "- Commit: \`${{ github.sha }}\`" >> "$GITHUB_STEP_SUMMARY" diff --git a/actions/mobile-build/action.yml b/actions/mobile-build/action.yml deleted file mode 100644 index 16cf0ce..0000000 --- a/actions/mobile-build/action.yml +++ /dev/null @@ -1,74 +0,0 @@ -name: Mobile Build -description: >- - Build an Expo / React Native app with EAS Build. Installs dependencies, sets - up the EAS CLI authenticated with an Expo token, and runs `eas build` for the - given profile and platform. Waits for the build to finish by default so a - later submit step can pick it up. - -inputs: - expo-token: - description: Expo access token (EXPO_TOKEN) with access to the project. - required: true - profile: - description: EAS build profile (from eas.json), e.g. `production` or `preview`. - required: true - platform: - description: Target platform — `android`, `ios`, or `all`. - required: false - default: all - node-version: - description: Node.js version used for the build. - required: false - default: "20" - wait: - description: Wait for the EAS build to complete. Set `false` to queue and return immediately. - required: false - default: "true" - -runs: - using: composite - steps: - - uses: actions/checkout@v6 - - - uses: actions/setup-node@v6 - with: - node-version: ${{ inputs.node-version }} - cache: npm - - - name: Install - shell: bash - run: npm ci - - - uses: expo/expo-github-action@v8 - with: - eas-version: latest - token: ${{ inputs.expo-token }} - - - name: EAS build - shell: bash - env: - PROFILE: ${{ inputs.profile }} - PLATFORM: ${{ inputs.platform }} - WAIT: ${{ inputs.wait }} - run: | - wait_flag="--wait" - if [ "$WAIT" != "true" ]; then wait_flag="--no-wait"; fi - eas build \ - --profile "$PROFILE" \ - --platform "$PLATFORM" \ - --non-interactive \ - $wait_flag - - - name: Build summary - shell: bash - env: - PROFILE: ${{ inputs.profile }} - PLATFORM: ${{ inputs.platform }} - run: | - { - echo "### EAS build" - echo "" - echo "- Profile: \`$PROFILE\`" - echo "- Platform: \`$PLATFORM\`" - echo "- Commit: \`${{ github.sha }}\`" - } >> "$GITHUB_STEP_SUMMARY" diff --git a/actions/mobile-checks/action.yml b/actions/mobile-checks/action.yml deleted file mode 100644 index 0a81c5f..0000000 --- a/actions/mobile-checks/action.yml +++ /dev/null @@ -1,44 +0,0 @@ -name: Mobile Checks -description: >- - Install dependencies and run lint, type-check and tests for an Expo / React - Native project. Lint, type-check and tests are informational and do not fail - the job. Scripts are run only if present, so the same action works for a - fully wired app and a bare skeleton. - -inputs: - node-version: - description: Node.js version used for the checks. - required: false - default: "20" - -runs: - using: composite - steps: - - uses: actions/checkout@v6 - - - uses: actions/setup-node@v6 - with: - node-version: ${{ inputs.node-version }} - cache: npm - - - name: Install - shell: bash - run: npm ci - - - name: Type-check - shell: bash - run: npx --no-install tsc --noEmit - continue-on-error: true - - - name: Lint - shell: bash - # Only runs if the project defines a `lint` script. - run: npm run lint --if-present - continue-on-error: true - - - name: Test - shell: bash - # Only runs if the project defines a `test` script. Force non-interactive - # CI mode so a watch-mode default (e.g. `jest --watchAll`) can't hang. - run: npm run test --if-present -- --ci --watchAll=false --passWithNoTests - continue-on-error: true diff --git a/actions/mobile-deploy/action.yml b/actions/mobile-deploy/action.yml deleted file mode 100644 index a014201..0000000 --- a/actions/mobile-deploy/action.yml +++ /dev/null @@ -1,115 +0,0 @@ -name: Mobile Deploy -description: >- - Deploy an Expo / React Native app. Two modes: `submit` sends the latest EAS - build to the app stores via `eas submit`; `update` publishes an over-the-air - JS/asset update to an EAS Update channel via `eas update`. Sets up the EAS CLI - authenticated with an Expo token. - -inputs: - expo-token: - description: Expo access token (EXPO_TOKEN) with access to the project. - required: true - mode: - description: "`submit` (store submission) or `update` (over-the-air update)." - required: true - profile: - description: EAS profile for `submit` mode (from eas.json), e.g. `production`. - required: false - default: production - platform: - description: Target platform for `submit` mode — `android`, `ios`, or `all`. - required: false - default: all - branch: - description: EAS Update branch for `update` mode (e.g. `production`, `preview`). - required: false - default: "" - message: - description: Update message for `update` mode. Defaults to the commit SHA. - required: false - default: "" - node-version: - description: Node.js version used for the deploy. - required: false - default: "20" - -runs: - using: composite - steps: - - uses: actions/checkout@v6 - - - uses: actions/setup-node@v6 - with: - node-version: ${{ inputs.node-version }} - cache: npm - - - name: Install - shell: bash - run: npm ci - - - uses: expo/expo-github-action@v8 - with: - eas-version: latest - token: ${{ inputs.expo-token }} - - - name: Validate mode - shell: bash - env: - MODE: ${{ inputs.mode }} - run: | - if [ "$MODE" != "submit" ] && [ "$MODE" != "update" ]; then - echo "::error::mode must be 'submit' or 'update' (got '$MODE')" - exit 1 - fi - - - name: EAS submit - if: inputs.mode == 'submit' - shell: bash - env: - PROFILE: ${{ inputs.profile }} - PLATFORM: ${{ inputs.platform }} - run: | - eas submit \ - --profile "$PROFILE" \ - --platform "$PLATFORM" \ - --latest \ - --non-interactive - - - name: EAS update (OTA) - if: inputs.mode == 'update' - shell: bash - env: - BRANCH: ${{ inputs.branch }} - MESSAGE: ${{ inputs.message }} - run: | - if [ -z "$BRANCH" ]; then - echo "::error::update mode requires a 'branch' input" - exit 1 - fi - msg="$MESSAGE" - if [ -z "$msg" ]; then msg="${{ github.sha }}"; fi - eas update \ - --branch "$BRANCH" \ - --message "$msg" \ - --non-interactive - - - name: Deploy summary - shell: bash - env: - MODE: ${{ inputs.mode }} - PROFILE: ${{ inputs.profile }} - PLATFORM: ${{ inputs.platform }} - BRANCH: ${{ inputs.branch }} - run: | - { - echo "### Mobile deploy" - echo "" - echo "- Mode: \`$MODE\`" - if [ "$MODE" = "submit" ]; then - echo "- Profile: \`$PROFILE\`" - echo "- Platform: \`$PLATFORM\`" - else - echo "- Update branch: \`$BRANCH\`" - fi - echo "- Commit: \`${{ github.sha }}\`" - } >> "$GITHUB_STEP_SUMMARY" diff --git a/actions/announce/action.yml b/actions/notify/action.yml similarity index 50% rename from actions/announce/action.yml rename to actions/notify/action.yml index f346891..594f261 100644 --- a/actions/announce/action.yml +++ b/actions/notify/action.yml @@ -1,39 +1,35 @@ -name: Announce Deployment +name: Notify description: >- - Post a deployment result to a Slack incoming webhook. Best-effort: if no - webhook URL is provided the action does nothing and succeeds, so a missing - webhook never fails the pipeline. + Post a pipeline result to a chat channel. Best-effort: with no webhook the + action is a no-op and succeeds, so a missing webhook never fails the pipeline. inputs: result: - description: Deployment result, e.g. the `result` of the deploy job (`success` / `failure`). + description: Result (success / failure), e.g. a deploy job's result. required: true webhook-url: - description: Slack incoming webhook URL. If empty, the action is a no-op. + description: Incoming webhook URL. Empty makes the action a no-op. required: false default: "" label: - description: Short label for the message, e.g. the app or component name. + description: Short label for the message (app / component name). required: false default: Deployment version: description: Version string shown in the message. required: false default: "" + channel: + description: "Chat channel: slack (only slack implemented today)." + required: false + default: slack runs: using: composite steps: - - name: Check webhook - id: hook - shell: bash - env: - WEBHOOK: ${{ inputs.webhook-url }} - run: '[ -n "$WEBHOOK" ] && echo "set=true" >> "$GITHUB_OUTPUT" || echo "set=false" >> "$GITHUB_OUTPUT"' - - name: Post to Slack - if: steps.hook.outputs.set == 'true' - uses: slackapi/slack-github-action@v3.0.3 + if: inputs.webhook-url != '' && inputs.channel == 'slack' + uses: slackapi/slack-github-action@45a88b9581bfab2566dc881e2cd66d334e621e2c # v3.0.3 with: webhook-type: incoming-webhook payload: | diff --git a/actions/infra-plan/action.yml b/actions/plan/action.yml similarity index 54% rename from actions/infra-plan/action.yml rename to actions/plan/action.yml index 4e16071..e11de12 100644 --- a/actions/infra-plan/action.yml +++ b/actions/plan/action.yml @@ -1,106 +1,138 @@ -name: Infrastructure Plan +name: Plan description: >- - Run Terraform format, validate and plan for a deploy target. On a pull - request the plan is posted as a sticky PR comment; on a push the plan file is - uploaded as an artifact for a later apply. Authenticates to Google Cloud - and/or AWS via keyless OIDC; the caller passes whichever auth applies and - the unused auth step is skipped. + Preview an infrastructure change. Installs the IaC tool, authenticates, runs + the plan via the runner contract (or `run-*` overrides), posts the plan as a + sticky PR comment, and on push uploads the saved plan for a later apply. inputs: + run-init: + description: Explicit init command. Empty falls back to " tf-init". + required: false + default: "" + run-plan: + description: Explicit plan command. Empty falls back to " tf-plan". + required: false + default: "" + runner: + description: Task runner for the default contract targets. + required: false + default: make env: - description: Target environment for the Terraform run. + description: Target environment. required: true target: - description: Terraform target directory under `deploy/` (e.g. app, setup). + description: Target directory under deploy/ (e.g. app, setup). required: false default: app + tool: + description: "IaC tool to install: terraform | none." + required: false + default: terraform tf-version: - description: Terraform version to install. + description: Terraform version (when tool is terraform). required: false default: "1.x" - wif-provider: - description: Google Cloud Workload Identity Federation provider resource name. Leave empty to skip GCP authentication. + gcp-wif-provider: + description: GCP WIF provider. Empty skips GCP auth. required: false default: "" - wif-service-account: - description: Service account email impersonated via Workload Identity Federation. Required when wif-provider is set. + gcp-service-account: + description: Service account impersonated via WIF. required: false default: "" aws-role-arn: - description: IAM role ARN to assume via OIDC. Leave empty to skip AWS authentication. + description: AWS role to assume via OIDC. Empty skips AWS auth. required: false default: "" aws-region: - description: AWS region for the assumed role. Required only when aws-role-arn is set. + description: AWS region for the assumed role. required: false default: "" github-token: - description: Token used to post the plan as a PR comment. Leave empty to skip the comment. + description: Token to post the plan as a PR comment. Empty skips the comment. required: false default: "" plan-artifact-name: - description: Name of the uploaded plan artifact (must match the apply action). + description: Name of the uploaded plan artifact (must match apply). required: false default: terraform-plan plan-extra-paths: - description: Additional paths to include in the plan artifact (one per line). Use this when the Terraform plan produces intermediate files outside `plan.tfplan` that the apply needs (e.g. a Lambda module's `builds/` directory). + description: Extra paths (one per line) to include in the plan artifact. required: false default: "" runs: using: composite steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - uses: hashicorp/setup-terraform@v4 + - name: Setup Terraform + if: inputs.tool == 'terraform' + uses: hashicorp/setup-terraform@dfe3c3f87815947d99a8997f908cb6525fc44e9e # v4.0.1 with: terraform_version: ${{ inputs.tf-version }} terraform_wrapper: true - - name: Authenticate to Google Cloud - if: inputs.wif-provider != '' - uses: google-github-actions/auth@v3 - with: - workload_identity_provider: ${{ inputs.wif-provider }} - service_account: ${{ inputs.wif-service-account }} - - - name: Setup gcloud - if: inputs.wif-provider != '' - uses: google-github-actions/setup-gcloud@v3 - - - name: Authenticate to AWS - if: inputs.aws-role-arn != '' - uses: aws-actions/configure-aws-credentials@v6 + - name: Authenticate + if: inputs.gcp-wif-provider != '' || inputs.aws-role-arn != '' + uses: nurdsoft/ci-workflows/actions/auth@v2 with: - role-to-assume: ${{ inputs.aws-role-arn }} + gcp-wif-provider: ${{ inputs.gcp-wif-provider }} + gcp-service-account: ${{ inputs.gcp-service-account }} + aws-role-arn: ${{ inputs.aws-role-arn }} aws-region: ${{ inputs.aws-region }} - name: Init shell: bash - run: make tf-init ENV=${{ inputs.env }} TARGET=${{ inputs.target }} + env: + RUN_INIT: ${{ inputs.run-init }} + RUNNER: ${{ inputs.runner }} + ENV: ${{ inputs.env }} + TARGET: ${{ inputs.target }} + run: | + if [ -n "$RUN_INIT" ]; then bash -c "$RUN_INIT"; else $RUNNER tf-init ENV="$ENV" TARGET="$TARGET"; fi - name: Format check id: fmt + if: inputs.tool == 'terraform' shell: bash - run: make tf-fmt ENV=${{ inputs.env }} TARGET=${{ inputs.target }} + env: + RUNNER: ${{ inputs.runner }} + ENV: ${{ inputs.env }} + TARGET: ${{ inputs.target }} + run: $RUNNER tf-fmt ENV="$ENV" TARGET="$TARGET" continue-on-error: true - name: Validate id: validate + if: inputs.tool == 'terraform' shell: bash - run: make tf-validate ENV=${{ inputs.env }} TARGET=${{ inputs.target }} + env: + RUNNER: ${{ inputs.runner }} + ENV: ${{ inputs.env }} + TARGET: ${{ inputs.target }} + run: $RUNNER tf-validate ENV="$ENV" TARGET="$TARGET" - name: Plan id: plan shell: bash + env: + RUN_PLAN: ${{ inputs.run-plan }} + RUNNER: ${{ inputs.runner }} + ENV: ${{ inputs.env }} + TARGET: ${{ inputs.target }} run: | set -o pipefail - make tf-plan ENV=${{ inputs.env }} TARGET=${{ inputs.target }} 2>&1 | tee /tmp/plan.txt + if [ -n "$RUN_PLAN" ]; then + bash -c "$RUN_PLAN" 2>&1 | tee /tmp/plan.txt + else + $RUNNER tf-plan ENV="$ENV" TARGET="$TARGET" 2>&1 | tee /tmp/plan.txt + fi continue-on-error: true - name: Post plan as PR comment if: github.event_name == 'pull_request' && inputs.github-token != '' - uses: actions/github-script@v9 + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 env: TF_TARGET: ${{ inputs.target }} FMT_OUTCOME: ${{ steps.fmt.outcome }} @@ -154,11 +186,9 @@ runs: }); } - # The plan file is consumed by the apply action in the same run, so the - # apply ships exactly the plan computed here. Uploaded on push only. - name: Upload plan artifact if: github.event_name == 'push' && steps.plan.outcome == 'success' - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: ${{ inputs.plan-artifact-name }} path: | diff --git a/actions/setup/action.yml b/actions/setup/action.yml new file mode 100644 index 0000000..0f20a60 --- /dev/null +++ b/actions/setup/action.yml @@ -0,0 +1,41 @@ +name: Setup +description: >- + Prepare the toolchain: install the language runtime, fetch dependencies, and + (optionally) authenticate the EAS CLI for Expo projects. Checkout-free — the + caller (or composing action) checks out first, so a job has a single checkout. + +inputs: + node-version: + description: Node.js version. + required: false + default: "20" + install: + description: Dependency-install command. + required: false + default: "npm ci" + expo-token: + description: Expo access token. If set, logs in the EAS CLI for later EAS commands. + required: false + default: "" + +runs: + using: composite + steps: + - name: Install Node + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version: ${{ inputs.node-version }} + cache: npm + + - name: Install dependencies + shell: bash + env: + INSTALL: ${{ inputs.install }} + run: bash -c "$INSTALL" + + - name: Setup EAS CLI + if: inputs.expo-token != '' + uses: expo/expo-github-action@c7b66a9c327a43a8fa7c0158e7f30d6040d2481e # 8.2.1 + with: + eas-version: latest + token: ${{ inputs.expo-token }} diff --git a/actions/verify/action.yml b/actions/verify/action.yml new file mode 100644 index 0000000..fade754 --- /dev/null +++ b/actions/verify/action.yml @@ -0,0 +1,53 @@ +name: Verify +description: >- + Run quality gates — type-check, lint and tests. All informational + (continue-on-error) so they surface problems without blocking; tighten on the + caller side once a project is clean. Commands are overridable for non-npm stacks. + +inputs: + node-version: + description: Node.js version. + required: false + default: "20" + typecheck: + description: Type-check command. + required: false + default: "npx --no-install tsc --noEmit" + lint: + description: Lint command. + required: false + default: "npm run lint --if-present" + test: + description: Test command. + required: false + default: "npm run test --if-present -- --ci --watchAll=false --passWithNoTests" + +runs: + using: composite + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - uses: nurdsoft/ci-workflows/actions/setup@v2 + with: + node-version: ${{ inputs.node-version }} + + - name: Type-check + shell: bash + env: + CMD: ${{ inputs.typecheck }} + run: bash -c "$CMD" + continue-on-error: true + + - name: Lint + shell: bash + env: + CMD: ${{ inputs.lint }} + run: bash -c "$CMD" + continue-on-error: true + + - name: Test + shell: bash + env: + CMD: ${{ inputs.test }} + run: bash -c "$CMD" + continue-on-error: true