Skip to content

About

Shared reusable GitHub Actions workflows

Resources

Stars

0 stars

Watchers

0 watching

Forks

Latest commit

 

History

10 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 

Repository files navigation

ci-components

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:

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

Three stages:

  1. prepare validates public inputs and selects the build matrix without credentials or a checkout.
  2. build checks out the caller at the triggering commit and builds an OCI archive. It runs in the <environment>-build environment (or build-environment) and has no OIDC token or registry login. With no platforms input, it runs once on ubuntu-24.04 without --platform. Otherwise each platform builds separately on its native runner.
  3. 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 once per run. Platform archives are checked and merged into one OCI index before publishing. It runs in the environment environment.

The tag is <commit>-<UTC yyyymmddHHMMSS>, prefixed prod- when environment is production. Non-empty tags replaces that derived tag entirely, including the production prefix. Explicit tags must not start with prod- (in any case) or have the derived <hex>-<number> form: every environment can pass tags, so only derived tags may look like derived tags.

Caller

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 empty Publisher identity provider; falls back to the same-named secret
service-account empty Publisher service account; falls back to the same-named secret
image empty Image without tag, or repository prefix when using image-name; falls back to the same-named secret
image-name empty Public relative image path appended to the resolved image, e.g. plugins/example
tags empty Newline-separated explicit tags; replaces the derived tag
moving-tags empty Newline-separated subset of tags that may change digest
platforms empty Newline-separated linux/amd64 and/or linux/arm64; empty preserves the original single build
build-args empty Newline-separated literal KEY=VALUE pairs; public values only
static-assets-path empty Absolute directory in the image, e.g. /app/.next/static; requires static-assets-destination
static-assets-destination empty GCS bucket and optional prefix, e.g. gs://example-static-bucket/_next/static; requires static-assets-path
environment (required) Environment of the publish job (deployment branch policy)
build-environment <environment>-build Environment of the build job; holds build variables and secrets. Must differ from environment
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=<full commit SHA>; declare ARG SOURCE_COMMIT to use it. Named variable and secret values are passed through the Docker process environment. Literal build-args values are passed as individual command arguments, without shell evaluation; spaces and extra = characters are preserved. Keys must match [A-Za-z_][A-Za-z0-9_]*. Duplicate argument names and overrides of SOURCE_COMMIT are rejected.

Tags must match ^[A-Za-z0-9_][A-Za-z0-9._-]{0,127}$. Explicit tags are immutable unless listed in moving-tags. Before any push, every tag is checked: a matching digest needs no update; a different digest on an immutable tag fails the job. Only explicit registry responses indicating absence permit creating a tag. Authentication and network errors fail closed. Empty lines are ignored; other whitespace is significant. Duplicate tags and platforms are rejected.

linux/amd64 uses ubuntu-24.04; linux/arm64 uses ubuntu-24.04-arm. No other platforms are accepted. Every platform build explicitly passes --platform; publishing pushes one index and applies all requested tags to it.

image-name must match ^[a-z0-9]+([._-][a-z0-9]+)*(/[a-z0-9]+([._-][a-z0-9]+)*)*$. For example, a secret prefix <registry>/<project>/<repository> and public image-name: plugins/example resolve to <registry>/<project>/<repository>/plugins/example. This supports several images from one caller repository, with a public image name selected by the caller's release logic. Distinct image names also receive distinct artifact names. Without image-name, retain one image per environment per workflow run.

Outputs remain digest and image (<effective-image>@<digest>). When platforms is set, the digest and signature identify the index. Otherwise they identify the original single-build result.

Static assets across rolling deployments

Set both static asset inputs to upload the image's content-hashed assets to a shared bucket that retains all builds. Both default to empty: the upload step is skipped and existing publishing behavior is unchanged. Paths must be absolute, without .. segments or control characters. Destinations must be gs://BUCKET/PREFIX with a lowercase bucket name (3–63 characters, letters, digits, ., _, -; first and last characters alphanumeric) and a prefix containing only letters, digits, ., _, ~, /, -, without .. segments. Repeated slashes, . segments and trailing slashes are normalized before publishing. If GitHub drops a validated prepare output because it contains a registered secret, the upload step fails closed instead of skipping assets.

The publish job reads the verified OCI layout without running the image or any caller code. Static assets require a single-platform build: prepare rejects them together with more than one entry in platforms, because separate builds can produce different asset names (for example a random build ID) and only one platform's assets would be uploaded. Layers are applied in order, including file and ancestor whiteouts and opaque directories. Gzip and uncompressed tar layers are supported; zstd layers fail clearly. Only regular files are uploaded. Symlinks, hardlinks and special files in the selected directory or its ancestors fail the job, even if a later layer would remove them. Absolute or traversing tar paths and sparse entries are rejected. Duplicate file entries use the last occurrence; later layers win. A missing directory or one with zero files fails the job.

Limits are 50 MiB per file, 2 GiB of retained uncompressed assets and 20,000 retained files, enforced while applying layers. Parsing also limits each expanded layer to 10 GiB, each layer and retained path set to 200,000 entries, tar paths to 4096 UTF-8 bytes, and extension headers to 1 MiB. The entry, UTF-8 and newline checks apply to every layer of the image, not only to the asset directory, so an image with a larger layer or non-UTF-8 file names cannot use this feature. Asset processing and upload have a ten-minute overall deadline, with eight upload workers and bounded retries for transient failures. The upload step allows up to 11 minutes; the publish job has a 35-minute timeout to leave room for the remaining publishing steps (20 minutes, as before, when the static asset inputs are not set).

