Merge pull request #158 from basecubedev/fix/appliance-admin-install #2
Workflow file for this run
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: Appliance Manager release | |
| # Cut a signed Appliance Manager package and publish the index an appliance | |
| # reads to find it. | |
| # | |
| # This is the half of the release story that a hosted runner is allowed to do. | |
| # An image built here is refused at signing time with builder_environment_untrusted, | |
| # because base-images.lock.json approves exactly one builder. The .deb is not: | |
| # packaging/appliance/build-deb.sh is reproducible from SOURCE_DATE_EPOCH and a | |
| # pinned compressor, so two builds of one commit are the same bytes and an | |
| # unattested builder is no objection to it. That is why this workflow can exist | |
| # and .github/workflows/appliance-image.yml deliberately cannot sign. | |
| # | |
| # Three jobs, so that no single job holds both the signing key and permission to | |
| # write to the repository: | |
| # | |
| # package builds the .deb and the manifest. no secret, contents: read | |
| # sign signs the manifest inside an approved the key, contents: read | |
| # environment and verifies the result | |
| # against the keyring the fleet ships | |
| # publish creates the release and rebuilds the no secret, contents: write | |
| # index that names every package | |
| # | |
| # Before the first run, one thing has to exist that only a human can create: an | |
| # environment named appliance-manager-signing, with required reviewers, holding | |
| # the secret APPLIANCE_MANAGER_SIGNING_KEY (an ASCII-armored secret key, base64 | |
| # encoded) and the variable APPLIANCE_MANAGER_SIGNING_FINGERPRINT. The key must | |
| # be one the shipped packaging/appliance/config/release-keyring.gpg already | |
| # trusts -- the sign job verifies its own signature against that keyring, so a | |
| # key the fleet would reject fails here rather than in the field. | |
| "on": | |
| push: | |
| tags: | |
| - appliance-manager-v* | |
| workflow_dispatch: | |
| inputs: | |
| version: | |
| description: The Manager version to cut, e.g. 0.1.0 (must already be tagged) | |
| type: string | |
| required: true | |
| permissions: | |
| contents: read | |
| concurrency: | |
| group: appliance-manager-release-${{ github.ref }} | |
| cancel-in-progress: false | |
| jobs: | |
| package: | |
| name: Appliance Manager package | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 15 | |
| permissions: | |
| contents: read | |
| outputs: | |
| version: ${{ steps.identify.outputs.version }} | |
| release_id: ${{ steps.identify.outputs.release_id }} | |
| steps: | |
| - name: Checkout repository | |
| uses: actions/checkout@v7 | |
| with: | |
| # build-deb.sh takes SOURCE_DATE_EPOCH from the commit date, and the | |
| # manifest names the revision, so both need real history. | |
| fetch-depth: 0 | |
| persist-credentials: false | |
| # Before anything is built and long before a reviewer is asked to release | |
| # the signing key: an identity whose secret half nobody holds fails at | |
| # `gpgv` three jobs later, reported as the fleet refusing the signature, | |
| # which is a message about the wrong thing. | |
| - name: Refuse an identity nobody can sign with | |
| run: python3 scripts/appliance-check-release-identity.py | |
| - name: Establish which version is being cut | |
| id: identify | |
| env: | |
| WANTED: ${{ inputs.version }} | |
| run: | | |
| set -euo pipefail | |
| # The tag is the version. Nothing in the source tree records one, so | |
| # there is no second answer it could disagree with -- the package is | |
| # built with what is resolved here and the release is named after the | |
| # same string, the way an EMS image takes its version from the tag CI | |
| # was invoked with. | |
| python3 - <<'PY' >> "$GITHUB_OUTPUT" | |
| import os | |
| import sys | |
| sys.path.insert(0, ".") | |
| from appliance.version import is_stable, version_from_tag | |
| ref = os.environ.get("GITHUB_REF_NAME", "") | |
| asked = (os.environ.get("WANTED") or "").strip().lstrip("vV") | |
| wanted = version_from_tag(ref) or asked | |
| if not wanted: | |
| raise SystemExit(f"{ref!r} is not a Manager tag and no version was given") | |
| if not is_stable(wanted): | |
| # Refused here, with the real reason, rather than five steps later | |
| # inside the manifest generator as "invalid_release_id". A | |
| # prerelease is spelled with a tilde so dpkg and version_key agree | |
| # on its order, and artifact_trust.RELEASE_ID has no tilde in its | |
| # grammar -- so this chain publishes releases and nothing else. | |
| raise SystemExit( | |
| f"{wanted} is a candidate, and a candidate has no publishable " | |
| "release id: RELEASE_ID admits no '~'. Cut a release instead." | |
| ) | |
| print(f"version={wanted}") | |
| print(f"release_id=ems-appliance-manager-{wanted}-arm64") | |
| PY | |
| cat "$GITHUB_OUTPUT" | |
| - name: Build the package | |
| env: | |
| VERSION: ${{ steps.identify.outputs.version }} | |
| run: | | |
| set -euo pipefail | |
| mkdir -p "${RUNNER_TEMP}/package" | |
| # LC_ALL, because build-deb.sh records dpkg-deb's own --version string | |
| # in the build record, the manifest quotes it, and the manifest is | |
| # about to be signed. A German runner would sign a different document | |
| # for the same bytes. | |
| LC_ALL=C packaging/appliance/build-deb.sh \ | |
| --output "${RUNNER_TEMP}/package" --arch arm64 --version "${VERSION}" | |
| ls -l "${RUNNER_TEMP}/package" | |
| - name: Describe it in the file that gets signed | |
| env: | |
| RELEASE_ID: ${{ steps.identify.outputs.release_id }} | |
| run: | | |
| set -euo pipefail | |
| # Everything descriptive is read from the package and its build record | |
| # rather than passed in, so the manifest cannot disagree with the | |
| # artefact it points at. | |
| scripts/appliance-build-manager-manifest.py \ | |
| --revision "${GITHUB_SHA}" \ | |
| --output "${RUNNER_TEMP}/package" \ | |
| "${RUNNER_TEMP}"/package/*.deb | |
| cat "${RUNNER_TEMP}/package/${RELEASE_ID}.manifest.json" | |
| - name: Hand it to the signer | |
| uses: actions/upload-artifact@v7 | |
| with: | |
| name: appliance-manager-unsigned | |
| path: ${{ runner.temp }}/package/ | |
| retention-days: 7 | |
| overwrite: true | |
| sign: | |
| name: Appliance Manager sign | |
| needs: package | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 15 | |
| # The approval gate. A human releases the key for this run; nothing else in | |
| # this repository reads a secret at all. | |
| environment: appliance-manager-signing | |
| permissions: | |
| contents: read | |
| steps: | |
| - name: Checkout repository | |
| uses: actions/checkout@v7 | |
| with: | |
| persist-credentials: false | |
| - name: Collect what was built | |
| uses: actions/download-artifact@v7 | |
| with: | |
| name: appliance-manager-unsigned | |
| path: ${{ runner.temp }}/package | |
| - name: Sign the manifest | |
| env: | |
| SIGNING_KEY: ${{ secrets.APPLIANCE_MANAGER_SIGNING_KEY }} | |
| FINGERPRINT: ${{ vars.APPLIANCE_MANAGER_SIGNING_FINGERPRINT }} | |
| RELEASE_ID: ${{ needs.package.outputs.release_id }} | |
| run: | | |
| set -euo pipefail | |
| [ -n "${SIGNING_KEY}" ] || { | |
| echo "::error::the appliance-manager-signing environment holds no key" | |
| echo "::error::create APPLIANCE_MANAGER_SIGNING_KEY (armored secret key, base64)" | |
| exit 1 | |
| } | |
| [ -n "${FINGERPRINT}" ] || { | |
| echo "::error::APPLIANCE_MANAGER_SIGNING_FINGERPRINT is not set" | |
| exit 1 | |
| } | |
| manifest="${RUNNER_TEMP}/package/${RELEASE_ID}.manifest.json" | |
| [ -r "${manifest}" ] || { echo "::error::${manifest} is missing"; exit 1; } | |
| # The key reaches gpg on a pipe and never touches a filesystem, and the | |
| # keyring it builds is destroyed unconditionally below. A key written | |
| # to disk on a runner is a key in a snapshot nobody can revoke. | |
| export GNUPGHOME="${RUNNER_TEMP}/gnupg" | |
| mkdir -p "${GNUPGHOME}" | |
| chmod 700 "${GNUPGHOME}" | |
| printf '%s' "${SIGNING_KEY}" | base64 -d | gpg --batch --quiet --import | |
| # The trailing '!' is not decoration. Without it gpg treats the | |
| # fingerprint as naming a key and then signs with whichever of that | |
| # key's signing subkeys it likes best -- measurably the newest, not | |
| # the one named. That is harmless while there is one subkey and wrong | |
| # the moment a rotation adds a second, which is exactly when nobody | |
| # would be looking. With '!' it signs with the subkey named or fails. | |
| gpg --batch --yes --armor --detach-sign \ | |
| --local-user "${FINGERPRINT}!" \ | |
| --output "${manifest}.asc" "${manifest}" | |
| - name: Refuse a signature the fleet would refuse | |
| env: | |
| RELEASE_ID: ${{ needs.package.outputs.release_id }} | |
| run: | | |
| set -euo pipefail | |
| manifest="${RUNNER_TEMP}/package/${RELEASE_ID}.manifest.json" | |
| # Verified against the keyring the package itself installs at | |
| # /etc/ems-appliance-manager/release-keyring.gpg, with the same | |
| # program the appliance uses. A key the fleet does not trust fails | |
| # here, on a runner, instead of on every appliance that fetched it. | |
| gpgv --keyring "$(pwd)/packaging/appliance/config/release-keyring.gpg" \ | |
| "${manifest}.asc" "${manifest}" | |
| echo "the shipped keyring accepts this signature" | |
| - name: Destroy the key material | |
| if: always() | |
| run: | | |
| set -euo pipefail | |
| rm -rf "${RUNNER_TEMP}/gnupg" | |
| echo "GNUPGHOME removed" | |
| - name: Hand the signed set to the publisher | |
| uses: actions/upload-artifact@v7 | |
| with: | |
| name: appliance-manager-signed | |
| path: ${{ runner.temp }}/package/ | |
| retention-days: 7 | |
| overwrite: true | |
| publish: | |
| name: Appliance Manager publish | |
| needs: [package, sign] | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 20 | |
| permissions: | |
| # The only job that writes, and it holds no key. | |
| contents: write | |
| steps: | |
| - name: Checkout repository | |
| uses: actions/checkout@v7 | |
| with: | |
| persist-credentials: false | |
| - name: Collect the signed set | |
| uses: actions/download-artifact@v7 | |
| with: | |
| name: appliance-manager-signed | |
| path: ${{ runner.temp }}/package | |
| - name: Publish the package | |
| env: | |
| GH_TOKEN: ${{ github.token }} | |
| GH_REPO: ${{ github.repository }} | |
| VERSION: ${{ needs.package.outputs.version }} | |
| TAG: appliance-manager-v${{ needs.package.outputs.version }} | |
| run: | | |
| set -euo pipefail | |
| notes="${RUNNER_TEMP}/notes.md" | |
| { | |
| echo "Appliance Manager ${VERSION} for arm64, signed." | |
| echo | |
| echo "An appliance installs this through its own Updates page; it is" | |
| echo "listed in the package index and verified against the keyring" | |
| echo "the appliance was flashed with. \`dpkg -i\` over SSH works too," | |
| echo "and arms no verification deadline, so it leaves no way back." | |
| echo | |
| echo "This release is marked pre-release so that GitHub's *Latest*" | |
| echo "badge keeps naming an EMS release: two products publish from" | |
| echo "this repository and only one of them owns the \`v*\` tags." | |
| } > "${notes}" | |
| if gh release view "${TAG}" >/dev/null 2>&1; then | |
| echo "::error::${TAG} already exists; a published version is never rewritten" | |
| exit 1 | |
| fi | |
| gh release create "${TAG}" \ | |
| --prerelease \ | |
| --target "${GITHUB_SHA}" \ | |
| --title "Appliance Manager ${VERSION}" \ | |
| --notes-file "${notes}" \ | |
| "${RUNNER_TEMP}"/package/* | |
| - name: Rebuild the index that names every package | |
| env: | |
| GH_TOKEN: ${{ github.token }} | |
| GH_REPO: ${{ github.repository }} | |
| RELEASE_ID: ${{ needs.package.outputs.release_id }} | |
| TAG: appliance-manager-v${{ needs.package.outputs.version }} | |
| INDEX_TAG: appliance-manager-index | |
| run: | | |
| set -euo pipefail | |
| work="${RUNNER_TEMP}/index" | |
| mkdir -p "${work}" | |
| # History is the point. The manager has no way back other than | |
| # installing an earlier package, so an index naming only the newest | |
| # one takes recovery away from every appliance that kept no copy. | |
| # --previous carries every entry forward; --keep is left unset. | |
| # | |
| # Whether an index already exists is decided on the HTTP status and | |
| # nothing else. Reading "the download failed" as "there is no index | |
| # yet" is how a transient 500 would republish the fleet's entire | |
| # history as a single entry, with --clobber, and nothing failing. | |
| previous="" | |
| carried=0 | |
| # Read as a status code rather than out of a tool's error prose. `gh` | |
| # exits 1 for a missing release and for a broken connection alike, and | |
| # telling them apart by the wording of its message makes the fleet's | |
| # history depend on a string that is not part of any contract. | |
| code=$(curl -sS -o "${work}/index-release.json" -w '%{http_code}' \ | |
| -H "Authorization: Bearer ${GH_TOKEN}" \ | |
| -H "Accept: application/vnd.github+json" \ | |
| "https://api.github.com/repos/${GH_REPO}/releases/tags/${INDEX_TAG}" || echo 000) | |
| case "${code}" in | |
| 200) | |
| # It exists, so its content is not optional here. | |
| gh release download "${INDEX_TAG}" --pattern manager-packages.json --dir "${work}" | |
| previous="${work}/manager-packages.json" | |
| carried=$(python3 -c 'import json,sys; print(len(json.load(open(sys.argv[1]))["releases"]))' "${previous}") | |
| echo "carrying forward ${carried} entries" | |
| ;; | |
| 404) | |
| echo "no index yet; this run creates the first one" | |
| ;; | |
| *) | |
| echo "::error::the index release answered ${code}; could not establish whether an index already exists, and refusing to publish one that might replace it" | |
| exit 1 | |
| ;; | |
| esac | |
| scripts/appliance-build-manager-index.py \ | |
| --base-url "https://github.com/${GITHUB_REPOSITORY}/releases/download/${TAG}" \ | |
| ${previous:+--previous "${previous}"} \ | |
| --output "${work}/manager-packages.json.new" \ | |
| "${RUNNER_TEMP}/package/${RELEASE_ID}.manifest.json" | |
| # The floor: this release adds one entry and removes none. Anything | |
| # else means history was dropped somewhere above, and the upload below | |
| # is a --clobber over the only list of packages an appliance can reach. | |
| published=$(python3 -c 'import json,sys; print(len(json.load(open(sys.argv[1]))["releases"]))' "${work}/manager-packages.json.new") | |
| if [ "${published}" -lt "${carried}" ]; then | |
| echo "::error::the new index names ${published} releases, the one it replaces named ${carried}" | |
| exit 1 | |
| fi | |
| mv "${work}/manager-packages.json.new" "${work}/manager-packages.json" | |
| cat "${work}/manager-packages.json" | |
| # One tag, forever, because packaging/appliance/config/appliance.conf | |
| # names it and a flashed appliance never gets that value corrected. | |
| if ! gh release view "${INDEX_TAG}" >/dev/null 2>&1; then | |
| gh release create "${INDEX_TAG}" \ | |
| --prerelease \ | |
| --target "${GITHUB_SHA}" \ | |
| --title "Appliance Manager package index" \ | |
| --notes "The index every appliance reads to learn which Manager packages exist. Rewritten by each Manager release; the tag never moves, because a flashed appliance cannot be told a new address." | |
| fi | |
| gh release upload "${INDEX_TAG}" "${work}/manager-packages.json" --clobber | |
| - name: Report what an appliance can now see | |
| env: | |
| VERSION: ${{ needs.package.outputs.version }} | |
| run: | | |
| set -euo pipefail | |
| { | |
| echo "### Appliance Manager ${VERSION}" | |
| echo | |
| echo "Signed, published, and named by the index at" | |
| echo "\`/releases/download/appliance-manager-index/manager-packages.json\`." | |
| echo | |
| echo '```json' | |
| python3 -c 'import json,sys; print(json.dumps([{k: r[k] for k in ("release_id", "release_version")} for r in json.load(open(sys.argv[1]))["releases"]], indent=2))' \ | |
| "${RUNNER_TEMP}/index/manager-packages.json" | |
| echo '```' | |
| } >> "$GITHUB_STEP_SUMMARY" |