From a48f068ac7b0096e61eea5a821d08658a87d89b5 Mon Sep 17 00:00:00 2001 From: Chase Date: Thu, 29 Oct 2026 14:50:05 +0100 Subject: [PATCH] Add reusable publish workflow (build, publish, sign) Two jobs: build runs the caller's Dockerfile without credentials; publish runs no caller code, pushes only the OCI data and signs the digest with keyless cosign. Each caller passes its own workload identity provider, service account and image; the workflow sets no identity. Also: CODEOWNERS, Dependabot for actions, actionlint + zizmor on PRs. --- .github/CODEOWNERS | 1 + .github/dependabot.yml | 9 + .github/workflows/lint.yml | 27 +++ .github/workflows/publish.yml | 364 ++++++++++++++++++++++++++++++++++ README.md | 107 +++++++++- 5 files changed, 507 insertions(+), 1 deletion(-) create mode 100644 .github/CODEOWNERS create mode 100644 .github/dependabot.yml create mode 100644 .github/workflows/lint.yml create mode 100644 .github/workflows/publish.yml diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..664781e --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1 @@ +* @Blockstream/infra 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/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 0000000..0746e9b --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,27 @@ +name: Lint workflows + +on: + pull_request: + push: + branches: [main, 'v*'] + +permissions: {} + +jobs: + lint: + runs-on: ubuntu-24.04 + timeout-minutes: 10 + permissions: + contents: read + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: actionlint + run: docker run --rm -v "$PWD:/repo" -w /repo rhysd/actionlint:1.7.7 -color + + - name: zizmor + env: + GH_TOKEN: ${{ github.token }} + run: pipx run zizmor==1.5.2 --min-severity medium .github/workflows diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..1f0297e --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,364 @@ +name: Build, publish and sign an image + +# Reusable workflow. Callers pin `@v1`: +# +# uses: Blockstream/ci-components/.github/workflows/publish.yml@v1 +# +# The build job runs caller code without registry credentials. The publish job +# runs no caller code: it re-uploads only the OCI data produced by the build +# and signs the pushed digest with keyless cosign (GitHub OIDC). +# +# Each caller passes its own workload identity provider, service account and +# image as inputs (for example from repository variables), so this public +# workflow names no internal resources. Which repository, ref and environment +# may publish where is enforced outside this file: the provider's attribute +# condition, the service account's workload identity binding and the +# environment's deployment branch policy. + +on: + workflow_call: + inputs: + workload-identity-provider: + description: Full workload identity provider name, projects//locations/global/workloadIdentityPools//providers/ + type: string + required: true + service-account: + description: Service account to impersonate for the push, @.iam.gserviceaccount.com + type: string + required: true + image: + description: Image to publish without tag, -docker.pkg.dev/// + type: string + required: true + environment: + description: Caller environment of the publish job, e.g. staging or production. The tag gets a prod- prefix for production. + type: string + required: true + build-environment: + description: Caller environment for the build job (build variables and secrets). Default -build. + type: string + default: '' + context: + description: Build context directory within the caller checkout + type: string + default: . + dockerfile: + description: Dockerfile path within the caller checkout + type: string + default: Dockerfile + target: + description: Optional Dockerfile target + type: string + default: '' + build-arg-vars: + description: Newline-separated variable names to pass as build arguments + type: string + default: '' + build-secrets: + description: Newline-separated secret names to pass as BuildKit secrets (RUN --mount=type=secret) + type: string + default: '' + build-arg-secrets: + description: Newline-separated secret names to pass as build arguments (deprecated, use build-secrets) + type: string + default: '' + # Callers that request build secrets use `secrets: inherit`. Secrets are + # resolved only inside build; publish never receives the secrets context. + outputs: + digest: + description: Published image digest + value: ${{ jobs.publish.outputs.digest }} + image: + description: Full image reference including digest + value: ${{ jobs.publish.outputs.image }} + +permissions: {} + +defaults: + run: + shell: bash + +jobs: + build: + # Forks skip instead of failing at authentication. + if: contains(fromJSON('["Blockstream", "BlockstreamResearch"]'), github.repository_owner) + runs-on: ubuntu-24.04 + # Large static-site pre-renders take ~50 minutes on a 2-vCPU runner. + timeout-minutes: 90 + environment: ${{ inputs.build-environment || format('{0}-build', inputs.environment) }} + permissions: + contents: read + outputs: + artifact-id: ${{ steps.upload.outputs.artifact-id }} + steps: + # Fail before a long build; publish checks these again. + - name: Validate inputs + env: + DEPLOY_ENVIRONMENT: ${{ inputs.environment }} + CALLER_SHA: ${{ github.sha }} + PROVIDER: ${{ inputs.workload-identity-provider }} + SERVICE_ACCOUNT: ${{ inputs.service-account }} + IMAGE_NAME: ${{ inputs.image }} + run: | + set -euo pipefail + [[ "$DEPLOY_ENVIRONMENT" =~ ^[a-z][a-z0-9-]{0,62}$ ]] \ + || { echo 'environment must be a lowercase environment name' >&2; exit 1; } + [[ "$CALLER_SHA" =~ ^[0-9a-f]{40}$ ]] || exit 1 + [[ "$PROVIDER" =~ ^projects/[0-9]+/locations/global/workloadIdentityPools/[a-z0-9-]+/providers/[a-z0-9-]+$ ]] \ + || { echo 'workload-identity-provider is invalid' >&2; exit 1; } + [[ "$SERVICE_ACCOUNT" =~ ^[a-z0-9-]+@[a-z0-9-]+\.iam\.gserviceaccount\.com$ ]] \ + || { echo 'service-account is invalid' >&2; exit 1; } + [[ "$IMAGE_NAME" =~ ^[a-z0-9-]+-docker\.pkg\.dev/[a-z0-9-]+/[a-z0-9-]+/[a-z0-9-]+$ ]] \ + || { echo 'image is invalid' >&2; exit 1; } + + - name: Check out caller at the triggering commit + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.sha }} + persist-credentials: false + + - name: Set up Buildx + uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1 + + - name: Build OCI archive + env: + BUILD_CONTEXT: ${{ inputs.context }} + DOCKERFILE: ${{ inputs.dockerfile }} + BUILD_TARGET: ${{ inputs.target }} + BUILD_ARG_VARS: ${{ inputs.build-arg-vars }} + BUILD_ARG_SECRETS: ${{ inputs.build-arg-secrets }} + BUILD_SECRETS: ${{ inputs.build-secrets }} + # Always passed as --build-arg SOURCE_COMMIT, e.g. for error-tracking + # release names; build contexts usually exclude .git. + SOURCE_COMMIT: ${{ github.sha }} + VARS_JSON: ${{ toJSON(vars) }} + # Callers name the secrets they need; confined to build, with no OIDC. + SECRETS_JSON: ${{ toJSON(secrets) }} # zizmor: ignore[overprovisioned-secrets] + run: | + set -euo pipefail + python3 -I - <<'PY' + import json + import os + from pathlib import Path + import re + import subprocess + + workspace = Path(os.environ["GITHUB_WORKSPACE"]).resolve() + context = (workspace / os.environ["BUILD_CONTEXT"]).resolve() + dockerfile = (workspace / os.environ["DOCKERFILE"]).resolve() + if not context.is_relative_to(workspace) or not context.is_dir(): + raise SystemExit("Build context must be a directory within the checkout") + if not dockerfile.is_relative_to(workspace) or not dockerfile.is_file(): + raise SystemExit("Dockerfile must be a file within the checkout") + + archive = Path(os.environ["RUNNER_TEMP"]) / "build-image.oci.tar" + args = ["docker", "buildx", "build", "--file", str(dockerfile), + "--output", f"type=oci,dest={archive}", "--provenance=false"] + if os.environ["BUILD_TARGET"]: + args += ["--target", os.environ["BUILD_TARGET"]] + if not re.fullmatch(r"[0-9a-f]{40}", os.environ["SOURCE_COMMIT"]): + raise SystemExit("SOURCE_COMMIT must be a full commit SHA") + args += ["--build-arg", "SOURCE_COMMIT"] + + # Pass --build-arg NAME or --secret id=NAME,env=NAME and resolve values + # in the subprocess environment, never in argv or echoed commands. Do not pass the + # serialized contexts (or unrequested secrets) to the Docker client. + build_env = dict(os.environ) + build_env.pop("VARS_JSON", None) + build_env.pop("SECRETS_JSON", None) + seen = set() + for names_key, values_key in [("BUILD_ARG_VARS", "VARS_JSON"), + ("BUILD_ARG_SECRETS", "SECRETS_JSON"), + ("BUILD_SECRETS", "SECRETS_JSON")]: + values = json.loads(os.environ[values_key]) + for line in os.environ[names_key].splitlines(): + name = line.strip() + if not name: + continue + if not re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", name): + raise SystemExit("Invalid build argument name") + if name in seen or name in build_env: + raise SystemExit("Duplicate or reserved build argument name") + if name.upper() not in values: + raise SystemExit("Requested build argument is not defined") + value = values[name.upper()] + if not isinstance(value, str) or "\0" in value: + raise SystemExit("Build argument must be a string without NUL") + seen.add(name) + build_env[name] = value + if names_key == "BUILD_SECRETS": + args += ["--secret", f"id={name},env={name}"] + else: + args += ["--build-arg", name] + args += ["--", str(context)] + result = subprocess.run(args, env=build_env, check=False) + # CalledProcessError would include argv; return only the exit status. + raise SystemExit(result.returncode) + PY + + - name: Upload OCI archive + id: upload + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + # One image per environment per workflow run. + name: oci-${{ inputs.environment }}-${{ github.run_id }}-${{ github.run_attempt }} + path: ${{ runner.temp }}/build-image.oci.tar + if-no-files-found: error + retention-days: 1 + compression-level: 0 + + publish: + needs: build + runs-on: ubuntu-24.04 + timeout-minutes: 20 + environment: ${{ inputs.environment }} + permissions: + contents: read + id-token: write + outputs: + digest: ${{ steps.push.outputs.digest }} + image: ${{ steps.push.outputs.image }} + # No checkout, local actions, caller scripts, docker load or container runs + # in this job. The only caller-produced input is the OCI data artifact. + steps: + - name: Validate settings and derive the tag + id: destination + env: + BUILD_ARTIFACT_ID: ${{ needs.build.outputs.artifact-id }} + CALLER_SHA: ${{ github.sha }} + DEPLOY_ENVIRONMENT: ${{ inputs.environment }} + PROVIDER: ${{ inputs.workload-identity-provider }} + SERVICE_ACCOUNT: ${{ inputs.service-account }} + IMAGE_NAME: ${{ inputs.image }} + run: | + set -euo pipefail + [[ "$BUILD_ARTIFACT_ID" =~ ^[0-9]+$ ]] || exit 1 + [[ "$CALLER_SHA" =~ ^[0-9a-f]{40}$ ]] || exit 1 + [[ "$PROVIDER" =~ ^projects/[0-9]+/locations/global/workloadIdentityPools/[a-z0-9-]+/providers/[a-z0-9-]+$ ]] \ + || { echo 'workload-identity-provider is invalid' >&2; exit 1; } + [[ "$SERVICE_ACCOUNT" =~ ^[a-z0-9-]+@[a-z0-9-]+\.iam\.gserviceaccount\.com$ ]] \ + || { echo 'service-account is invalid' >&2; exit 1; } + [[ "$IMAGE_NAME" =~ ^([a-z0-9-]+)-docker\.pkg\.dev/[a-z0-9-]+/[a-z0-9-]+/[a-z0-9-]+$ ]] \ + || { echo 'image is invalid' >&2; exit 1; } + printf 'registry=%s-docker.pkg.dev\n' "${BASH_REMATCH[1]}" >> "$GITHUB_OUTPUT" + tag="$CALLER_SHA-$(date -u +%Y%m%d%H%M%S)" + if [[ "$DEPLOY_ENVIRONMENT" == production ]]; then + tag="prod-$tag" + fi + printf 'tag=%s\n' "$tag" >> "$GITHUB_OUTPUT" + + - name: Download only this build's artifact + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + artifact-ids: ${{ needs.build.outputs.artifact-id }} + path: ${{ runner.temp }}/incoming-image + merge-multiple: true + + - name: Unpack OCI data without executing caller content + run: | + set -euo pipefail + python3 -I - <<'PY' + import hashlib + import os + from pathlib import Path + import re + import tarfile + + archive = Path(os.environ["RUNNER_TEMP"]) / "incoming-image/build-image.oci.tar" + root = Path(os.environ["RUNNER_TEMP"]) / "publish-oci" + root.mkdir(mode=0o700) + total = 0 + entries = 0 + # crane expects an OCI directory, not an OCI tarball. Copy only the + # OCI layout's data files; never extract links, paths or file modes. + with tarfile.open(archive, mode="r|") as source: + for member in source: + entries += 1 + if entries > 100000: + raise SystemExit("Too many OCI entries") + name = member.name.removeprefix("./") + if member.isdir() and name.rstrip("/") in (".", "blobs", "blobs/sha256"): + continue + is_blob = re.fullmatch(r"blobs/sha256/[0-9a-f]{64}", name) + if not member.isfile() or (name not in ("index.json", "oci-layout") and not is_blob): + raise SystemExit("Unexpected OCI entry") + total += member.size + if member.size < 0 or total > 10 * 1024**3: + raise SystemExit("OCI archive exceeds 10 GiB limit") + target = root / name + target.parent.mkdir(mode=0o700, parents=True, exist_ok=True) + digest = hashlib.sha256() + # Exclusive creation rejects duplicate entries. No supplied + # entry can create a symlink or escape this isolated directory. + with source.extractfile(member) as incoming, target.open("xb") as output: + os.chmod(target, 0o600) + while chunk := incoming.read(1024 * 1024): + digest.update(chunk) + output.write(chunk) + if is_blob and digest.hexdigest() != target.name: + raise SystemExit("OCI blob digest mismatch") + if not (root / "index.json").is_file() or not (root / "oci-layout").is_file(): + raise SystemExit("Missing OCI layout metadata") + PY + + - name: Install crane with a pinned archive checksum + # This maintained release installer verifies the digest, and does not + # perform the unrelated GHCR login in imjasonh/setup-crane. + uses: jaxxstorm/action-install-gh-release@25e24d2d23ae098373794ef1d6faecb48ee52da8 # v3.0.0 + with: + repo: google/go-containerregistry + tag: v0.22.1 + platform: linux + arch: x86_64 + digest: 0ab7a1d6932a213aed964ce97666c3077fe691c8606413674a8b3e0b9ec4cda0 + cache: disable + + - name: Install cosign + uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2 + with: + cosign-release: v3.1.3 + + - name: Authenticate as the repository publisher + id: auth + uses: google-github-actions/auth@7c6bc770dae815cd3e89ee6cdf493a5fab2cc093 # v3.0.0 + with: + workload_identity_provider: ${{ inputs.workload-identity-provider }} + service_account: ${{ inputs.service-account }} + token_format: access_token + create_credentials_file: false + export_environment_variables: false + + - name: Log in to the registry + env: + REGISTRY: ${{ steps.destination.outputs.registry }} + ACCESS_TOKEN: ${{ steps.auth.outputs.access_token }} + run: | + set -euo pipefail + printf '%s' "$ACCESS_TOKEN" | crane auth login "$REGISTRY" \ + --username oauth2accesstoken --password-stdin + + - name: Push image and record the registry digest + id: push + env: + IMAGE_NAME: ${{ inputs.image }} + IMAGE_TAG: ${{ steps.destination.outputs.tag }} + run: | + set -euo pipefail + crane push "$RUNNER_TEMP/publish-oci" "$IMAGE_NAME:$IMAGE_TAG" + digest="$(crane digest "$IMAGE_NAME:$IMAGE_TAG")" + [[ "$digest" =~ ^sha256:[0-9a-f]{64}$ ]] || exit 1 + printf 'digest=%s\n' "$digest" >> "$GITHUB_OUTPUT" + printf 'image=%s@%s\n' "$IMAGE_NAME" "$digest" >> "$GITHUB_OUTPUT" + + - name: Sign image by digest using GitHub OIDC + env: + IMAGE: ${{ steps.push.outputs.image }} + run: | + set -euo pipefail + cosign sign --yes "$IMAGE" + + - name: Remove registry login + if: ${{ always() && steps.auth.outcome == 'success' }} + env: + REGISTRY: ${{ steps.destination.outputs.registry }} + run: crane auth logout "$REGISTRY" diff --git a/README.md b/README.md index 2e7c5e6..804e620 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,107 @@ # ci-components -Shared reusable GitHub Actions workflows + +Shared reusable GitHub Actions workflows. Patch a job here once instead of in +every repository. + +## Versions + +Same scheme as our GitLab CI components: semver tags (`v1.2.0`) plus a moving +major branch (`v1`). Pin the major branch: + +```yaml +uses: Blockstream/ci-components/.github/workflows/publish.yml@v1 +``` + +- Changes land on `main` by pull request. +- A release is a pull request from `main` into `v1`, then a `v1.x.y` tag on + the merge commit. Breaking changes start a `v2` branch. +- Release tags are immutable. Never create a tag named `v1`: it would shadow + the branch. + +Some consumers only trust the workflow at `refs/heads/v1` (for example to +grant registry credentials or to verify signatures), so a merge into `v1` +ships to every caller. + +## `publish.yml`: build, publish and sign a container image + +Two jobs: + +1. `build` checks out the caller at the triggering commit and builds an OCI + archive. It runs in the `-build` environment (or + `build-environment`) and has no OIDC token. +2. `publish` runs no caller code. It pushes only the OCI data from `build`, + authenticates through workload identity federation and signs the digest + with keyless cosign. It runs in the `environment` environment. + +The tag is `-`, prefixed `prod-` when +`environment` is `production`. + +### Caller + +```yaml +on: + push: + branches: [main] + +permissions: {} + +jobs: + publish: + permissions: + contents: read + id-token: write + uses: Blockstream/ci-components/.github/workflows/publish.yml@v1 + with: + # Each repository brings its own identity, e.g. repository variables. + workload-identity-provider: ${{ vars.GCP_WORKLOAD_IDENTITY_PROVIDER }} + service-account: ${{ vars.GCP_SERVICE_ACCOUNT }} + image: ${{ vars.IMAGE_NAME }} + environment: production + dockerfile: Dockerfile + build-arg-vars: | + PUBLIC_URL + build-secrets: | + NPM_TOKEN + secrets: inherit # only when build-secrets or build-arg-secrets are used +``` + +### Inputs + +| Input | Default | Use | +|---|---|---| +| `workload-identity-provider` | (required) | `projects//locations/global/workloadIdentityPools//providers/` | +| `service-account` | (required) | Publisher, `@.iam.gserviceaccount.com` | +| `image` | (required) | Image without tag, `-docker.pkg.dev///` | +| `environment` | (required) | Environment of the publish job (deployment branch policy) | +| `build-environment` | `-build` | Environment of the build job; holds build variables and secrets | +| `context` | `.` | Build context within the checkout | +| `dockerfile` | `Dockerfile` | Dockerfile within the checkout | +| `target` | | Dockerfile target | +| `build-arg-vars` | | Variable names to pass as `--build-arg` | +| `build-secrets` | | Secret names to pass as BuildKit secrets (`RUN --mount=type=secret,id=NAME`) | +| `build-arg-secrets` | | Deprecated: secret names to pass as `--build-arg` | + +Every build also gets `--build-arg SOURCE_COMMIT=`; declare +`ARG SOURCE_COMMIT` to use it. Values are passed through the Docker process +environment, never on the command line. One image per environment per +workflow run. + +Outputs: `digest`, `image` (`@`). + +The workflow sets no identity of its own. Which repository, ref and +environment may publish is enforced by the caller's identity provider +condition, the service account's workload identity binding and the +environment's deployment branch policy, not by this file. + +Verify a published image: + +```sh +cosign verify \ + --certificate-oidc-issuer https://token.actions.githubusercontent.com \ + --certificate-identity https://github.com/Blockstream/ci-components/.github/workflows/publish.yml@refs/heads/v1 \ + --certificate-github-workflow-repository / \ + @ +``` + +Always check `--certificate-github-workflow-repository`: this workflow is +public, so the identity alone does not say which repository built the image.