Merge pull request #25 from unattended-backpack/dev #17
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: Create Release | |
| on: | |
| push: | |
| branches: [ master ] | |
| paths-ignore: | |
| - '**/*.md' | |
| - '**/*.txt' | |
| - 'docs/**' | |
| - '.github/workflows/codeql.yml' | |
| permissions: | |
| actions: read | |
| contents: read | |
| id-token: write | |
| concurrency: | |
| group: ${{ github.workflow }}-${{ github.ref }} | |
| cancel-in-progress: false | |
| jobs: | |
| authenticate: | |
| runs-on: [ self-hosted, actions-runner ] | |
| outputs: | |
| build-image: ${{ steps.config.outputs.build-image }} | |
| steps: | |
| - name: Find build image | |
| id: config | |
| run: | | |
| . /opt/github-runner/secrets/registry.env | |
| BUILD_IMAGE="${BUILD_IMAGE:-unattended/petros:latest}" | |
| echo "build-image=$BUILD_IMAGE" >> $GITHUB_OUTPUT | |
| echo "Using build image: $BUILD_IMAGE" | |
| - name: Log into DigitalOcean Container Registry | |
| if: >- | |
| startsWith( | |
| steps.config.outputs.build-image, | |
| 'registry.digitalocean.com' | |
| ) | |
| run: | | |
| DO_TOKEN=$(cat /opt/github-runner/secrets/do_token) | |
| echo "::add-mask::$DO_TOKEN" | |
| echo "$DO_TOKEN" | \ | |
| docker login registry.digitalocean.com \ | |
| --username oauth2 \ | |
| --password-stdin | |
| release: | |
| needs: authenticate | |
| runs-on: [ self-hosted, actions-runner ] | |
| container: | |
| image: ${{ needs.authenticate.outputs.build-image }} | |
| options: --user 1000:1000 --group-add 960 --group-add 987 | |
| volumes: | |
| - /var/run/docker.sock:/var/run/docker.sock | |
| - /opt/github-runner/secrets:/opt/github-runner/secrets:ro | |
| defaults: | |
| run: | |
| working-directory: ${{ github.workspace }} | |
| outputs: | |
| image-digest: ${{ steps.push-do.outputs.digest }} | |
| build-timestamp: ${{ steps.metadata.outputs.timestamp }} | |
| steps: | |
| - name: Checkout code | |
| uses: actions/checkout@ff7abcd0c3c05ccf6adc123a8cd1fd4fb30fb493 | |
| - name: Generate build metadata | |
| id: metadata | |
| run: | | |
| echo "timestamp=$(date +%s)" >> $GITHUB_OUTPUT | |
| echo "sha_short=$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT | |
| - name: Load secrets and configuration | |
| id: load-config | |
| run: | | |
| # Load GitHub tokens from runner-local files | |
| CI_GH_PAT=$(cat /opt/github-runner/secrets/ci_gh_pat) | |
| CI_GH_CLASSIC_PAT=$(cat /opt/github-runner/secrets/ci_gh_classic_pat) | |
| echo "::add-mask::$CI_GH_PAT" | |
| echo "::add-mask::$CI_GH_CLASSIC_PAT" | |
| echo "CI_GH_PAT=$CI_GH_PAT" >> $GITHUB_ENV | |
| echo "CI_GH_CLASSIC_PAT=$CI_GH_CLASSIC_PAT" >> $GITHUB_ENV | |
| # Load registry tokens from runner-local files | |
| DO_TOKEN=$(cat /opt/github-runner/secrets/do_token) | |
| DH_TOKEN=$(cat /opt/github-runner/secrets/dh_token) | |
| echo "::add-mask::$DO_TOKEN" | |
| echo "::add-mask::$DH_TOKEN" | |
| echo "DO_TOKEN=$DO_TOKEN" >> $GITHUB_ENV | |
| echo "DH_TOKEN=$DH_TOKEN" >> $GITHUB_ENV | |
| # Load GPG secrets from runner-local files | |
| GPG_PRIVATE_KEY=$(cat /opt/github-runner/secrets/gpg_private_key) | |
| GPG_PASSPHRASE=$(cat /opt/github-runner/secrets/gpg_passphrase) | |
| GPG_PUBLIC_KEY=$(cat /opt/github-runner/secrets/gpg_public_key) | |
| echo "::add-mask::$GPG_PRIVATE_KEY" | |
| echo "::add-mask::$GPG_PASSPHRASE" | |
| echo "GPG_PRIVATE_KEY=$GPG_PRIVATE_KEY" >> $GITHUB_ENV | |
| echo "GPG_PASSPHRASE=$GPG_PASSPHRASE" >> $GITHUB_ENV | |
| echo "GPG_PUBLIC_KEY=$GPG_PUBLIC_KEY" >> $GITHUB_ENV | |
| # Load public config from repository | |
| . "$GITHUB_WORKSPACE/.env.maintainer" | |
| # Export to environment for subsequent steps | |
| echo "IMAGE_NAME=$IMAGE_NAME" >> $GITHUB_ENV | |
| echo "ATTIC_SERVER_URL=$ATTIC_SERVER_URL" >> $GITHUB_ENV | |
| echo "ATTIC_CACHE=$ATTIC_CACHE" >> $GITHUB_ENV | |
| echo "ATTIC_PUBLIC_KEY=$ATTIC_PUBLIC_KEY" >> $GITHUB_ENV | |
| echo "VENDOR_BASE_URL=$VENDOR_BASE_URL" >> $GITHUB_ENV | |
| # Load registry configuration | |
| . /opt/github-runner/secrets/registry.env | |
| # Export to environment for subsequent steps | |
| echo "DO_REGISTRY_NAME=$DO_REGISTRY_NAME" >> $GITHUB_ENV | |
| echo "DH_USERNAME=$DH_USERNAME" >> $GITHUB_ENV | |
| - name: Generate release notes | |
| id: release-notes | |
| uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd | |
| with: | |
| github-token: ${{ env.CI_GH_PAT }} | |
| script: | | |
| const generateReleaseNotes = require( | |
| './.github/scripts/generate-release-notes.js' | |
| ); | |
| const releaseNotes = await generateReleaseNotes({ | |
| github, context, core | |
| }); | |
| core.setOutput('RELEASE_NOTES', releaseNotes); | |
| - name: Build Docker image | |
| id: build | |
| env: | |
| DOCKER_BUILD_ARGS: ${{ env.DOCKER_BUILD_ARGS }} | |
| run: | | |
| echo "Building ${{ env.IMAGE_NAME }} image ..." | |
| # Determine which attic token to use (priority order): | |
| # 1. Runner-local admin token (if exists) | |
| # 2. Repository .attic_admin_token (if exists) | |
| # 3. Repository attic_token (read-only fallback) | |
| if [ -f /opt/github-runner/secrets/attic_admin_token ]; then | |
| ATTIC_TOKEN_SOURCE=/opt/github-runner/secrets/attic_admin_token | |
| echo "Using runner-local admin attic token" | |
| elif [ -f .attic_admin_token ]; then | |
| ATTIC_TOKEN_SOURCE=.attic_admin_token | |
| echo "Using repository admin attic token" | |
| else | |
| ATTIC_TOKEN_SOURCE=attic_token | |
| echo "Using repository read-only attic token" | |
| fi | |
| # Compute the attic_token hash for cache busting. | |
| ATTIC_TOKEN_HASH=$(sha256sum ${ATTIC_TOKEN_SOURCE} | cut -d' ' -f1) | |
| # Build the image. | |
| docker build \ | |
| ${DOCKER_BUILD_ARGS} \ | |
| --build-arg ATTIC_SERVER_URL="${{ env.ATTIC_SERVER_URL }}" \ | |
| --build-arg ATTIC_CACHE="${{ env.ATTIC_CACHE }}" \ | |
| --build-arg ATTIC_PUBLIC_KEY="${{ env.ATTIC_PUBLIC_KEY }}" \ | |
| --build-arg VENDOR_BASE_URL="${{ env.VENDOR_BASE_URL }}" \ | |
| --build-arg ATTIC_CACHE_BUST="${ATTIC_TOKEN_HASH}" \ | |
| --secret id=attic_token,src=${ATTIC_TOKEN_SOURCE} \ | |
| -t ${{ env.IMAGE_NAME }}:${{ github.sha }} \ | |
| . | |
| echo "build_success=true" >> $GITHUB_OUTPUT | |
| - name: Log into DigitalOcean Container Registry | |
| if: steps.build.outputs.build_success == 'true' | |
| run: | | |
| echo "${{ env.DO_TOKEN }}" | \ | |
| docker login registry.digitalocean.com \ | |
| --username oauth2 \ | |
| --password-stdin | |
| - name: Push to DigitalOcean Container Registry | |
| if: steps.build.outputs.build_success == 'true' | |
| id: push-do | |
| timeout-minutes: 30 | |
| run: | | |
| DO_REG="registry.digitalocean.com" | |
| DO_IMAGE="${DO_REG}/${{ env.DO_REGISTRY_NAME }}/${{ env.IMAGE_NAME }}" | |
| docker tag "${{ env.IMAGE_NAME }}:${{ github.sha }}" \ | |
| "${DO_IMAGE}:${{ github.sha }}" | |
| PUSH_OUTPUT=$(docker push "${DO_IMAGE}:${{ github.sha }}" 2>&1) | |
| echo "$PUSH_OUTPUT" | |
| DIGEST=$(echo "$PUSH_OUTPUT" | \ | |
| sed -n 's/.*digest: \([^ ]*\).*/\1/p' | head -1) | |
| echo "digest=${DIGEST}" >> $GITHUB_OUTPUT | |
| - name: Log into GitHub Container Registry | |
| if: steps.build.outputs.build_success == 'true' | |
| run: | | |
| echo "${{ env.CI_GH_CLASSIC_PAT }}" | docker login ghcr.io \ | |
| --username "${{ github.actor }}" \ | |
| --password-stdin | |
| - name: Push to GitHub Container Registry | |
| if: steps.build.outputs.build_success == 'true' | |
| id: push-ghcr | |
| timeout-minutes: 30 | |
| run: | | |
| GHCR_IMAGE="ghcr.io/${{ github.repository }}" | |
| docker tag "${{ env.IMAGE_NAME }}:${{ github.sha }}" \ | |
| "${GHCR_IMAGE}:${{ github.sha }}" | |
| PUSH_OUTPUT=$(docker push \ | |
| "${GHCR_IMAGE}:${{ github.sha }}" 2>&1) | |
| echo "$PUSH_OUTPUT" | |
| DIGEST=$(echo "$PUSH_OUTPUT" | \ | |
| sed -n 's/.*digest: \([^ ]*\).*/\1/p' | head -1) | |
| echo "digest=${DIGEST}" >> $GITHUB_OUTPUT | |
| - name: Log into Docker Hub | |
| if: steps.build.outputs.build_success == 'true' | |
| run: | | |
| echo "${{ env.DH_TOKEN }}" | docker login \ | |
| --username "${{ env.DH_USERNAME }}" \ | |
| --password-stdin | |
| - name: Push to Docker Hub | |
| if: steps.build.outputs.build_success == 'true' | |
| id: push-dh | |
| timeout-minutes: 30 | |
| run: | | |
| DH_IMAGE="${{ env.DH_USERNAME }}/${{ env.IMAGE_NAME }}" | |
| docker tag "${{ env.IMAGE_NAME }}:${{ github.sha }}" \ | |
| "${DH_IMAGE}:${{ github.sha }}" | |
| PUSH_OUTPUT=$(docker push "${DH_IMAGE}:${{ github.sha }}" 2>&1) | |
| echo "$PUSH_OUTPUT" | |
| DIGEST=$(echo "$PUSH_OUTPUT" | \ | |
| sed -n 's/.*digest: \([^ ]*\).*/\1/p' | head -1) | |
| echo "digest=${DIGEST}" >> $GITHUB_OUTPUT | |
| - name: Verify consistent digests | |
| id: verify-digests | |
| if: steps.build.outputs.build_success == 'true' | |
| run: | | |
| DO_DIGEST="${{ steps.push-do.outputs.digest }}" | |
| GHCR_DIGEST="${{ steps.push-ghcr.outputs.digest }}" | |
| DH_DIGEST="${{ steps.push-dh.outputs.digest }}" | |
| echo "Registry Digests (may differ due to manifest format):" | |
| echo " DO: ${DO_DIGEST}" | |
| echo " GHCR: ${GHCR_DIGEST}" | |
| echo " DH: ${DH_DIGEST}" | |
| echo "" | |
| # Verify all pushes succeeded | |
| if [ -z "${DO_DIGEST}" ] || \ | |
| [ -z "${GHCR_DIGEST}" ] || \ | |
| [ -z "${DH_DIGEST}" ]; then | |
| echo "ERROR: One or more pushes failed to return a digest!" | |
| echo "image_match=false" >> $GITHUB_OUTPUT | |
| exit 1 | |
| fi | |
| # Get local image ID for release notes | |
| LOCAL_ID=$(docker inspect \ | |
| ${{ env.IMAGE_NAME }}:${{ github.sha }} \ | |
| --format='{{.Id}}') | |
| echo "Local image ID: ${LOCAL_ID}" | |
| echo "" | |
| echo "✅ All pushes completed successfully." | |
| echo "Digests will be GPG signed for verification." | |
| echo "image_match=true" >> $GITHUB_OUTPUT | |
| echo "image_id=${LOCAL_ID}" >> $GITHUB_OUTPUT | |
| - name: Sign images with cosign | |
| id: cosign-sign | |
| if: steps.verify-digests.outputs.image_match == 'true' | |
| env: | |
| COSIGN_EXPERIMENTAL: "true" | |
| run: | | |
| echo "Signing container images with cosign ..." | |
| # Sign GHCR | |
| echo "Signing GHCR image ..." | |
| cosign sign --yes \ | |
| "ghcr.io/${{ github.repository }}@${{ steps.push-ghcr.outputs.digest }}" | |
| echo "✅ Signed GHCR image" | |
| # Sign Docker Hub | |
| echo "Signing Docker Hub image ..." | |
| cosign sign --yes \ | |
| "${{ env.DH_USERNAME }}/${{ env.IMAGE_NAME }}@${{ steps.push-dh.outputs.digest }}" | |
| echo "✅ Signed Docker Hub image" | |
| # Sign DigitalOcean | |
| echo "Signing DigitalOcean image ..." | |
| cosign sign --yes \ | |
| "registry.digitalocean.com/${{ env.DO_REGISTRY_NAME }}/${{ env.IMAGE_NAME }}@${{ steps.push-do.outputs.digest }}" | |
| echo "✅ Signed DigitalOcean image" | |
| echo "cosign_success=true" >> $GITHUB_OUTPUT | |
| - name: Sign release artifacts with GPG | |
| id: gpg-sign | |
| if: steps.cosign-sign.outputs.cosign_success == 'true' | |
| env: | |
| BUILD_TIMESTAMP: ${{ steps.metadata.outputs.timestamp }} | |
| BUILD_SHA_SHORT: ${{ steps.metadata.outputs.sha_short }} | |
| DO_DIGEST: ${{ steps.push-do.outputs.digest }} | |
| GHCR_DIGEST: ${{ steps.push-ghcr.outputs.digest }} | |
| DH_DIGEST: ${{ steps.push-dh.outputs.digest }} | |
| run: sh .github/scripts/sign-release-artifacts.sh | |
| - name: Create release | |
| id: create-release | |
| if: steps.gpg-sign.outputs.signing_success == 'true' | |
| uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd | |
| env: | |
| BUILD_TIMESTAMP: ${{ steps.metadata.outputs.timestamp }} | |
| BUILD_SHA_SHORT: ${{ steps.metadata.outputs.sha_short }} | |
| DO_DIGEST: ${{ steps.push-do.outputs.digest }} | |
| GHCR_DIGEST: ${{ steps.push-ghcr.outputs.digest }} | |
| DH_DIGEST: ${{ steps.push-dh.outputs.digest }} | |
| IMAGE_MATCH: ${{ steps.verify-digests.outputs.image_match }} | |
| IMAGE_ID: ${{ steps.verify-digests.outputs.image_id }} | |
| RELEASE_NOTES: ${{ steps.release-notes.outputs.RELEASE_NOTES }} | |
| with: | |
| github-token: ${{ env.CI_GH_PAT }} | |
| script: | | |
| const createRelease = require('./.github/scripts/create-release.js'); | |
| await createRelease({ github, context, core }); | |
| # After registry consistency is verified, add the 'latest' tag. | |
| - name: Tag as latest after verification | |
| if: steps.create-release.outputs.RELEASE_SUCCESS == 'true' | |
| run: | | |
| # Tag and push 'latest' to each registry. | |
| # DO Registry | |
| DO_REG="registry.digitalocean.com" | |
| DO_IMAGE="${DO_REG}/${{ env.DO_REGISTRY_NAME }}/${{ env.IMAGE_NAME }}" | |
| docker tag "${DO_IMAGE}:${{ github.sha }}" "${DO_IMAGE}:latest" | |
| docker push "${DO_IMAGE}:latest" | |
| # GHCR | |
| GHCR_IMAGE="ghcr.io/${{ github.repository }}" | |
| docker tag "${GHCR_IMAGE}:${{ github.sha }}" \ | |
| "${GHCR_IMAGE}:latest" | |
| docker push "${GHCR_IMAGE}:latest" | |
| # Docker Hub | |
| DH_IMAGE="${{ env.DH_USERNAME }}/${{ env.IMAGE_NAME }}" | |
| docker tag "${DH_IMAGE}:${{ github.sha }}" \ | |
| "${DH_IMAGE}:latest" | |
| docker push "${DH_IMAGE}:latest" | |
| echo "✅ Successfully tagged all registries with 'latest'" | |
| - name: Perform rollback on failure | |
| id: rollback | |
| if: >- | |
| failure() && | |
| steps.build.outputs.build_success == 'true' | |
| env: | |
| DO_DIGEST: ${{ steps.push-do.outputs.digest }} | |
| GHCR_DIGEST: ${{ steps.push-ghcr.outputs.digest }} | |
| DH_DIGEST: ${{ steps.push-dh.outputs.digest }} | |
| run: sh .github/scripts/rollback-registries.sh | |
| - name: Create rollback record | |
| if: >- | |
| failure() && | |
| steps.build.outputs.build_success == 'true' | |
| uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd | |
| continue-on-error: true | |
| env: | |
| BUILD_SHA_SHORT: ${{ steps.metadata.outputs.sha_short }} | |
| BUILD_TIMESTAMP: ${{ steps.metadata.outputs.timestamp }} | |
| BUILD_SUCCESS: ${{ steps.build.outputs.build_success }} | |
| RELEASE_SUCCESS: ${{ steps.create-release.outputs.RELEASE_SUCCESS }} | |
| DO_DIGEST: ${{ steps.push-do.outputs.digest }} | |
| GHCR_DIGEST: ${{ steps.push-ghcr.outputs.digest }} | |
| DH_DIGEST: ${{ steps.push-dh.outputs.digest }} | |
| DO_ROLLBACK_SUCCESS: ${{ steps.rollback.outputs.do_rollback_success }} | |
| GHCR_ROLLBACK_SUCCESS: >- | |
| ${{ steps.rollback.outputs.ghcr_rollback_success }} | |
| DH_ROLLBACK_SUCCESS: ${{ steps.rollback.outputs.dh_rollback_success }} | |
| with: | |
| github-token: ${{ env.CI_GH_CLASSIC_PAT }} | |
| script: | | |
| const createRollbackRecord = require( | |
| './.github/scripts/create-rollback-record.js' | |
| ); | |
| await createRollbackRecord({ github, context, core }); |