From b3491240778599b65f5102ab8c68f48ceaf02374 Mon Sep 17 00:00:00 2001 From: Guanzhou Song Date: Mon, 3 Aug 2026 21:25:34 -0400 Subject: [PATCH] Make package generation and GPG signing optional in the CD build Forks could not run the deployment workflow cleanly: the GPG import step ran unconditionally, so a repository without GPG_PRIVATE_KEY got a failed step on every run. The continue-on-error that hid it was also a hazard upstream, where a broken key would silently republish the site with an unsigned APT repository in place of a signed one and break apt-get update for every client pinned with signed-by. Resolve two independent feature flags up front, since the secrets context is not readable from a step-level if condition: - Package repository generation follows the BUILD_PACKAGES variable, and defaults on for this repository and off for forks, so a fork build only exercises the site and Pages publish. - Signing follows the presence of GPG_PRIVATE_KEY. When the key is absent the import step is skipped rather than failed; when it is present the import must succeed, and the artifact check now asserts that InRelease, Release.gpg and the exported keyring were produced. Document the fork workflow and both settings in the readme. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: Guanzhou Song --- .github/scripts/download_packages.sh | 8 +- .github/workflows/continuous-deployment.yml | 132 +++++++++++++++++--- readme.md | 25 ++++ 3 files changed, 147 insertions(+), 18 deletions(-) diff --git a/.github/scripts/download_packages.sh b/.github/scripts/download_packages.sh index 10cfe33..1e22606 100755 --- a/.github/scripts/download_packages.sh +++ b/.github/scripts/download_packages.sh @@ -15,9 +15,13 @@ sign_deb_package() { if [ -n "$GPG_FINGERPRINT" ] && [ -f "$package_file" ]; then echo " Signing DEB package: $(basename "$package_file")" if command -v dpkg-sig >/dev/null 2>&1; then - dpkg-sig --sign builder --gpg-options "--default-key $GPG_FINGERPRINT" "$package_file" || echo " Warning: Could not sign $(basename "$package_file")" + # Per-package signatures are not what apt verifies (it checks the signed + # Release file), so a failure here is reported rather than fatal. It is + # still surfaced as a workflow annotation so a broken signing key cannot + # scroll past unnoticed in the log. + dpkg-sig --sign builder --gpg-options "--default-key $GPG_FINGERPRINT" "$package_file" || echo "::warning::Could not sign $(basename "$package_file")" else - echo " Warning: dpkg-sig not available, skipping DEB package signing" + echo "::warning::dpkg-sig not available, skipping DEB package signing" fi fi } diff --git a/.github/workflows/continuous-deployment.yml b/.github/workflows/continuous-deployment.yml index d783e30..c0e86b5 100644 --- a/.github/workflows/continuous-deployment.yml +++ b/.github/workflows/continuous-deployment.yml @@ -27,33 +27,106 @@ jobs: steps: - name: Checkout source uses: actions/checkout@v7 - - name: Install required packages + - name: Resolve optional build features + # The static site is always built. Mirroring the release packages and + # signing them are separate opt-outs so that a fork with no secrets can + # still run this workflow end to end. + id: features + env: + # The `secrets` context is not readable from a step-level `if:`, so + # the presence of the signing key has to be resolved into a step + # output first. Binding the secret to this one step also keeps it out + # of every other step's environment. + GPG_PRIVATE_KEY: ${{ secrets.GPG_PRIVATE_KEY }} + # A fork has neither the signing key nor a reason to spend several + # minutes mirroring release assets, so package generation defaults + # off outside this repository. `BUILD_PACKAGES` overrides it either + # way - but note that turning it off upstream publishes a site with + # no /deb, /rpm and no keyring, which breaks `apt-get update` for + # everyone already pointed at the repository. + BUILD_PACKAGES: ${{ vars.BUILD_PACKAGES }} + IS_UPSTREAM: ${{ github.repository == 'documentdb/documentdb.github.io' }} + run: | + set -euo pipefail + requested=$(printf '%s' "$BUILD_PACKAGES" | tr '[:upper:]' '[:lower:]') + case "$requested" in + true|false) packages="$requested" ;; + '') packages="$IS_UPSTREAM" ;; + *) + echo "::error::BUILD_PACKAGES must be 'true' or 'false' (got '$BUILD_PACKAGES')" + exit 1 + ;; + esac + + if [ "$packages" = 'true' ] && [ -n "$GPG_PRIVATE_KEY" ]; then + sign=true + else + sign=false + fi + + echo "packages=$packages" >> "$GITHUB_OUTPUT" + echo "sign=$sign" >> "$GITHUB_OUTPUT" + + { + echo "### Build configuration" + echo "" + echo "| Feature | Enabled | Controlled by |" + echo "| --- | --- | --- |" + echo "| Static site | true | always built |" + echo "| Package repositories | $packages | \`BUILD_PACKAGES\` variable |" + echo "| Package signing | $sign | \`GPG_PRIVATE_KEY\` secret |" + } >> "$GITHUB_STEP_SUMMARY" + + if [ "$packages" = 'true' ] && [ "$sign" != 'true' ]; then + echo "::warning::GPG_PRIVATE_KEY is not set - the package repositories will be published unsigned." + fi + - name: Install packaging tools + if: steps.features.outputs.packages == 'true' run: | until sudo apt-get update; do sleep 1; done sudo apt-get install -y createrepo-c dpkg-dev dpkg-sig gnupg2 python3 - name: Setup GPG id: import_gpg + if: steps.features.outputs.sign == 'true' + # Deliberately no `continue-on-error`: a key that is configured but + # cannot be imported has to fail the run. Swallowing that error + # republishes the site with an unsigned repository in place of a signed + # one, which breaks `apt-get update` for every client pinned with + # `signed-by`. Signing is optional; silently losing it is not. uses: crazy-max/ghaction-import-gpg@v7 with: gpg_private_key: ${{ secrets.GPG_PRIVATE_KEY }} - continue-on-error: true - - name: Set GPG fingerprint and version config + - name: Configure package build + if: steps.features.outputs.packages == 'true' + env: + SIGN: ${{ steps.features.outputs.sign }} + FINGERPRINT: ${{ steps.import_gpg.outputs.fingerprint }} + KEY_ID: ${{ steps.import_gpg.outputs.keyid }} + KEY_NAME: ${{ steps.import_gpg.outputs.name }} + KEY_EMAIL: ${{ steps.import_gpg.outputs.email }} + # Configure which DocumentDB release to mirror. Both can be + # overridden by repository variables. + DOCUMENTDB_VERSION: ${{ vars.DOCUMENTDB_VERSION || 'latest' }} + MULTI_VERSION: ${{ vars.MULTI_VERSION || 'true' }} run: | - # Configure GPG signing - if [ -n "${{ steps.import_gpg.outputs.fingerprint }}" ]; then - echo "GPG_FINGERPRINT=${{ steps.import_gpg.outputs.fingerprint }}" >> $GITHUB_ENV - echo "✅ GPG key loaded successfully" - echo " Fingerprint: ${{ steps.import_gpg.outputs.fingerprint }}" - echo " Key ID: ${{ steps.import_gpg.outputs.keyid }}" - echo " User ID: ${{ steps.import_gpg.outputs.name }} <${{ steps.import_gpg.outputs.email }}>" + set -euo pipefail + if [ "$SIGN" = 'true' ]; then + if [ -z "$FINGERPRINT" ]; then + echo "::error::The GPG key imported without a fingerprint; refusing to publish an unsigned repository." + exit 1 + fi + echo "GPG_FINGERPRINT=$FINGERPRINT" >> "$GITHUB_ENV" + echo "GPG key loaded successfully" + echo " Fingerprint: $FINGERPRINT" + echo " Key ID: $KEY_ID" + echo " User ID: $KEY_NAME <$KEY_EMAIL>" else - echo "⚠️ No GPG key configured - packages will not be signed" - echo " To enable signing, add GPG_PRIVATE_KEY to repository secrets" + echo "No GPG key configured - packages will not be signed." + echo "To enable signing, add GPG_PRIVATE_KEY to the repository secrets." fi - - # Configure DocumentDB version (can be overridden by repository variables) - echo "DOCUMENTDB_VERSION=${{ vars.DOCUMENTDB_VERSION || 'latest' }}" >> $GITHUB_ENV - echo "MULTI_VERSION=${{ vars.MULTI_VERSION || 'true' }}" >> $GITHUB_ENV + + echo "DOCUMENTDB_VERSION=$DOCUMENTDB_VERSION" >> "$GITHUB_ENV" + echo "MULTI_VERSION=$MULTI_VERSION" >> "$GITHUB_ENV" - name: Setup Node.js uses: actions/setup-node@v7 with: @@ -108,12 +181,17 @@ jobs: exit 1 fi - name: Download DocumentDB packages from latest release + if: steps.features.outputs.packages == 'true' run: .github/scripts/download_packages.sh - name: Verify generated package components + if: steps.features.outputs.packages == 'true' + env: + SIGN: ${{ steps.features.outputs.sign }} run: | set -euo pipefail python3 - <<'PY' import json + import os from pathlib import Path release_info = Path("out/packages/release-info.json") @@ -150,6 +228,28 @@ jobs: release_text = release_file.read_text() if "deb13" not in release_text: raise SystemExit("deb13 assets exist but deb13 is missing from the APT Release file") + + # A run that imported a signing key must not publish an unsigned + # repository: apt rejects a suite whose InRelease/Release.gpg vanished, + # so a silently skipped signature is a client-visible outage rather + # than a cosmetic regression. + if os.environ.get("SIGN") == "true": + if any(name.endswith(".deb") for name in assets): + for artifact in ( + Path("out/deb/dists/stable/Release.gpg"), + Path("out/deb/dists/stable/InRelease"), + Path("out/documentdb-archive-keyring.gpg"), + ): + if not artifact.exists(): + raise SystemExit( + f"Signing was enabled but {artifact} was not produced" + ) + + # RPM metadata signing is best-effort inside the download script, + # so surface it as a warning instead of failing the deployment. + for repomd in sorted(Path("out/rpm").glob("*/repodata/repomd.xml")): + if not Path(f"{repomd}.asc").exists(): + print(f"::warning::{repomd} was not signed") PY - name: Upload artifact uses: actions/upload-pages-artifact@v5 diff --git a/readme.md b/readme.md index 1e8cef6..f37311f 100644 --- a/readme.md +++ b/readme.md @@ -112,6 +112,31 @@ Documentation articles and API reference content are managed in a separate repos > - API reference content (`api-reference/`) > +### Testing the Deployment Workflow in a Fork + +The [deployment workflow](.github/workflows/continuous-deployment.yml) builds the static site, mirrors the DocumentDB release packages into APT and YUM repositories, signs those repositories, and publishes everything to GitHub Pages. + +Only the static site build is mandatory. Packaging and signing are resolved automatically at the start of the run, so a fork with no repository secrets can exercise the whole workflow: + +1. Enable GitHub Pages in your fork (**Settings** > **Pages** > **Source**: *GitHub Actions*) + +1. Push to `main` in your fork, or run the workflow manually from the **Actions** tab + +1. Check the run summary, which reports which optional features were enabled and why + +The two optional halves are controlled independently: + +| Setting | Type | Default | Effect when enabled | +| --- | --- | --- | --- | +| `BUILD_PACKAGES` | Variable | On in `documentdb/documentdb.github.io`, off in forks | Downloads the release `.deb` and `.rpm` assets and builds the APT and YUM repositories | +| `GPG_PRIVATE_KEY` | Secret | *unset* | Signs the APT `Release` file and the RPM metadata, and publishes `documentdb-archive-keyring.gpg` | +| `DOCUMENTDB_VERSION` | Variable | `latest` | Release tag the packages are mirrored from | + +Set `BUILD_PACKAGES` to `true` in your fork if you specifically want to test the packaging path; signing is then skipped unless you also add your own `GPG_PRIVATE_KEY`. + +> [!IMPORTANT] +> Signing is optional, but it never fails quietly. When `GPG_PRIVATE_KEY` is set, the run fails if the key cannot be imported or the signatures are not produced. Publishing an unsigned repository over a signed one breaks `apt-get update` for every client that already trusts the keyring. + ## Content Configuration Documentation content is automatically compiled during builds from external repositories. The mapping is configured in [content.config.json](content.config.json).