Uploads finish before any image push or tag update. A failure prevents the push and signing steps. Objects are created with ifGenerationMatch=0: existing objects are skipped only when the original SHA-256 and serving metadata match, and their stored-byte MD5 matches. If gzip bytes differ, a bounded download must match the object's stored-byte MD5 and decompress to the same original SHA-256; this allows compression-library changes. Any content or serving-metadata collision fails the job and names the object (URL-quoted and truncated to 300 characters). An existing object must also have no Content-Disposition or Content-Language. Objects are never overwritten or deleted. Partial uploads from a failed run can be safely retried. Use a separate bucket or prefix for each trust level, such as staging and production. To recover from a collision, an operator must delete the colliding objects before retrying; the publisher identity cannot delete them. Files receive extension-specific content types and Cache-Control: public, max-age=31536000, immutable. JS/MJS, CSS, source maps, JSON, SVG, TXT, HTML and WASM are stored as gzip bytes with a fixed timestamp and Content-Encoding: gzip.

Grant the publisher service account storage.objects.create and storage.objects.get on the bucket, in addition to its existing registry permissions. A custom role containing those two storage permissions is sufficient; listing, updating and deleting objects are unnecessary. Authentication uses the existing short-lived access token, with no credentials file or cloud environment export. The build job receives no cloud credentials.

The bucket must be browser-readable at the configured asset URL. Configure bucket CORS for the app's origins, including font requests. Retain old build objects for as long as clients may use them; the workflow performs no cleanup. Use this only for public, content-hashed assets: a changed file at an existing object name is a collision, not an update.

Example caller (publisher settings are caller-provided variables):

permissions: {}

jobs:
  publish:
    permissions:
      contents: read
      id-token: write
    uses: Blockstream/ci-components/.github/workflows/publish.yml@v1
    with:
      workload-identity-provider: ${{ vars.GCP_WORKLOAD_IDENTITY_PROVIDER }}
      service-account: ${{ vars.GCP_SERVICE_ACCOUNT }}
      image: ${{ vars.IMAGE_NAME }}
      environment: production
      static-assets-path: /app/.next/static
      static-assets-destination: gs://example-static-bucket/_next/static

Set the corresponding Next.js prefix at build time in next.config.mjs:

export default {
  assetPrefix: 'https://storage.googleapis.com/example-static-bucket',
};

Next.js appends /_next/static/... to this prefix. The final Docker image must contain /app/.next/static. This publishes that directory only; arrange separate hosting for any other assets your application needs.

Publisher secrets

The optional secrets workload-identity-provider, service-account, and image provide fallbacks for the same-named inputs. Each non-empty input takes precedence; publishing fails clearly if both sources are empty. Prefer secrets for private publisher settings in public callers, whose workflow logs are public. Publisher settings are resolved and validated in publish; the plain image-name is also validated before building. Secret-derived registry and image names are masked, including derived hostnames.

Publisher secrets cannot be combined with build-secrets or build-arg-secrets, including through secrets: inherit. The existing named build-secret feature serializes the secrets context, and workflow expressions cannot filter arbitrary keys out of it. The workflow rejects this combination and withholds the context from build whenever any publisher secret is present. Existing callers using publisher inputs and no same-named publisher secrets retain the named build-secret feature. Public build-args and build-arg-vars work with either publisher settings source.

GitHub may suppress the reusable workflow's image output when it contains a secret. Consumers using a secret prefix can combine that secret, image-name, and the public digest output themselves within a job that needs the reference.

Versioned multi-platform release

This caller uses placeholder secret names and a public image path. A tag such as v1.2.3 publishes an immutable version tag for both platforms. Serialize publishes for a given image so two runs cannot race between checking and writing a tag; registry-enforced immutability can protect against other publishers too.

on:
  push:
    tags: ['v*']

permissions: {}

concurrency:
  group: publish-example
  cancel-in-progress: false

jobs:
  release:
    permissions:
      contents: read
      id-token: write
    uses: Blockstream/ci-components/.github/workflows/publish.yml@v1
    with:
      environment: production
      image-name: plugins/example
      tags: ${{ github.ref_name }}
      platforms: |
        linux/amd64
        linux/arm64
      build-args: |
        VERSION=${{ github.ref_name }}
    secrets:
      workload-identity-provider: ${{ secrets.PUBLISHER_IDENTITY_PROVIDER }}
      service-account: ${{ secrets.PUBLISHER_SERVICE_ACCOUNT }}
      image: ${{ secrets.REGISTRY_PREFIX }}

Moving latest on the default branch

Replace main with the caller's default branch. This example publishes an immutable commit tag and permits only latest to move. Use the same concurrency group as other workflows publishing this image.

on:
  push:
    branches: [main]

permissions: {}

concurrency:
  group: publish-example
  cancel-in-progress: false

jobs:
  latest:
    permissions:
      contents: read
      id-token: write
    uses: Blockstream/ci-components/.github/workflows/publish.yml@v1
    with:
      environment: production
      image-name: plugins/example
      tags: |
        ${{ github.sha }}
        latest
      moving-tags: latest
      platforms: |
        linux/amd64
        linux/arm64
    secrets:
      workload-identity-provider: ${{ secrets.PUBLISHER_IDENTITY_PROVIDER }}
      service-account: ${{ secrets.PUBLISHER_SERVICE_ACCOUNT }}
      image: ${{ secrets.REGISTRY_PREFIX }}

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:

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 <owner>/<caller-repo> \
  <image>@<digest>

Always check --certificate-github-workflow-repository: this workflow is public, so the identity alone does not say which repository built the image. A tag does not prove which branch built an image. To accept only images built from one ref, also check it, for example --certificate-github-workflow-ref refs/heads/main.

About

Shared reusable GitHub Actions workflows

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors