diff --git a/.github/README.md b/.github/README.md new file mode 100644 index 0000000..111ed22 --- /dev/null +++ b/.github/README.md @@ -0,0 +1,134 @@ +# Reusable OCI & Docker composites (`FuelLabs/github-actions`) + +**Public** repository. Anyone may **`uses:`** the composites and `workflow_call` workflows. Fuel teams typically pin a **semver ref**; internal policies are **your org’s** (approvals, private registries, etc.). + +This file documents the **OCI / Docker / Helm** composites and their callable workflow wrappers. **[`publish-docker-image.yml`](.github/workflows/publish-docker-image.yml)** is a **legacy** contract (GHCR `GITHUB_CONTAINER_*` secrets, `images` / `docker_file` input names) that **forwards to** [`docker-build-push.yml`](.github/workflows/docker-build-push.yml) in this repo. Prefer **`docker-build-push`** for new ECR OIDC / multi-arch / Warp; keep **`publish-docker-image`** only for existing `uses:` pins. [notify-slack-action.yml](.github/workflows/notify-slack-action.yml) is a separate **reusable** workflow used on failure (including from **`publish-docker-image`**). + +## Artifacts (this “Fuel OCI” set) + +| Kind | Path | Purpose | +|------|------|---------| +| Composite | `.github/actions/docker-build-push` | ECR private/public OIDC or registry login; **Buildx** + QEMU, or **Warp** | +| Composite | `.github/actions/helm-publish-oci` | Non-PR Helm **OCI** publish (lint, push) via registry token or AWS OIDC (ECR) | +| Composite | `.github/actions/slack-notify-failure` | Small Slack failure step (`ravsamhq/notify-slack-action`) | +| Reusable workflow | `.github/workflows/docker-build-push.yml` | Native per-platform runner builds + digest merge (default), or Warp direct push | +| Reusable workflow | `.github/workflows/helm-publish-oci.yml` | Same for Helm | +| Reusable workflow (legacy) | `.github/workflows/publish-docker-image.yml` | Same implementation as `docker-build-push` (wraps the row above) + old secret/input names + Slack on failure | + +**Not in scope for these composites:** PR-only Helm, `helm-cleanup-pr`, preview charts. + +**Related (same repo, different path style):** [`setups/docker`](../setups/docker/) is a small composite for **GHCR login and compose**; use the table above for **build + push** or **Helm OCI**. + +## How callable workflows resolve composites + +Callers’ jobs check out the **consumer** repository. A reusable workflow in **this** repo must **not** use `./.github/actions/...` — that path would resolve in the **caller**, not here. Composite steps use a **fully qualified** `uses: FuelLabs/github-actions/.github/actions/@`, where **`` is a string literal in the workflow file** (e.g. `@master`). GitHub does **not** allow the `env` context in a step’s `uses:` (runtime error: `Unrecognized named-value: 'env'`). Do not use `...@${{ env.… }}`. + +**Releases:** in `docker-build-push.yml` and `helm-publish-oci.yml`, set the `...@` on the composite to the **same** tag/SHA you are about to publish (e.g. `...@v1.0.0` on the commit you tag). Default branch can keep `...@master` for development. Consumers who pin `uses: .../docker-build-push.yml@v1.0.0` get the workflow and composite at that ref together. + +## Secrets + +| Mechanism | In composite? | How to pass | +|-----------|----------------|-------------| +| `secrets.*` in `action.yml` | **No** | `with:` from the caller (`password: ${{ secrets.x }}` — still masked) | +| Reusable workflow | **Yes** | `on.workflow_call.secrets`, caller `secrets: inherit` or explicit map | + +`secrets: inherit` on **composite** actions is not supported; use a callable workflow if you want one secrets mapping. + +## Examples + +**Callable** — Docker (pin replaces `v1.0.0` when you release): + +```yaml +jobs: + image: + uses: FuelLabs/github-actions/.github/workflows/docker-build-push.yml@v1.0.0 + secrets: inherit + with: + auth-mode: registry-login + dockerfile: Dockerfile + image: ghcr.io/fuellabs/myapp + build-backend: native + runs-on-amd64: ubuntu-latest + runs-on-arm64: ubuntu-24.04-arm +``` + +**Callable** — Docker to ECR Public (OIDC): + +```yaml +jobs: + image: + uses: FuelLabs/github-actions/.github/workflows/docker-build-push.yml@v1.0.0 + with: + auth-mode: ecr-public-oidc + aws-role-arn: ${{ secrets.AWS_ROLE_ARN }} + aws-region: us-east-1 + dockerfile: Dockerfile + image: public.ecr.aws/your-alias/myapp + build-backend: native +``` + +**Callable** — Docker via Warp (no native digest merge): + +```yaml +jobs: + image: + uses: FuelLabs/github-actions/.github/workflows/docker-build-push.yml@v1.0.0 + secrets: inherit + with: + auth-mode: ecr-oidc + aws-role-arn: ${{ secrets.AWS_ROLE_ARN }} + dockerfile: Dockerfile + image: 123.dkr.ecr.us-east-1.amazonaws.com/myapp + build-backend: warp + profile-name: my-warp-profile +``` +**Callable** — Helm to GHCR (`registry-login`; needs `packages: write` in the **called** job — workflow already sets it): + +```yaml +jobs: + chart: + uses: FuelLabs/github-actions/.github/workflows/helm-publish-oci.yml@v1.0.0 + with: + auth-mode: registry-login + chart-folder: helm/my-chart + registry-url: oci://ghcr.io/${{ github.repository_owner }}/charts + secrets: + REGISTRY_USERNAME: ${{ github.actor }} + REGISTRY_ACCESS_TOKEN: ${{ secrets.GITHUB_TOKEN }} +``` + +**Callable** — Helm to AWS ECR (`ecr-oidc`): + +```yaml +jobs: + chart: + uses: FuelLabs/github-actions/.github/workflows/helm-publish-oci.yml@v1.0.0 + with: + auth-mode: ecr-oidc + aws-role-arn: ${{ secrets.AWS_ROLE_ARN }} + aws-region: us-east-1 + chart-folder: helm/my-chart + registry-url: oci://123456789012.dkr.ecr.us-east-1.amazonaws.com/charts + # Optional if registry-url includes host (recommended) + # registry-host: 123456789012.dkr.ecr.us-east-1.amazonaws.com +``` + +**Composite** (consumer writes full `permissions`): + +```yaml +- uses: FuelLabs/github-actions/.github/actions/docker-build-push@v1.0.0 + with: + auth-mode: ecr-oidc + aws-role-arn: ${{ secrets.AWS_ROLE_ARN }} + image: 123.dkr.ecr.us-east-1.amazonaws.com/app + dockerfile: Dockerfile +``` + +### `slack-notify-failure` vs `notify-slack-action.yml` + +- **`.github/actions/slack-notify-failure`**: **composite** — add as a step, pass `github_token` + `slack_webhook` via `with:`. +- **`.github/workflows/notify-slack-action.yml`**: older **reusable workflow** (checkout, Rust toolchain) — use only if you already depend on it; new work should prefer the **composite** above. + +## Pinning + +Third-party `uses:` in composites are pinned. Bump in PRs. This repo is **not** the same as **Terraform** tags in `infrastructure-tools` — use **`github-actions`’ own** releases. diff --git a/.github/actions/docker-build-push/action.yml b/.github/actions/docker-build-push/action.yml new file mode 100644 index 0000000..cd320c7 --- /dev/null +++ b/.github/actions/docker-build-push/action.yml @@ -0,0 +1,227 @@ +# Bump third-party majors via PR after review (supply chain). +name: Docker build and push +description: Build and push a container image (ECR private/public OIDC or registry login; Buildx or Warp). + +inputs: + auth-mode: + description: 'ecr-oidc | ecr-public-oidc | registry-login' + required: true + aws-role-arn: + description: IAM role ARN for ECR (auth-mode ecr-oidc or ecr-public-oidc) + required: false + aws-region: + description: AWS region for ECR (public ECR generally uses us-east-1) + required: false + default: us-east-1 + registry: + description: Registry host (registry-login), e.g. ghcr.io + required: false + default: ghcr.io + username: + description: Registry username (registry-login) + required: false + password: + description: 'Registry password or PAT (registry-login). Caller passes secrets.* in workflow with: — masked in logs.' + required: false + image: + description: Image name for docker/metadata-action (without tag) + required: true + tags: + description: Multiline docker/metadata-action tag rules (empty = ECR-style sha + dated raw tag) + required: false + default: '' + flavor: + description: Optional global metadata / tag flavor (docker/metadata-action), e.g. 'latest=auto' + required: false + default: '' + labels: + description: Optional extra image labels (docker/metadata-action, multiline KEY=VAL) + required: false + default: '' + context: + description: Docker build context path + required: false + default: . + dockerfile: + description: Path to Dockerfile + required: true + build-args: + description: Build-args (multiline KEY=VAL) + required: false + platforms: + description: Comma-separated platforms, e.g. linux/amd64,linux/arm64 + required: false + default: linux/amd64 + build-backend: + description: 'buildx | warp' + required: false + default: buildx + profile-name: + description: Warp profile name (build-backend warp — required for org Warp projects) + required: false + push-by-digest: + description: 'true | false (buildx only). true pushes canonical digest refs (for manifest merge flows).' + required: false + default: 'false' + cache-scope: + description: Optional GHA cache scope (buildx only), e.g. linux-amd64 + required: false + default: '' + setup-only: + description: 'true | false. If true, only auth/login setup is performed (no metadata/build/push).' + required: false + default: 'false' + metadata-only: + description: 'true | false. If true, run auth/login + metadata generation only (no build/push).' + required: false + default: 'false' + push: + description: 'true | false' + required: false + default: 'true' + +outputs: + image: + description: Repository/image name without tag (matches inputs.image — not docker/metadata bake image.name, which can be comma-separated tagged refs) + value: ${{ inputs.setup-only != 'true' && inputs.image || '' }} + imageid: + description: Image ID from build backend + value: ${{ (inputs.setup-only != 'true' && inputs.metadata-only != 'true') && (inputs.build-backend == 'warp' && steps.publish-warp.outputs.imageId || (inputs.push-by-digest == 'true' && steps.publish-buildx-digest.outputs.imageId || steps.publish-buildx.outputs.imageId)) || '' }} + digest: + description: Image digest + value: ${{ (inputs.setup-only != 'true' && inputs.metadata-only != 'true') && (inputs.build-backend == 'warp' && steps.publish-warp.outputs.digest || (inputs.push-by-digest == 'true' && steps.publish-buildx-digest.outputs.digest || steps.publish-buildx.outputs.digest)) || '' }} + metadata: + description: docker/metadata-action bake JSON (same schema for Buildx digest merge, Buildx push, Warp — excludes setup-only login-only runs) + value: ${{ inputs.setup-only != 'true' && steps.meta.outputs.json || '' }} + tags: + description: docker/metadata-action computed tags list + value: ${{ inputs.setup-only != 'true' && steps.meta.outputs.tags || '' }} + labels: + description: docker/metadata-action computed labels list + value: ${{ inputs.setup-only != 'true' && steps.meta.outputs.labels || '' }} + version: + description: docker/metadata-action version (primary tag component for image:version) + value: ${{ inputs.setup-only != 'true' && steps.meta.outputs.version || '' }} + +runs: + using: composite + steps: + - name: Configure AWS credentials (ECR OIDC) + if: inputs.auth-mode == 'ecr-oidc' || inputs.auth-mode == 'ecr-public-oidc' + uses: aws-actions/configure-aws-credentials@v4 + with: + role-to-assume: ${{ inputs.aws-role-arn }} + aws-region: ${{ inputs.aws-region }} + + - name: Login to Amazon ECR (private) + if: inputs.auth-mode == 'ecr-oidc' + id: login-ecr + uses: aws-actions/amazon-ecr-login@v2 + + - name: Login to Amazon ECR Public + if: inputs.auth-mode == 'ecr-public-oidc' + id: login-ecr-public + uses: aws-actions/amazon-ecr-login@v2 + with: + registry-type: public + + - name: Log in to container registry + if: inputs.auth-mode == 'registry-login' + uses: docker/login-action@v3 + with: + registry: ${{ inputs.registry }} + username: ${{ inputs.username }} + password: ${{ inputs.password }} + + - name: Resolve metadata tag lines + id: tag-lines + if: inputs.setup-only != 'true' + shell: bash + env: + # Do not embed inputs in the script body; pass through env to avoid shell/heredoc injection + INPUT_TAGS: ${{ inputs.tags }} + run: | + set -euo pipefail + if [ -r /proc/sys/kernel/random/uuid ]; then + delim="GHA_OUT_$(tr -d -- '-\n\r' < /proc/sys/kernel/random/uuid)" + else + delim="GHA_OUT_${RANDOM}_$$_$(date +%s%N)" + fi + { + echo "tags<<${delim}" + if [ -n "$INPUT_TAGS" ]; then + printf '%s\n' "$INPUT_TAGS" + else + printf '%s\n' 'type=sha,prefix=' + printf '%s\n' "type=raw,value=sha-{{sha}}-{{date 'YYYYMMDDHHmmss'}}" + fi + echo "$delim" + } >> "$GITHUB_OUTPUT" + + - name: Docker meta + id: meta + if: inputs.setup-only != 'true' + uses: docker/metadata-action@v5 + with: + images: | + ${{ inputs.image }} + tags: ${{ steps.tag-lines.outputs.tags }} + flavor: | + ${{ inputs.flavor }} + labels: | + ${{ inputs.labels }} + + - name: Set up QEMU (Buildx cross-arch) + if: inputs.setup-only != 'true' && inputs.metadata-only != 'true' && inputs.build-backend == 'buildx' && (contains(inputs.platforms, ',') || contains(inputs.platforms, 'arm')) + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx + if: inputs.setup-only != 'true' && inputs.metadata-only != 'true' && inputs.build-backend == 'buildx' + uses: docker/setup-buildx-action@v3 + + - name: Build and push (Buildx canonical digest) + if: inputs.setup-only != 'true' && inputs.metadata-only != 'true' && inputs.build-backend == 'buildx' && inputs.push-by-digest == 'true' + uses: docker/build-push-action@v6 + id: publish-buildx-digest + with: + context: ${{ inputs.context }} + file: ${{ inputs.dockerfile }} + # Default provenance/SBOM adds extra manifests; digest merge + ECR then often + # references a descriptor imagetools cannot resolve ("not found"). + provenance: false + sbom: false + outputs: type=image,name=${{ inputs.image }},push-by-digest=true,name-canonical=true,push=true + labels: ${{ steps.meta.outputs.labels }} + build-args: ${{ inputs.build-args }} + platforms: ${{ inputs.platforms }} + cache-from: ${{ inputs.cache-scope != '' && format('type=gha,scope={0}', inputs.cache-scope) || 'type=gha' }} + cache-to: ${{ inputs.cache-scope != '' && format('type=gha,mode=max,scope={0}', inputs.cache-scope) || 'type=gha,mode=max' }} + + - name: Build and push (Buildx) + if: inputs.setup-only != 'true' && inputs.metadata-only != 'true' && inputs.build-backend == 'buildx' && inputs.push-by-digest != 'true' + uses: docker/build-push-action@v6 + id: publish-buildx + with: + context: ${{ inputs.context }} + file: ${{ inputs.dockerfile }} + push: ${{ inputs.push == 'true' }} + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + build-args: ${{ inputs.build-args }} + platforms: ${{ inputs.platforms }} + cache-from: ${{ inputs.cache-scope != '' && format('type=gha,scope={0}', inputs.cache-scope) || 'type=gha' }} + cache-to: ${{ inputs.cache-scope != '' && format('type=gha,mode=max,scope={0}', inputs.cache-scope) || 'type=gha,mode=max' }} + + - name: Build and push (Warp) + if: inputs.setup-only != 'true' && inputs.metadata-only != 'true' && inputs.build-backend == 'warp' + uses: Warpbuilds/build-push-action@v6 + id: publish-warp + with: + context: ${{ inputs.context }} + file: ${{ inputs.dockerfile }} + push: ${{ inputs.push == 'true' }} + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + build-args: ${{ inputs.build-args }} + platforms: ${{ inputs.platforms }} + profile-name: ${{ inputs.profile-name }} diff --git a/.github/actions/helm-publish-oci/action.yml b/.github/actions/helm-publish-oci/action.yml new file mode 100644 index 0000000..e3879a6 --- /dev/null +++ b/.github/actions/helm-publish-oci/action.yml @@ -0,0 +1,216 @@ +# Non-PR Helm OCI publish only (no is-pr / pr-number / Chart.yaml PR mutation). +# Bump third-party majors via PR after review (supply chain). +name: Helm publish (OCI) +description: Lint and push a Helm chart to an OCI registry using Chart.yaml version or an override. + +inputs: + auth-mode: + description: 'registry-login | ecr-oidc' + required: false + default: registry-login + chart-folder: + description: Path to the Helm chart directory + required: true + registry-url: + description: OCI registry URL for the chart (e.g. oci://ghcr.io/org/charts) + required: true + registry-host: + description: Registry host for helm registry login in ecr-oidc mode (e.g. 123.dkr.ecr.us-east-1.amazonaws.com). If empty, derived from registry-url. + required: false + default: '' + username: + description: Registry username (required for registry-login) + required: false + access-token: + description: Registry password or token (required for registry-login). Caller should set from secrets — masked in logs. + required: false + aws-role-arn: + description: IAM role ARN for OIDC auth (required for ecr-oidc) + required: false + aws-region: + description: AWS region for ECR in ecr-oidc mode + required: false + default: us-east-1 + chart-version: + description: Override chart version (otherwise read from Chart.yaml) + required: false + force: + description: 'true | false — force push' + required: false + default: 'false' + helm-version: + description: Helm CLI version for azure/setup-helm (e.g. v3.14.4 or latest) + required: false + default: v3.14.4 + +outputs: + chart-version: + description: Chart version that was published + value: ${{ steps.version.outputs.version }} + +runs: + using: composite + steps: + - name: Set up Helm + uses: azure/setup-helm@v4 + with: + version: ${{ inputs.helm-version }} + + - name: Validate auth-mode input combination + shell: bash + env: + AUTH_MODE: ${{ inputs.auth-mode }} + USERNAME: ${{ inputs.username }} + ACCESS_TOKEN: ${{ inputs.access-token }} + AWS_ROLE_ARN: ${{ inputs.aws-role-arn }} + run: | + set -euo pipefail + case "$AUTH_MODE" in + registry-login) + if [ -z "$USERNAME" ] || [ -z "$ACCESS_TOKEN" ]; then + echo "registry-login requires username and access-token" >&2 + exit 1 + fi + ;; + ecr-oidc) + if [ -z "$AWS_ROLE_ARN" ]; then + echo "ecr-oidc requires aws-role-arn" >&2 + exit 1 + fi + ;; + *) + echo "Unsupported auth-mode: $AUTH_MODE" >&2 + exit 1 + ;; + esac + + - name: Configure AWS credentials (OIDC) + if: inputs.auth-mode == 'ecr-oidc' + uses: aws-actions/configure-aws-credentials@v4 + with: + role-to-assume: ${{ inputs.aws-role-arn }} + aws-region: ${{ inputs.aws-region }} + + - name: Resolve registry host for ECR login + if: inputs.auth-mode == 'ecr-oidc' + id: registry-host + shell: bash + env: + REGISTRY_HOST_INPUT: ${{ inputs.registry-host }} + REGISTRY_URL: ${{ inputs.registry-url }} + run: | + set -euo pipefail + host="$REGISTRY_HOST_INPUT" + if [ -z "$host" ]; then + host="${REGISTRY_URL#oci://}" + host="${host%%/*}" + fi + if [ -z "$host" ]; then + echo "Unable to resolve registry host from registry-url: $REGISTRY_URL" >&2 + exit 1 + fi + echo "host=$host" >> "$GITHUB_OUTPUT" + + - name: Helm registry login to ECR + if: inputs.auth-mode == 'ecr-oidc' + shell: bash + env: + AWS_REGION: ${{ inputs.aws-region }} + REGISTRY_HOST: ${{ steps.registry-host.outputs.host }} + run: | + set -euo pipefail + aws ecr get-login-password --region "$AWS_REGION" | helm registry login --username AWS --password-stdin "$REGISTRY_HOST" + + - name: Determine chart version + id: version + shell: bash + env: + CHART_FOLDER: ${{ inputs.chart-folder }} + CHART_VERSION_INPUT: ${{ inputs.chart-version }} + run: | + set -euo pipefail + chart_yaml="${CHART_FOLDER}/Chart.yaml" + if [ -n "$CHART_VERSION_INPUT" ]; then + if [[ "$CHART_VERSION_INPUT" == *$'\n'* ]]; then + echo "chart-version must be a single line" >&2 + exit 1 + fi + CHART_VERSION="$CHART_VERSION_INPUT" + else + CHART_VERSION=$(grep '^version:' "$chart_yaml" | head -1 | sed 's/^version:[[:space:]]*//' | tr -d "'\"") + fi + if [[ "$CHART_VERSION" == *$'\n'* || "$CHART_VERSION" == *$'\r'* ]]; then + echo "Chart version value must be a single line" >&2 + exit 1 + fi + # Single-line value only; avoid printf interpreting % in version strings + printf '%s\n' "version=$CHART_VERSION" >> "$GITHUB_OUTPUT" + echo "Chart version: ${CHART_VERSION}" + + - name: Apply chart version override to Chart.yaml + if: inputs.chart-version != '' + shell: bash + env: + CHART_FOLDER: ${{ inputs.chart-folder }} + CHART_VERSION: ${{ inputs.chart-version }} + run: | + set -euo pipefail + chart_yaml="${CHART_FOLDER}/Chart.yaml" + if [[ "$CHART_VERSION" == *$'\n'* ]]; then + echo "chart-version must be a single line" >&2 + exit 1 + fi + cp -a "$chart_yaml" "${chart_yaml}.pre-oci-publish.bak" + tmp="${chart_yaml}.oci.publish.tmp" + # awk: replace first top-level version line; avoids sed delimiter/injection in the value + awk -v nver="$CHART_VERSION" 'BEGIN{done=0} /^version:/{if(!done){print "version: " nver; done=1; next}} 1' "$chart_yaml" > "$tmp" && mv "$tmp" "$chart_yaml" + + - name: Lint Helm chart + shell: bash + env: + CHART_FOLDER: ${{ inputs.chart-folder }} + run: helm lint "$CHART_FOLDER" + + - name: Package and push chart (OCI, registry-login) + if: inputs.auth-mode == 'registry-login' + uses: bsord/helm-push@v4.1.0 + with: + useOCIRegistry: true + registry-url: ${{ inputs.registry-url }} + username: ${{ inputs.username }} + access-token: ${{ inputs.access-token }} + force: ${{ inputs.force }} + chart-folder: ${{ inputs.chart-folder }} + + - name: Package and push chart (OCI, ecr-oidc) + if: inputs.auth-mode == 'ecr-oidc' + shell: bash + env: + CHART_FOLDER: ${{ inputs.chart-folder }} + CHART_VERSION: ${{ steps.version.outputs.version }} + REGISTRY_URL: ${{ inputs.registry-url }} + run: | + set -euo pipefail + tmpdir="$(mktemp -d)" + trap 'rm -rf "$tmpdir"' EXIT + helm package "$CHART_FOLDER" --version "$CHART_VERSION" --destination "$tmpdir" + shopt -s nullglob + pkgs=("$tmpdir"/*.tgz) + shopt -u nullglob + if [ "${#pkgs[@]}" -ne 1 ]; then + echo "Expected exactly one chart package in $tmpdir, got ${#pkgs[@]}" >&2 + exit 1 + fi + helm push "${pkgs[0]}" "$REGISTRY_URL" + + - name: Restore Chart.yaml after override + if: inputs.chart-version != '' && always() + shell: bash + env: + CHART_FOLDER: ${{ inputs.chart-folder }} + run: | + set -euo pipefail + chart_yaml="${CHART_FOLDER}/Chart.yaml" + if [ -f "${chart_yaml}.pre-oci-publish.bak" ]; then + mv -- "${chart_yaml}.pre-oci-publish.bak" "$chart_yaml" + fi diff --git a/.github/actions/slack-notify-failure/action.yml b/.github/actions/slack-notify-failure/action.yml new file mode 100644 index 0000000..e445b78 --- /dev/null +++ b/.github/actions/slack-notify-failure/action.yml @@ -0,0 +1,26 @@ +# Same pattern as FuelLabs/fuel-core .github/actions/slack-notify-template +name: Notify Slack on failure +description: Sends a Slack notification when the job fails (ravsamhq/notify-slack-action). + +inputs: + github_token: + description: 'GitHub token; caller passes secrets.GITHUB_TOKEN (or PAT) via with:. Composite metadata cannot use secrets.* context.' + required: true + slack_webhook: + description: 'Slack incoming webhook URL from caller repo/org secret — masked in logs.' + required: true + +runs: + using: composite + steps: + - name: Notify if job fails + uses: ravsamhq/notify-slack-action@v2 + with: + status: ${{ job.status }} + token: ${{ inputs.github_token }} + notification_title: '{workflow} has {status_message}' + message_format: '{emoji} *{workflow}* {status_message} in <{repo_url}|{repo}> : <{run_url}|View Run Results>' + footer: '' + notify_when: failure + env: + SLACK_WEBHOOK_URL: ${{ inputs.slack_webhook }} diff --git a/.github/workflows/docker-build-push.yml b/.github/workflows/docker-build-push.yml new file mode 100644 index 0000000..71f40ba --- /dev/null +++ b/.github/workflows/docker-build-push.yml @@ -0,0 +1,384 @@ +# Callable reusable workflow — Docker build & push (FuelLabs/github-actions). +# Pin: uses: FuelLabs/github-actions/.github/workflows/docker-build-push.yml@ +# +# Composites must use remote uses: (not ./) — the job workspace is the caller’s repo, so +# actions/checkout is the caller, not this repo. The composite ref below must be a +# **literal** (not env — env is not allowed in `uses:`). On release, set it to the same +# tag you publish (e.g. @v1.0.0) before tagging, so consumers pinning this workflow get a +# matching composite. On feature branches, pin the same ref as this workflow file (not @master). +name: Reusable — Docker build and push + +on: + workflow_call: + inputs: + runs-on: + type: string + description: GitHub-hosted runner label for the job + default: ubuntu-latest + platforms: + type: string + description: Comma-separated platforms (e.g. linux/amd64,linux/arm64) + default: linux/amd64 + build-backend: + type: string + description: 'buildx | native | warp (buildx/native = native runner + digest merge path)' + default: buildx + auth-mode: + type: string + description: 'ecr-oidc | ecr-public-oidc | registry-login' + required: true + aws-role-arn: + type: string + description: IAM role for ECR OIDC (required when auth-mode is ecr-oidc or ecr-public-oidc) + required: false + aws-region: + type: string + default: us-east-1 + runs-on-amd64: + type: string + description: Runner label for linux/amd64 native build + default: ubuntu-latest + runs-on-arm64: + type: string + description: Runner label for linux/arm64 native build + default: ubuntu-24.04-arm + registry: + type: string + description: Registry host when using registry-login + default: ghcr.io + docker-context: + type: string + default: . + dockerfile: + type: string + required: true + image: + type: string + required: true + tags: + type: string + description: Optional multiline docker/metadata-action tag rules (see .github/README.md) + required: false + flavor: + type: string + description: Optional docker/metadata-action flavor (e.g. latest=auto) + required: false + default: '' + labels: + type: string + description: Optional docker/metadata-action custom labels (multiline) + required: false + default: '' + build-args: + type: string + required: false + profile-name: + type: string + description: Warp profile (required when build-backend is warp for Fuel projects) + required: false + digest-artifact-key: + type: string + description: > + Suffix for digest upload-artifact names. MUST be unique per image when the caller runs this + workflow in a matrix (GitHub replaces artifacts with the same name). Use e.g. matrix.name. + If empty, a 16-char hash of inputs.image is used. + required: false + default: '' + secrets: + REGISTRY_USERNAME: + description: Username for registry-login (omit for pure ECR OIDC) + required: false + REGISTRY_PASSWORD: + description: Password or PAT for registry-login + required: false + outputs: + image: + description: Repository/image name without tag (inputs.image — stable across native-merge and Warp) + value: ${{ jobs.native-merge.outputs.image || jobs.warp.outputs.image }} + digest: + description: Image digest + value: ${{ jobs.native-merge.outputs.digest || jobs.warp.outputs.digest }} + metadata: + description: docker/metadata-action bake JSON (stable schema across native-merge and Warp) + value: ${{ jobs.native-merge.outputs.metadata || jobs.warp.outputs.metadata }} + +jobs: + native-plan: + if: ${{ inputs.build-backend != 'warp' }} + runs-on: ${{ inputs.runs-on }} + outputs: + matrix: ${{ steps.plan.outputs.matrix }} + digest_artifact_key: ${{ steps.artifact-key.outputs.digest_artifact_key }} + steps: + - name: Build native matrix from requested platforms + id: plan + shell: bash + env: + PLATFORMS: ${{ inputs.platforms }} + RUNNER_AMD64: ${{ inputs.runs-on-amd64 }} + RUNNER_ARM64: ${{ inputs.runs-on-arm64 }} + run: | + set -euo pipefail + python3 - <<'PY' >> "$GITHUB_OUTPUT" + import json + import os + + raw = os.environ.get("PLATFORMS", "") + runner_amd64 = os.environ.get("RUNNER_AMD64", "ubuntu-latest") + runner_arm64 = os.environ.get("RUNNER_ARM64", "ubuntu-24.04-arm") + allowed = { + "linux/amd64": runner_amd64, + "linux/arm64": runner_arm64, + } + + requested = [p.strip() for p in raw.split(",") if p.strip()] + if not requested: + raise SystemExit("platforms input must contain at least one platform") + + include = [] + seen = set() + invalid = [] + for p in requested: + if p not in allowed: + invalid.append(p) + continue + if p in seen: + continue + seen.add(p) + include.append({"platform": p, "runner": allowed[p]}) + + if invalid: + raise SystemExit( + "Unsupported platform(s): " + + ", ".join(invalid) + + ". Allowed: linux/amd64, linux/arm64" + ) + if not include: + raise SystemExit("No valid platforms to build") + + print(f"matrix={json.dumps({'include': include}, separators=(',', ':'))}") + PY + + - name: Digest artifact key + id: artifact-key + shell: bash + env: + EXPLICIT: ${{ inputs.digest-artifact-key }} + IMAGE: ${{ inputs.image }} + run: | + set -euo pipefail + if [ -n "${EXPLICIT}" ]; then + key="${EXPLICIT}" + key="${key//[^a-zA-Z0-9._-]/-}" + key="${key:0:120}" + else + key=$(printf '%s' "$IMAGE" | sha256sum | awk '{print substr($1,1,16)}') + fi + printf '%s\n' "digest_artifact_key=$key" >> "$GITHUB_OUTPUT" + + native-build: + if: ${{ inputs.build-backend != 'warp' }} + needs: native-plan + runs-on: ${{ matrix.runner }} + permissions: + id-token: write + contents: read + packages: write + strategy: + fail-fast: false + matrix: ${{ fromJSON(needs.native-plan.outputs.matrix) }} + steps: + - uses: actions/checkout@v4 + + - name: Derive platform pair + id: platform + shell: bash + env: + MATRIX_PLATFORM: ${{ matrix.platform }} + run: | + set -euo pipefail + platform="${MATRIX_PLATFORM}" + echo "pair=${platform//\//-}" >> "$GITHUB_OUTPUT" + + - name: Build and push by digest (native) + id: build + uses: FuelLabs/github-actions/.github/actions/docker-build-push@master + with: + auth-mode: ${{ inputs.auth-mode }} + aws-role-arn: ${{ inputs.aws-role-arn }} + aws-region: ${{ inputs.aws-region }} + registry: ${{ inputs.registry }} + username: ${{ secrets.REGISTRY_USERNAME }} + password: ${{ secrets.REGISTRY_PASSWORD }} + image: ${{ inputs.image }} + tags: ${{ inputs.tags }} + flavor: ${{ inputs.flavor }} + labels: ${{ inputs.labels }} + context: ${{ inputs.docker-context }} + dockerfile: ${{ inputs.dockerfile }} + build-backend: buildx + push-by-digest: 'true' + cache-scope: ${{ steps.platform.outputs.pair }} + platforms: ${{ matrix.platform }} + build-args: ${{ inputs.build-args }} + + - name: Export digest + shell: bash + env: + BUILD_DIGEST: ${{ steps.build.outputs.digest }} + run: | + set -euo pipefail + mkdir -p /tmp/digests + digest="${BUILD_DIGEST}" + digest="${digest#sha256:}" + digest="${digest//[[:space:]]/}" + if [[ ! "$digest" =~ ^[0-9a-fA-F]{64}$ ]]; then + echo "Invalid digest from build (expected 64 hex chars): ${BUILD_DIGEST}" >&2 + exit 1 + fi + touch "/tmp/digests/${digest,,}" + + - name: Upload digest + uses: actions/upload-artifact@v4 + with: + name: digests-${{ steps.platform.outputs.pair }}-${{ needs.native-plan.outputs.digest_artifact_key }} + path: /tmp/digests/* + if-no-files-found: error + retention-days: 1 + + native-merge: + if: ${{ inputs.build-backend != 'warp' }} + needs: + - native-plan + - native-build + runs-on: ${{ inputs.runs-on }} + permissions: + id-token: write + contents: read + packages: write + outputs: + image: ${{ steps.image.outputs.image }} + digest: ${{ steps.inspect.outputs.digest }} + metadata: ${{ steps.meta.outputs.metadata }} + steps: + - name: Download digests + uses: actions/download-artifact@v4 + with: + path: /tmp/digests + pattern: digests-*-${{ needs.native-plan.outputs.digest_artifact_key }} + merge-multiple: true + + - name: Login and metadata only (reuse composite) + uses: FuelLabs/github-actions/.github/actions/docker-build-push@master + id: meta + with: + auth-mode: ${{ inputs.auth-mode }} + aws-role-arn: ${{ inputs.aws-role-arn }} + aws-region: ${{ inputs.aws-region }} + registry: ${{ inputs.registry }} + username: ${{ secrets.REGISTRY_USERNAME }} + password: ${{ secrets.REGISTRY_PASSWORD }} + image: ${{ inputs.image }} + tags: ${{ inputs.tags }} + flavor: ${{ inputs.flavor }} + labels: ${{ inputs.labels }} + dockerfile: ${{ inputs.dockerfile }} + metadata-only: 'true' + + # Same as o2-mm-stoikov merge job: metadata-only composite skips setup-buildx; imagetools needs it. + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Create manifest list and push + working-directory: /tmp/digests + shell: bash + env: + IMAGE_REF: ${{ inputs.image }} + METADATA_JSON: ${{ steps.meta.outputs.metadata }} + run: | + set -euo pipefail + manifests=() + declare -A seen=() + while IFS= read -r -d '' f; do + d="$(basename "$f")" + d="${d,,}" + [[ "$d" =~ ^[0-9a-f]{64}$ ]] || continue + [[ -n "${seen[$d]:-}" ]] && continue + seen[$d]=1 + manifests+=("${IMAGE_REF}@sha256:${d}") + done < <(find . -type f -print0) + if [ "${#manifests[@]}" -eq 0 ]; then + echo "No valid sha256 digest marker files under $(pwd)" >&2 + find . -ls >&2 || true + exit 1 + fi + set -f + docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$METADATA_JSON") \ + "${manifests[@]}" + + - name: Set image output + id: image + shell: bash + env: + IMAGE_REF: ${{ inputs.image }} + run: | + set -euo pipefail + printf '%s\n' "image=$IMAGE_REF" >> "$GITHUB_OUTPUT" + + - name: Inspect pushed manifest digest + id: inspect + shell: bash + env: + IMAGE_REF: ${{ inputs.image }} + VERSION: ${{ steps.meta.outputs.version }} + run: | + set -euo pipefail + # Match o2-mm-stoikov: inspect repo:version (metadata-action version == primary tag suffix). + if [ -z "${VERSION}" ]; then + echo "Empty metadata version; cannot inspect merged manifest" >&2 + exit 1 + fi + digest="$( + docker buildx imagetools inspect "${IMAGE_REF}:${VERSION}" --format '{{json .Manifest}}' \ + | jq -r '.digest // empty' + )" + if [ -z "${digest}" ]; then + echo "Could not read manifest digest for ${IMAGE_REF}:${VERSION}" >&2 + exit 1 + fi + echo "digest=$digest" >> "$GITHUB_OUTPUT" + + warp: + if: ${{ inputs.build-backend == 'warp' }} + runs-on: ${{ inputs.runs-on }} + permissions: + id-token: write + contents: read + packages: write + outputs: + image: ${{ steps.build.outputs.image }} + digest: ${{ steps.build.outputs.digest }} + metadata: ${{ steps.build.outputs.metadata }} + steps: + - uses: actions/checkout@v4 + + - name: Build and push (Warp) + id: build + uses: FuelLabs/github-actions/.github/actions/docker-build-push@master + with: + auth-mode: ${{ inputs.auth-mode }} + aws-role-arn: ${{ inputs.aws-role-arn }} + aws-region: ${{ inputs.aws-region }} + registry: ${{ inputs.registry }} + username: ${{ secrets.REGISTRY_USERNAME }} + password: ${{ secrets.REGISTRY_PASSWORD }} + image: ${{ inputs.image }} + tags: ${{ inputs.tags }} + flavor: ${{ inputs.flavor }} + labels: ${{ inputs.labels }} + context: ${{ inputs.docker-context }} + dockerfile: ${{ inputs.dockerfile }} + build-args: ${{ inputs.build-args }} + platforms: ${{ inputs.platforms }} + build-backend: warp + profile-name: ${{ inputs.profile-name }} diff --git a/.github/workflows/helm-publish-oci.yml b/.github/workflows/helm-publish-oci.yml new file mode 100644 index 0000000..deac9ec --- /dev/null +++ b/.github/workflows/helm-publish-oci.yml @@ -0,0 +1,82 @@ +# Callable reusable workflow — Helm OCI publish (non-PR flows only). +# Pin: uses: FuelLabs/github-actions/.github/workflows/helm-publish-oci.yml@ +# +# Composites use remote `uses:` with a **literal** ref (not `env` — not allowed in `uses:`). +# On release, set it to the same tag you publish (e.g. @v1.0.0) before tagging. See docker-build-push.yml. +name: Reusable — Helm publish (OCI) + +on: + workflow_call: + inputs: + auth-mode: + type: string + description: 'registry-login | ecr-oidc' + required: false + default: registry-login + chart-folder: + type: string + required: true + registry-url: + type: string + required: true + registry-host: + type: string + description: Optional registry host for ecr-oidc (derived from registry-url when empty) + required: false + default: '' + aws-role-arn: + type: string + description: IAM role ARN for ecr-oidc mode + required: false + aws-region: + type: string + default: us-east-1 + chart-version: + type: string + description: Optional override; otherwise Chart.yaml version is used + required: false + force: + type: string + default: 'false' + helm-version: + type: string + default: v3.14.4 + secrets: + REGISTRY_USERNAME: + required: false + REGISTRY_ACCESS_TOKEN: + required: false + outputs: + chart-version: + description: Chart version published + value: ${{ jobs.helm.outputs.chart-version }} + +jobs: + helm: + runs-on: ubuntu-latest + permissions: + # Required for aws-actions/configure-aws-credentials OIDC (ecr-oidc mode) + id-token: write + contents: read + # Required when REGISTRY_ACCESS_TOKEN is GITHUB_TOKEN pushing to ghcr.io (Packages) + packages: write + outputs: + chart-version: ${{ steps.publish.outputs.chart-version }} + steps: + - uses: actions/checkout@v4 + + - name: Publish chart + id: publish + uses: FuelLabs/github-actions/.github/actions/helm-publish-oci@master + with: + auth-mode: ${{ inputs.auth-mode }} + chart-folder: ${{ inputs.chart-folder }} + registry-url: ${{ inputs.registry-url }} + registry-host: ${{ inputs.registry-host }} + aws-role-arn: ${{ inputs.aws-role-arn }} + aws-region: ${{ inputs.aws-region }} + username: ${{ secrets.REGISTRY_USERNAME }} + access-token: ${{ secrets.REGISTRY_ACCESS_TOKEN }} + chart-version: ${{ inputs.chart-version }} + force: ${{ inputs.force }} + helm-version: ${{ inputs.helm-version }} diff --git a/.github/workflows/publish-crate.yml b/.github/workflows/publish-crate.yml index 1e85bb0..c15ed25 100644 --- a/.github/workflows/publish-crate.yml +++ b/.github/workflows/publish-crate.yml @@ -34,8 +34,8 @@ jobs: registry-token: ${{ secrets.CARGO_REGISTRY_TOKEN }} notify-slack-action: - needs: cargo-toml-lint - if: ${{failure()}} + needs: [publish-crate] + if: failure() uses: FuelLabs/github-actions/.github/workflows/notify-slack-action.yml@master secrets: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/publish-docker-image.yml b/.github/workflows/publish-docker-image.yml index fe38702..92d4253 100644 --- a/.github/workflows/publish-docker-image.yml +++ b/.github/workflows/publish-docker-image.yml @@ -1,3 +1,6 @@ +# Legacy callable — GHCR username/password + metadata inputs. +# Nested jobs use FuelLabs/github-actions/... (not ./): cross-repo workflow_call resolves ./ to the caller. +# Pin: `uses: FuelLabs/github-actions/.github/workflows/publish-docker-image.yml@` name: Build and Publish Docker Image on: @@ -7,6 +10,7 @@ on: description: "Defines global behaviors for tags (e.g. 'latest=auto')" required: false type: string + default: "" docker_file: description: "Path to the deployment file" @@ -15,7 +19,10 @@ on: default: "deployment/Dockerfile" images: - description: "Docker image names (e.g. 'ghcr.io/FuelLabs/repository-name')" + description: > + One or more image repository names (comma-separated). Each is published separately; + multi-value strings must not be passed as a single docker-build-push image input (commas + break the buildx outputs CSV). Same idea as docker/metadata-action multi-image lists. required: true type: string @@ -23,15 +30,16 @@ on: description: "Docker image labels (e.g. 'com.example.label=value')" required: false type: string + default: "" tags: - description: "Docker image tags (e.g. 'latest,1.0.0')" + description: "Docker image tags (e.g. multiline docker/metadata-action rules, or 'latest,1.0.0' per your existing usage)" required: true type: string secrets: GH_TOKEN: - description: "GitHub Token" + description: "GitHub Token (Slack notify job)" required: true GITHUB_CONTAINER_USERNAME: @@ -46,55 +54,56 @@ on: description: "Slack Webhook URL" required: true -env: - GITHUB_CONTAINNER_REGISTRY_URL: ghcr.io - jobs: - build-and-publish-docker-image: + prepare-images: runs-on: ubuntu-latest - - permissions: - contents: read - packages: write - + outputs: + images_json: ${{ steps.split.outputs.images_json }} steps: - - name: Checkout repository - uses: actions/checkout@v4 - - - name: Docker Meta - id: meta - uses: docker/metadata-action@v3 - with: - flavor: ${{ inputs.flavor }} - images: ${{ inputs.images }} - labels: ${{ inputs.labels }} - tags: ${{ inputs.tags }} + - name: Split images input + id: split + shell: bash + env: + IMAGES: ${{ inputs.images }} + run: | + set -euo pipefail + python3 - <<'PY' >> "$GITHUB_OUTPUT" + import json + import os + + raw = os.environ.get("IMAGES", "") + parts = [p.strip() for p in raw.split(",") if p.strip()] + if not parts: + raise SystemExit("images input must contain at least one image name") + print(f"images_json={json.dumps(parts)}") + PY - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v1 - - - name: Login to GitHub Container Registry - uses: docker/login-action@v1 - with: - registry: ${{ env.GITHUB_CONTAINNER_REGISTRY_URL }} - username: ${{ secrets.GITHUB_CONTAINER_USERNAME }} - password: ${{ secrets.GITHUB_CONTAINER_PASSWORD }} - - - name: Build and publish image to Github Container Registry - uses: docker/build-push-action@v2 - with: - context: . - file: ${{ inputs.docker_file }} - push: true - tags: ${{ steps.meta.outputs.tags }} - labels: ${{ steps.meta.outputs.labels }} - cache-from: type=gha - cache-to: type=gha,mode=max + build-and-publish-docker-image: + needs: prepare-images + strategy: + fail-fast: false + matrix: + image: ${{ fromJSON(needs.prepare-images.outputs.images_json) }} + uses: FuelLabs/github-actions/.github/workflows/docker-build-push.yml@master + with: + runs-on: ubuntu-latest + auth-mode: registry-login + registry: ghcr.io + dockerfile: ${{ inputs.docker_file }} + image: ${{ matrix.image }} + tags: ${{ inputs.tags }} + labels: ${{ inputs.labels }} + flavor: ${{ inputs.flavor }} + build-backend: buildx + platforms: linux/amd64 + secrets: + REGISTRY_USERNAME: ${{ secrets.GITHUB_CONTAINER_USERNAME }} + REGISTRY_PASSWORD: ${{ secrets.GITHUB_CONTAINER_PASSWORD }} - notify-slack-action: - needs: cargo-toml-lint - if: ${{failure()}} + notify-slack-on-failure: + needs: [prepare-images, build-and-publish-docker-image] + if: ${{ always() && (needs.prepare-images.result == 'failure' || needs['build-and-publish-docker-image'].result == 'failure') }} uses: FuelLabs/github-actions/.github/workflows/notify-slack-action.yml@master secrets: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_TOKEN: ${{ secrets.GH_TOKEN }} SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} diff --git a/README.md b/README.md index 17dcab3..1ae5fbe 100644 --- a/README.md +++ b/README.md @@ -1,18 +1,29 @@ -# Github Actions +# GitHub Actions -Repoistory to host all of Fuel's reusable workflows +Repository for Fuel’s reusable **workflows** and **composite** actions (public). -## Group of Actions +## OCI, Docker, Helm, Slack (composites + `workflow_call`) -| Group | Description | -| --------------------------------- | ---------------------------------------------------------------- | -| [audit](./audits/) | Reusable workflows for auditing npm packages | -| [changeset](./changeset/) | Reusable workflow for create changesets and release npm packages | -| [gh-projects](./gh-projects/) | Automating interactions between GH Projects and repositories | -| [setups/node](./setups/node/) | Setup node and pnpm requirements on CI | -| [setups/docker](./setups/docker/) | Setup docker and docker compose on CI | -| [setups/npm](./setups/npm/) | Setup npm deployment requirements on CI | -| [update-sdk](./update-sdk/) | Reusable workflow for update the SDK packages | +| Location | Description | +| -------- | ----------- | +| [`.github/README.md`](.github/README.md) | **Docker/Helm/Slack** — `docker-build-push`, `helm-publish-oci`, `slack-notify-failure`, and callable `docker-build-push` / `helm-publish-oci` workflows (ECR OIDC, Buildx/QEMU, Warp, OCI). | + +**Legacy / separate:** [`.github/workflows/publish-docker-image.yml`](.github/workflows/publish-docker-image.yml) wraps the same path as `docker-build-push` (GHCR + legacy `GITHUB_CONTAINER_*` / `images` / `docker_file` inputs) and [`.github/workflows/notify-slack-action.yml`](.github/workflows/notify-slack-action.yml) (Slack reusable on failure). New work should use [`docker-build-push.yml`](.github/workflows/docker-build-push.yml) and the composites in [`.github/README.md`](.github/README.md) unless you must keep an old `uses:` pin. + +## Other groups + +Root-level actions use **`uses: FuelLabs/github-actions//...`** and a colocated **README** in each folder. **OCI (Docker / Helm) stacks** are different: composites live under [`.github/actions/`](.github/actions/) and are documented in [`.github/README.md`](.github/README.md) (callable workflows, cross-repo `uses:` with **literal** composite `@ref`, not `env`); that split exists so Docker/Helm can share one doc with **ECR, Warp, and OCI** patterns. + +| Group | Description | +| ---------------------------------- | ---------------------------------------------------------------- | +| [`.github/README.md`](.github/README.md) | **Docker, Helm, Slack** composites and `workflow_call` wrappers (see OCI block above) | +| [audit](./audits/) | Reusable workflows for auditing npm packages | +| [changeset](./changeset/) | Reusable workflow for create changesets and release npm packages | +| [gh-projects](./gh-projects/) | Automating interactions between GH Projects and repositories | +| [setups/node](./setups/node/) | Setup node and pnpm requirements on CI | +| [setups/docker](./setups/docker/) | **GHCR login** / docker compose (lightweight; not a full build+push) | +| [setups/npm](./setups/npm/) | Setup npm deployment requirements on CI | +| [update-sdk](./update-sdk/) | Reusable workflow for update the SDK packages | ## License diff --git a/setups/docker/README.md b/setups/docker/README.md index 9f5586c..bea3450 100644 --- a/setups/docker/README.md +++ b/setups/docker/README.md @@ -1,4 +1,4 @@ -### Setup node +### Setup docker A github action to setup Docker