diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index c7d26618..87026f24 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -220,7 +220,7 @@ jobs: - name: Check if we need to install browsers id: browsers - uses: actions/github-script@v7 + uses: actions/github-script@v8 with: script: | const { existsSync } = require('fs'); diff --git a/.github/workflows/create-release.yaml b/.github/workflows/create-release.yaml new file mode 100644 index 00000000..32be2c59 --- /dev/null +++ b/.github/workflows/create-release.yaml @@ -0,0 +1,161 @@ +# Create Release +# +# Template to use with this workflow: +# https://github.com/verkstedt/.github/tree/main/workflow-templates/create-release.yaml +# +# Pushes a commit that updates version data directly to the main branch. +# +# Requires GitHub token with persmissions to do that. + +name: Create Release + +on: + workflow_call: + inputs: + release-type: + description: 'Type of release (major, minor, patch, or prerelease)' + type: string + default: 'patch' + working-directory: + description: 'Working directory (where package.json is located)' + type: string + default: '.' + +jobs: + release: + name: 'Create Release' + runs-on: ubuntu-latest + + permissions: + contents: write + pull-requests: write + issues: write + + steps: + - name: Verify inputs + run: | + if ! echo "${{ inputs.release-type }}" | grep -qE '^(major|minor|patch|prerelease)$' + then + echo "Invalid release-type: ${{ inputs.release-type }}. Must be one of: major, minor, patch, prerelease." + exit 64 # EX_USAGE + fi + + if [ "${{ inputs.release-type }}" = "prerelease" ] + then + echo "ERROR: Prerelease not supported yet." # TODO Support prerelease + exit 64 # EX_USAGE + fi + + - name: Set environment variables + env: + ENV_VARS: ${{ secrets.env_vars }} + run: | + echo "$ENV_VARS" >> "$GITHUB_ENV" + + - name: Setup + id: setup + uses: verkstedt/actions/setup@new-actions-for-building-pushing-and-releasing + with: + working-directory: ${{ inputs.working-directory }} + github-npm-registry-personal-access-token: ${{ secrets.GH_NPM_REGISTRY_PERSONAL_ACCESS_TOKEN }} + + - name: Release + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + # Note: This script doesn’t access steps.*.outputs and + # inputs.* directly to make it easier to copy it to separate + # file for testing + PACKAGE_MANAGER: ${{ steps.setup.outputs.package-manager }} + RELEASE_TYPE: ${{ inputs.release-type }} + run: | + echo "::group::Prepare semantic-release configuration" + # TODO Move this to separate package, that can be installed, together with dependencies + release_config_mjs="$( mktemp --suffix=.mjs )" + cat > "$release_config_mjs" << 'RELEASE_CONFIG_MJS_EOF' + /** + * @type {import('semantic-release').GlobalConfig} + */ + export default { + plugins: [ + ["@semantic-release/exec", { + // Do not analyse commits to determine release type + analyzeCommitsCmd: `echo ${process.env.RELEASE_TYPE}`, + // Update version.txt, if exists + prepareCmd: 'if [ -f version.txt ]; then echo "${nextRelease.version}" > version.txt; fi', + // Use GitHub API to generate release notes + generateNotesCmd: ` + gh api \ + -X POST \ + repos/:owner/:repo/releases/generate-notes \ + -f tag_name='\${nextRelease.gitTag}' \ + -f previous_tag_name='\${lastRelease.gitTag}' \ + --jq '"# " + .name + "\n\n" + .body' + `, + // Write release notes to GitHub Actions summary + successCmd: `echo '\${nextRelease.notes}' >> \$GITHUB_STEP_SUMMARY`, + }], + // Update version package.json, do not publish to npm registry + ['@semantic-release/npm', { + npmPublish: false + }], + // Write changelog + ['@semantic-release/changelog'], + // Commit and push changed files + ['@semantic-release/git', { + assets: ['CHANGELOG.md', 'package.json', 'package-lock.json', 'version.txt'], + message: 'chore(release): ${nextRelease.version}\n\n\${nextRelease.notes}' + }], + // Register a GitHub release + ['@semantic-release/github'], + ] + } + RELEASE_CONFIG_MJS_EOF + echo "::endgroup::" + + # Clean up + trap 'rm -fv "$release_config_mjs"' EXIT INT TERM + + # FIXME This feels wrong, but couldn’t find a way of running semantic-release without having plugins installed + echo "::group::Install semantic-release plugins" + npm_pkg_args=$( + node --input-type=module --eval " + const { default: config } = await import(process.argv[1], { assert: { type: 'module' } }); + process.stdout.write( + config.plugins + .map(p => Array.isArray(p) ? p[0] : p) + .filter(p => ! /^(\\.\\/|\\.\\.\\/|\/)/.test(p)) + .join('\\n') + ); + " "$release_config_mjs" | + # Skip plugins bundled with semantic-release + # https://semantic-release.gitbook.io/semantic-release/usage/plugins#default-plugins + # Docs also list @semantic-release/npm, but it doesn’t seem true + grep -vE '@semantic-release/commit-analyzer|@semantic-release/release-notes-generator|@semantic-release/github' + ) + + if [ -n "$npm_pkg_args" ] + then + case "$PACKAGE_MANAGER" in + yarn) + # shellcheck disable=SC2086 + yarn add -D $npm_pkg_args + ;; + npm) + # shellcheck disable=SC2086 + npm install -D $npm_pkg_args + ;; + *) + echo "Unsupported package manager: $PACKAGE_MANAGER" + exit 69 # EX_UNAVAILABLE + ;; + esac + + git restore package.json || : + git restore package-lock.json || : + git restore yarn.lock || : + fi + echo "::endgroup::" + + echo "::group::Run semantic-release" + npx --yes semantic-release --extends "$release_config_mjs" "$@" + echo "::endgroup::" diff --git a/.github/workflows/docker-build-push.yaml b/.github/workflows/docker-build-push.yaml new file mode 100644 index 00000000..3d4b4015 --- /dev/null +++ b/.github/workflows/docker-build-push.yaml @@ -0,0 +1,399 @@ +# Docker Build and Push +# +# Template to use with this workflow: +# https://github.com/verkstedt/.github/tree/main/workflow-templates/docker-build-push.yaml +# +# Build and push Docker images to multiple registries: +# - GitHub Container Registry (GHCR) +# - Amazon ECR (AWS Elastic Container Registry) +# - Google Artifact Registry +# +# Will tag all images with: +# - sha of the commit (`git-`) +# +# If version change is detected: +# - NO (meant for preview deployment) +# - tag with `edge` +# - YES (meant for production deployment, unless it’s prerelease) +# - tag with `v` +# - if version is not prerelease also with: +# - `latest` +# - `v` and `v.` + +name: Docker Build and Push +on: + workflow_call: + secrets: + BUILD_ARGS: + description: 'Build arguments for Docker build (multiline string)' + + inputs: + dry-run: + description: 'Dry run mode (no pushing to registries)' + type: boolean + default: false + + # Build configuration + context: + description: 'Docker build context path' + type: string + default: '.' + dockerfile: + description: 'Path to Dockerfile' + type: string + default: 'Dockerfile' + platforms: + description: 'Platforms to build for (comma-separated)' + type: string + default: 'linux/amd64' + + # GitHub Container Registry + github_enable: + description: 'Push to GitHub Container Registry' + type: boolean + default: true + github_package_name: + description: 'GitHub Container Registry package name. Defaults to GitHub repository name' + type: string + default: '' + + # Google Artifact Registry + google_enable: + description: 'Push to Google Artifact Registry' + type: boolean + default: false + google_workload_identity_provider: + description: 'Google Cloud workload identity provider. Required, if google_enable is true' + type: string + default: '' + google_service_account: + description: 'Google Cloud service account email. Required, if google_enable is true' + type: string + default: '' + google_project: + description: 'Google Cloud project ID. Required, if google_enable is true' + type: string + default: '' + google_registry_location: + description: 'Google Artifact Registry location. Required, if google_enable is true' + type: string + default: '' + google_repository: + description: 'Google Artifact Registry repository path. Required, if google_enable is true' + type: string + default: '' + google_package_name: + description: 'Google Artifact Registry package name. Defaults to GitHub repository name' + type: string + default: '' + + # Amazon Elastic Container Registry + amazon_enable: + description: 'Push to Amazon ECR' + type: boolean + default: false + amazon_aws_region: + description: 'Amazon AWS region. Required, if amazon_enable is true' + type: string + default: '' + amazon_aws_account_id: + description: 'Amazon AWS account ID. Required, if amazon_enable is true' + type: string + default: '' + amazon_role_to_assume: + description: 'Amazon AWS IAM role to assume. Required, if amazon_enable is true' + type: string + default: '' + amazon_package_name: + description: 'Amazon ECR package name. Defaults to GitHub repository name' + type: string + default: '' + +jobs: + build_publish: + name: 'Docker Build and Publish' + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + id-token: write + + steps: + - name: 'GitHub Container Registry: Setup' + id: github + run: | + if [ "${{ inputs.github_enable }}" != "true" ] + then + echo "enabled=false" | tee -a "$GITHUB_OUTPUT" + else + missing_inputs='' + missing_secrets='' + + [ -n "${{ secrets.GITHUB_TOKEN }}" ] || missing_secrets="$missing_secrets GITHUB_TOKEN" + + if [ -n "$missing_inputs$missing_secrets" ] + then + echo "::error::Missing required inputs ($missing_inputs) or secrets ($missing_secrets)." >&2 + exit 64 # EX_USAGE + fi + + # ghcr.io/:ORGANISATION/:IMAGE_NAME + + registry="ghcr.io/${{ github.repository_owner }}" + echo "registry=$registry" | tee -a "$GITHUB_OUTPUT" + + package_name="${{ inputs.github_package_name }}" + if [ -z "$package_name" ] + then + package_name="${{ github.event.repository.name }}" + fi + echo "package_name=$package_name" | tee -a "$GITHUB_OUTPUT" + + echo "enabled=true" | tee -a "$GITHUB_OUTPUT" + fi + + - name: 'GitHub Container Registry: Authenticate' + if: steps.github.outputs.enabled == 'true' + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.repository_owner }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: 'Google Artifact Registry: Setup' + id: google + run: | + if [ "${{ inputs.google_enable }}" != "true" ] + then + echo "enabled=false" | tee -a "$GITHUB_OUTPUT" + else + missing_inputs='' + missing_secrets='' + + [ -n "${{ inputs.google_project }}" ] || missing_inputs="$missing_inputs google_project" + [ -n "${{ inputs.google_registry_location }}" ] || missing_inputs="$missing_inputs google_registry_location" + [ -n "${{ inputs.google_repository }}" ]|| missing_inputs="$missing_inputs google_repository" + + [ -n "${{ inputs.google_workload_identity_provider }}" ] || missing_inputs="$missing_inputs google_workload_identity_provider" + [ -n "${{ inputs.google_service_account }}" ] || missing_inputs="$missing_inputs google_service_account" + + if [ -n "$missing_inputs$missing_secrets" ] + then + echo "::error::Missing required inputs ($missing_inputs) or secrets ($missing_secrets)." >&2 + exit 64 # EX_USAGE + fi + + # :LOCATION-docker.pkg.dev/:PROJECT/:REPOSITORY/:IMAGE_NAME + + registry="${{ inputs.google_registry_location }}-docker.pkg.dev/${{ inputs.google_project }}/${{ inputs.google_repository }}" + echo "registry=$registry" | tee -a "$GITHUB_OUTPUT" + + package_name="${{ inputs.google_package_name }}" + if [ -z "$package_name" ] + then + package_name="${{ github.event.repository.name }}" + fi + echo "package_name=$package_name" | tee -a "$GITHUB_OUTPUT" + + echo "enabled=true" | tee -a "$GITHUB_OUTPUT" + fi + + - name: 'Google Artifact Registry: Authenticate to Google Cloud' + if: steps.google.outputs.enabled == 'true' + id: google_auth + uses: google-github-actions/auth@v3 + with: + token_format: access_token + project_id: ${{ inputs.google_project }} + workload_identity_provider: ${{ inputs.google_workload_identity_provider }} + service_account: ${{ inputs.google_service_account }} + + - name: 'Google Artifact Registry: Authenticate to Google Artifact Registry' + if: steps.google.outputs.enabled == 'true' + uses: docker/login-action@v3 + with: + registry: ${{ steps.google.outputs.registry }} + username: oauth2accesstoken + password: ${{ steps.google_auth.outputs.access_token }} + + - name: 'Amazon Elastic Container Registry: Setup' + id: amazon + run: | + if [ "${{ inputs.amazon_enable }}" != "true" ] + then + echo "enabled=false" | tee -a "$GITHUB_OUTPUT" + else + missing_inputs='' + missing_secrets='' + + [ -n "${{ inputs.amazon_aws_account_id }}" ] || missing_inputs="$missing_inputs amazon_aws_account_id" + [ -n "${{ inputs.amazon_aws_region }}" ] || missing_inputs="$missing_inputs amazon_aws_region" + [ -n "${{ inputs.amazon_role_to_assume }}" ] || missing_inputs="$missing_inputs amazon_role_to_assume" + + if [ -n "$missing_inputs$missing_secrets" ] + then + echo "::error::Missing required inputs ($missing_inputs) or secrets ($missing_secrets)." >&2 + exit 64 # EX_USAGE + fi + + # :AWS_ACCOUNT_ID.dkr.ecr.:REGION.amazonaws.com/:IMAGE_NAME + + registry="${{ inputs.amazon_aws_account_id }}.dkr.ecr.${{ inputs.amazon_aws_region }}.amazonaws.com" + echo "registry=$registry" | tee -a "$GITHUB_OUTPUT" + + package_name="${{ inputs.amazon_package_name }}" + if [ -z "$package_name" ] + then + package_name="${{ github.event.repository.name }}" + fi + echo "package_name=$package_name" | tee -a "$GITHUB_OUTPUT" + + echo "enabled=true" | tee -a "$GITHUB_OUTPUT" + fi + + - name: 'Amazon Elastic Container Registry: Configure AWS credentials' + if: steps.amazon.outputs.enabled == 'true' + uses: aws-actions/configure-aws-credentials@v5 + with: + aws-region: ${{ inputs.amazon_aws_region }} + role-to-assume: ${{ inputs.amazon_role_to_assume }} + + - name: 'Amazon Elastic Container Registry: Authenticate to Amazon Elastic Container Registry' + if: steps.amazon.outputs.enabled == 'true' + uses: docker/login-action@v3 + with: + registry: ${{ steps.amazon.outputs.registry }} + + - name: 'Verify setup' + run: | + if [ "${{ steps.github.outputs.enabled }}" != "true" ] && \ + [ "${{ steps.google.outputs.enabled }}" != "true" ] && \ + [ "${{ steps.amazon.outputs.enabled }}" != "true" ] + then + echo "::error::No container registry is enabled for pushing." >&2 + exit 64 # EX_USAGE + fi + + - name: 'Checkout' + uses: actions/checkout@v5 + + - name: 'Determine tags to use' + id: tags + run: | + # Always include `git-` + TAGS="git-$( git rev-parse --short HEAD )" + + # Note: This is a glob, not a regular rexpression, so [0-9]* + # acts as /[0-9].*/ in regex. This might match more than + # intended, but it’s a trade–off not to complicate things. + version_glob="v[0-9]*.[0-9]*.[0-9]*" + + VERSION_TAG="$( + git describe --tags --abbrev=0 --exact --match="$version_glob" 2>/dev/null + )" + + if [ -z "$VERSION_TAG" ] + then + # Use “edge” if not building a tagged version + TAGS="$TAGS edge" + else + TAGS="$TAGS ${VERSION_TAG}" + if ! echo "$VERSION_TAG" | grep -q '-' + then + TAGS="$TAGS $( echo "${VERSION_TAG}" | grep -oE '^v[0-9]+' )" + TAGS="$TAGS $( echo "${VERSION_TAG}" | grep -oE '^v[0-9]+\.[0-9]+' )" + # Use “latest” if tagging a non–prerelease version + TAGS="$TAGS latest" + fi + fi + + TAGS="$( echo "$TAGS" | tr ' ' '\n' )" + + { + echo "Tagging image with:" + echo + # shellcheck disable=SC2001,SC2016 + echo "$TAGS" | sed 's/.*/- `&`/' + echo + } >> "$GITHUB_STEP_SUMMARY" + + URLS="$( + # GitHub Container Registry + if [ "${{ steps.github.outputs.enabled }}" == "true" ] + then + echo "${{ steps.github.outputs.registry }}/${{ steps.github.outputs.package_name }}" + fi + + # Google Artifact Registry + if [ "${{ steps.google.outputs.enabled }}" == "true" ] + then + echo "${{ steps.google.outputs.registry }}/${{ steps.google.outputs.package_name }}" + fi + + # Amazon ECR + if [ "${{ steps.amazon.outputs.enabled }}" == "true" ] + then + echo "${{ steps.amazon.outputs.registry }}/${{ steps.amazon.outputs.package_name }}" + fi + )" + + { + echo "Pushing to:" + echo + # shellcheck disable=SC2001 + echo "$URLS" | sed 's|^|- https://|' + echo + } >> "$GITHUB_STEP_SUMMARY" + + { + echo "tags< + +Build Docker image and push it to the registries. When a version change is detected, also create a git tag. + +See [docker-build-push.yaml](./.github/workflows/docker-build-push.yaml) for details. + +### Create Release + +Template: + + +Bump up a version. + +See [create-release.yaml](./.github/workflows/create-release.yaml) for details. + ## Deploying new versions of actions and workflows You might have noticed that main branch in this repository is called diff --git a/setup/action.yaml b/setup/action.yaml index e60f19f2..a793e416 100644 --- a/setup/action.yaml +++ b/setup/action.yaml @@ -19,18 +19,21 @@ outputs: scripts: description: "Comma–separated list of available npm scripts. Includes comma at the beginning and the end, so you can use contains(needs.setup.outputs.scripts, ',script-name,')" value: ${{ steps.scripts.outputs.scripts }} + package-manager: + description: 'Detected package manager (npm or yarn)' + value: ${{ steps.package-manager.outputs.package-manager }} runs: using: composite steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v5 with: fetch-depth: ${{ inputs.fetch-depth }} ref: ${{ github.event.pull_request.head.ref || github.ref }} - name: Setup Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@v5 with: node-version-file: '${{ inputs.working-directory }}/.nvmrc' @@ -83,6 +86,22 @@ runs: run: | echo "//npm.pkg.github.com/:_authToken=${{ inputs.github-npm-registry-personal-access-token }}" >> ~/.npmrc + - name: Determine package manager + id: package-manager + working-directory: ${{ inputs.working-directory }} + shell: bash + run: | + if [ -e "yarn.lock" ] + then + echo "package-manager=yarn" >> "$GITHUB_OUTPUT" + elif [ -e "package-lock.json" ] + then + echo "package-manager=npm" >> "$GITHUB_OUTPUT" + else + echo "ERROR: Could not determine package manager. Neither package-lock.json nor yarn.lock found." >&2 + exit 66 # EX_NOINPUT + fi + - name: Install dependencies if: steps.cache.outputs.cache-hit != 'true' working-directory: ${{ inputs.working-directory }} @@ -96,13 +115,23 @@ runs: export GH_REGISTRY_TOKEN="$GITHUB_NPM_REGISTRY_PERSONAL_ACCESS_TOKEN" fi - if [ -e "yarn.lock" ] + if [ "${{ steps.package-manager.outputs['package-manager'] }}" = "yarn" ] then yarn install --immutable else npm ci fi + - name: Verify the integrity of provenance attestations and registry signatures for installed dependencies + if: steps.cache.outputs.cache-hit != 'true' && steps.package-manager.outputs['package-manager'] == 'npm' + working-directory: ${{ inputs.working-directory }} + shell: bash + run: | + if [ 0 -lt "$( jq -r '( .dependencies + .devDependencies ) // {} | to_entries | length' package.json )" ] + then + npm audit signatures + fi + - name: Run prepare npm script if: > steps.cache.outputs.cache-hit == 'true'