Skip to content

release

release #2

Workflow file for this run

name: release
# Builds, gates, publishes and install-checks the `encypher` CLI for five
# targets (PRD_CLI_Universal_Install in the private monorepo). The canonical
# copy of every file in this repository is packaging/distribution/ in
# encypherai/encypherai-commercial; packaging/release/public-repo.sh sync puts
# it here, and the provenance job refuses to run when the two differ.
#
# - Dispatch only. `publish: false` is a dry run: every job except publish and
# promote, with the install checks run against the built archives served on
# 127.0.0.1.
# - Trusted code first: .github/scripts/provenance.sh, from this repository,
# refuses a source_ref that is not on the private main before any private
# file executes.
# - The release is published as a pre-release, installed and run by the
# release's own install.sh and install.ps1 on all five platforms, and only
# then marked latest, so an unpinned install never receives an unchecked
# release.
# - This repository is public, and so are its logs and artifacts. Private
# tooling (builds, tests, the artifact gate) runs through
# packaging/release/contained.py, which prints only a status line; a failed
# step's output is uploaded encrypted to the two maintainer certificates in
# .github/, so only a holder of one private half can read it.
on:
workflow_dispatch:
inputs:
source_ref:
description: "Commit of encypherai/encypherai-commercial to release (40 hex characters, on its main)"
required: true
type: string
probe_floor:
description: "ENCYPHER_PROBE_FLOOR: the published probe release head"
required: true
type: string
publish:
description: "Publish the release (false: dry run, nothing is published)"
required: true
default: false
type: boolean
permissions:
contents: read
concurrency:
group: release-${{ github.repository }}
cancel-in-progress: false
env:
RUSTUP_TOOLCHAIN: "1.97.1"
CARGO_TERM_COLOR: never
SOURCE_REPOSITORY: encypherai/encypherai-commercial
jobs:
provenance:
name: Source revision, version and pre-checks
runs-on: ubuntu-24.04
environment: build
timeout-minutes: 15
outputs:
revision: ${{ steps.pin.outputs.revision }}
source_date_epoch: ${{ steps.pin.outputs.source_date_epoch }}
version: ${{ steps.pin.outputs.version }}
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
path: public
persist-credentials: false
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
repository: ${{ env.SOURCE_REPOSITORY }}
ref: ${{ inputs.source_ref }}
ssh-key: ${{ secrets.ENCYPHER_SOURCE_DEPLOY_KEY }}
path: source
fetch-depth: 0
filter: tree:0
sparse-checkout: |
packages/encypher-c2pa
packaging
apps/content-provenance/public/samples
persist-credentials: false
- name: Trusted pre-checks (source on private main, this repository matches it)
env:
SOURCE_REF: ${{ inputs.source_ref }}
SOURCE_DIR: source
PUBLIC_DIR: public
run: bash public/.github/scripts/provenance.sh
- name: Pin the version, revision and SOURCE_DATE_EPOCH
id: pin
working-directory: source
env:
PROBE_FLOOR: ${{ inputs.probe_floor }}
run: |
if ! [[ "${PROBE_FLOOR}" =~ ^[1-9][0-9]*$ ]]; then
echo "probe_floor must be the published probe release head (a number of at least 1), got '${PROBE_FLOOR}'" >&2
exit 1
fi
version="$(python3 packaging/release/release.py version)"
if git -C ../public ls-remote --exit-code --tags origin "refs/tags/v${version}" >/dev/null; then
echo "v${version} already exists here; bump encypher-cli, encypher-client and encypher-client-ffi together" >&2
exit 1
fi
{
echo "revision=$(git rev-parse HEAD)"
echo "source_date_epoch=$(git log -1 --format=%ct)"
echo "version=${version}"
} >> "$GITHUB_OUTPUT"
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: smoke-fixture
path: |
source/apps/content-provenance/public/samples/signed-test.jpg
source/packages/encypher-c2pa/fixtures/hash-mode/image_png.png
if-no-files-found: error
retention-days: 7
windows-lock:
name: Windows unit tests and installer downloads (windows-2022)
needs: provenance
runs-on: windows-2022
environment: build
timeout-minutes: 45
defaults:
run:
shell: bash
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
repository: ${{ env.SOURCE_REPOSITORY }}
ref: ${{ needs.provenance.outputs.revision }}
ssh-key: ${{ secrets.ENCYPHER_SOURCE_DEPLOY_KEY }}
path: source
sparse-checkout: |
packages/encypher-c2pa
packaging
persist-credentials: false
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
with:
python-version: "3.12"
- name: Install the pinned toolchain
run: rustup toolchain install "${RUSTUP_TOOLCHAIN}" --profile minimal
# The output lock (LockFileEx, POSIX-semantics delete of a held lock
# file, delete-pending contention), the no-replace publish (MoveFileExW)
# and the results-file identity are Windows-only code paths.
- name: Client core and CLI unit tests
working-directory: source/packages/encypher-c2pa
env:
ENCYPHER_PROBE_FLOOR: ${{ inputs.probe_floor }}
run: |
contained="python ../../packaging/release/contained.py --show ^test.result:"
${contained} --label "encypher-client unit tests" -- cargo test --locked -p encypher-client --lib
${contained} --label "encypher-cli unit tests" -- cargo test --locked -p encypher-cli --lib
# install.ps1 follows redirects itself and checks every hop before
# requesting it; this runs that downloader under both shells against
# local servers, with a throwaway certificate trusted on this runner only.
- name: install.ps1 downloader under Windows PowerShell 5.1 and PowerShell 7
working-directory: source/packaging/release
env:
ENCYPHER_TEST_TRUST_MACHINE_ROOT: "1"
run: python -m unittest -v test_install.SaveEncypherUrl
# The plain logs never leave the runner; these are encrypted to the
# maintainer certificate in this repository.
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
if: failure()
with:
name: withheld-logs-windows-lock
path: ${{ runner.temp }}/contained/*.log.cms
if-no-files-found: ignore
retention-days: 7
build:
name: Build ${{ matrix.target }}
needs: provenance
runs-on: ${{ matrix.os }}
environment: build
timeout-minutes: 90
strategy:
fail-fast: false
matrix:
include:
- target: aarch64-apple-darwin
os: macos-15
- target: x86_64-apple-darwin
os: macos-15-intel
# glibc 2.35 runners: release.py refuses a binary that needs more than GLIBC_2.34.
- target: x86_64-unknown-linux-gnu
os: ubuntu-22.04
- target: aarch64-unknown-linux-gnu
os: ubuntu-22.04-arm
- target: x86_64-pc-windows-msvc
os: windows-2022
defaults:
run:
shell: bash
env:
TARGET: ${{ matrix.target }}
ENCYPHER_PROBE_FLOOR: ${{ inputs.probe_floor }}
SOURCE_DATE_EPOCH: ${{ needs.provenance.outputs.source_date_epoch }}
REVISION: ${{ needs.provenance.outputs.revision }}
VERSION: ${{ needs.provenance.outputs.version }}
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
repository: ${{ env.SOURCE_REPOSITORY }}
ref: ${{ needs.provenance.outputs.revision }}
ssh-key: ${{ secrets.ENCYPHER_SOURCE_DEPLOY_KEY }}
path: source
sparse-checkout: |
packages/encypher-c2pa
packaging
persist-credentials: false
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
with:
python-version: "3.12"
- name: Install the pinned toolchain and llvm-tools
run: rustup toolchain install "${RUSTUP_TOOLCHAIN}" --profile minimal --target "${TARGET}" --component llvm-tools
- name: Refuse a test-hooks feature graph
working-directory: source/packages/encypher-c2pa
run: |
if ! graph="$(cargo tree --locked -p encypher-cli -p encypher-client-ffi -e features,normal --target "${TARGET}" 2>/dev/null)"; then
echo "cargo tree failed; rerun it at the release's source revision" >&2
exit 1
fi
if grep -q 'test-hooks' <<< "${graph}"; then
echo "a shipped build must never enable test-hooks" >&2
exit 1
fi
# One remap convention for every pipeline (scripts/remap_prefixes.py)
# keeps the runner's checkout, cargo, rustup and home paths out of the
# binaries; the artifact gate refuses any other absolute source path.
- name: Build the CLI and the C library (dist profile)
working-directory: source/packages/encypher-c2pa
run: |
RUSTFLAGS="$(python scripts/remap_prefixes.py)"
export RUSTFLAGS
contained="python ../../packaging/release/contained.py"
${contained} --label "dist build: encypher" -- cargo build --locked -p encypher-cli --profile dist --target "${TARGET}"
${contained} --label "dist build: encypher-client-ffi" -- cargo build --locked -p encypher-client-ffi --profile dist --target "${TARGET}"
# Apple Silicon runs only signed code; the ad hoc signature is the whole
# macOS signing story (no Developer ID, no notarization).
- name: Ad hoc signature (macOS)
if: runner.os == 'macOS'
working-directory: source/packages/encypher-c2pa/target/${{ matrix.target }}/dist
run: codesign --force --sign - encypher libencypher_client.dylib
- name: Artifact gate on the final binaries
working-directory: source/packages/encypher-c2pa
run: |
dir="target/${TARGET}/dist"
case "${TARGET}" in
*windows*) files=("${dir}/encypher.exe" "${dir}/encypher_client.dll") ;;
*apple*) files=("${dir}/encypher" "${dir}/libencypher_client.dylib") ;;
*) files=("${dir}/encypher" "${dir}/libencypher_client.so") ;;
esac
python ../../packaging/release/contained.py --label "artifact gate: binaries" -- python scripts/check-client-artifact.py "${files[@]}"
- name: Smoke the binary
working-directory: source/packages/encypher-c2pa
env:
ENCYPHER_API_KEY: ${{ secrets.ENCYPHER_API_KEY }}
run: |
cli="target/${TARGET}/dist/encypher"
[[ "${TARGET}" == *windows* ]] && cli="${cli}.exe"
"${cli}" --version
"${cli}" inspect fixtures/hash-mode/image_jpeg.jpg --json
if [[ -n "${ENCYPHER_API_KEY}" ]]; then
# The envelopes name documents and service URLs; this log is public.
"${cli}" sign fixtures/hash-mode/image_png.png -o "${RUNNER_TEMP}/smoke-signed.png" \
--digital-source-type http://cv.iptc.org/newscodes/digitalsourcetype/digitalCapture \
--on-existing-provenance chain --json >/dev/null
"${cli}" verify "${RUNNER_TEMP}/smoke-signed.png" --json >/dev/null
echo "smoke: signed and verified a PNG"
else
echo "ENCYPHER_API_KEY is not configured: live sign skipped"
fi
- name: Archive and inventory
run: |
python source/packaging/release/release.py package --target "${TARGET}" --version "${VERSION}" \
--build-dir "source/packages/encypher-c2pa/target/${TARGET}/dist" --revision "${REVISION}" \
--source-date-epoch "${SOURCE_DATE_EPOCH}" --probe-floor "${ENCYPHER_PROBE_FLOOR}" --out dist
- name: Artifact gate on the archive
run: |
python source/packaging/release/contained.py --label "artifact gate: archive" -- \
python source/packages/encypher-c2pa/scripts/check-client-artifact.py dist/*.tar.gz
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: dist-${{ matrix.target }}
path: dist
if-no-files-found: error
retention-days: 7
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
if: failure()
with:
name: withheld-logs-${{ matrix.target }}
path: ${{ runner.temp }}/contained/*.log.cms
if-no-files-found: ignore
retention-days: 7
merge:
name: SHA256SUMS, inventory and installers
needs: [provenance, windows-lock, build]
runs-on: ubuntu-24.04
environment: build
timeout-minutes: 20
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
repository: ${{ env.SOURCE_REPOSITORY }}
ref: ${{ needs.provenance.outputs.revision }}
ssh-key: ${{ secrets.ENCYPHER_SOURCE_DEPLOY_KEY }}
path: source
sparse-checkout: |
packages/encypher-c2pa
packaging
persist-credentials: false
- name: Install the pinned toolchain and llvm-tools
run: rustup toolchain install "${RUSTUP_TOOLCHAIN}" --profile minimal --component llvm-tools
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
with:
pattern: dist-*
path: dist
merge-multiple: true
- name: SHA256SUMS and inventory.json (one revision, one toolchain)
run: python3 source/packaging/release/release.py merge --dist dist
- name: Artifact gate on every archive
run: |
python3 source/packaging/release/contained.py --label "artifact gate: all archives" -- \
python3 source/packages/encypher-c2pa/scripts/check-client-artifact.py dist/*.tar.gz
- name: Attach the installers
run: cp source/packaging/install/install.sh source/packaging/install/install.ps1 dist/
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: dist
path: |
dist/*.tar.gz
dist/SHA256SUMS
dist/inventory.json
dist/install.sh
dist/install.ps1
if-no-files-found: error
retention-days: 7
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
if: failure()
with:
name: withheld-logs-merge
path: ${{ runner.temp }}/contained/*.log.cms
if-no-files-found: ignore
retention-days: 7
publish:
name: Publish v${{ needs.provenance.outputs.version }} as a pre-release
if: inputs.publish
needs: [provenance, merge]
runs-on: ubuntu-24.04
environment: publish
timeout-minutes: 15
permissions:
contents: write
steps:
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
with:
name: dist
path: dist
# Every asset is attached to a draft first, then the draft is published:
# with immutable releases on, the files and tag are locked from that
# moment, so nothing can be added or swapped afterwards.
- name: Draft, upload, publish as a pre-release
env:
GH_TOKEN: ${{ github.token }}
VERSION: ${{ needs.provenance.outputs.version }}
REVISION: ${{ needs.provenance.outputs.revision }}
PROBE_FLOOR: ${{ inputs.probe_floor }}
run: |
toolchain="$(python3 -c 'import json; print(json.load(open("dist/inventory.json"))["toolchain"])')"
notes="encypher ${VERSION}. Source revision ${REVISION}, ${toolchain}, probe floor ${PROBE_FLOOR}. Install: see the README."
gh release create "v${VERSION}" --repo "${GITHUB_REPOSITORY}" --target "${GITHUB_SHA}" --draft --prerelease \
--title "encypher ${VERSION}" --notes "${notes}" \
dist/*.tar.gz dist/SHA256SUMS dist/inventory.json dist/install.sh dist/install.ps1
gh release edit "v${VERSION}" --repo "${GITHUB_REPOSITORY}" --draft=false
install-check:
name: Install on ${{ matrix.name }}
needs: [provenance, merge, publish]
if: always() && needs.merge.result == 'success' && (needs.publish.result == 'success' || (needs.publish.result == 'skipped' && !inputs.publish))
runs-on: ${{ matrix.os }}
environment: build
timeout-minutes: 30
strategy:
fail-fast: false
matrix:
include:
- name: Linux x86_64 and rockylinux:9
os: ubuntu-22.04
unpinned: true
- name: Linux arm64
os: ubuntu-22.04-arm
unpinned: false
- name: macOS Apple Silicon
os: macos-15
unpinned: true
- name: macOS Intel
os: macos-15-intel
unpinned: false
- name: Windows x64
os: windows-2022
unpinned: true
env:
VERSION: ${{ needs.provenance.outputs.version }}
PUBLISHED: ${{ inputs.publish }}
ENCYPHER_API_KEY: ${{ secrets.ENCYPHER_API_KEY }}
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
path: public
persist-credentials: false
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
with:
python-version: "3.12"
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
with:
name: smoke-fixture
path: fixture-download
- name: Collect the fixtures
shell: bash
run: |
mkdir -p fixture
find fixture-download -type f \( -name signed-test.jpg -o -name image_png.png \) -exec cp {} fixture/ \;
echo "FIXTURE=${GITHUB_WORKSPACE}/fixture" >> "$GITHUB_ENV"
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
if: ${{ !inputs.publish }}
with:
name: dist
path: dist
- name: Download base (published release)
if: inputs.publish
shell: bash
run: echo "BASE=https://github.com/${GITHUB_REPOSITORY}/releases" >> "$GITHUB_ENV"
- name: Download base (dry run, loopback mirror of the built release)
if: ${{ !inputs.publish }}
shell: bash
run: |
mirror="${RUNNER_TEMP}/mirror"
mkdir -p "${mirror}/releases/download/v${VERSION}" "${mirror}/releases/latest/download"
cp dist/* "${mirror}/releases/download/v${VERSION}/"
# GitHub serves the latest release's assets here too, and that is
# where an unpinned install reads both the script and SHA256SUMS.
cp dist/* "${mirror}/releases/latest/download/"
{
echo "MIRROR=${mirror}"
echo "BASE=http://127.0.0.1:8765/releases"
echo "ENCYPHER_DOWNLOAD_URL=http://127.0.0.1:8765/releases"
} >> "$GITHUB_ENV"
- name: Start the loopback mirror (Linux, macOS)
if: ${{ !inputs.publish && runner.os != 'Windows' }}
shell: bash
run: |
nohup python -m http.server 8765 --bind 127.0.0.1 --directory "${MIRROR}" >/dev/null 2>&1 &
for _ in $(seq 1 30); do
curl -fsS -o /dev/null "${BASE}/download/v${VERSION}/SHA256SUMS" && exit 0
sleep 1
done
exit 1
- name: Start the loopback mirror (Windows)
if: ${{ !inputs.publish && runner.os == 'Windows' }}
shell: pwsh
run: |
Start-Process python -ArgumentList '-m', 'http.server', '8765', '--bind', '127.0.0.1', '--directory', $env:MIRROR -WindowStyle Hidden
foreach ($i in 1..30) {
try { Invoke-WebRequest -UseBasicParsing "$env:BASE/download/v$env:VERSION/SHA256SUMS" | Out-Null; exit 0 } catch { Start-Sleep 1 }
}
exit 1
# One leg per operating system takes the README's unpinned command, which
# only a dry run can exercise: until promote, `latest` still points at the
# previous release.
- name: install.sh as a user runs it
if: runner.os != 'Windows'
shell: bash
env:
ENCYPHER_CHECK_UNPINNED: ${{ !inputs.publish && matrix.unpinned && '1' || '' }}
run: bash public/.github/scripts/install-check.sh
- name: install.sh on glibc 2.34 (rockylinux:9)
if: matrix.os == 'ubuntu-22.04'
shell: bash
run: |
docker run --rm --network host -e ENCYPHER_VERSION="${VERSION}" -e ENCYPHER_DOWNLOAD_URL -e BASE \
-v "${FIXTURE}:/fixture:ro" rockylinux/rockylinux:9 \
sh -c 'curl -fsSL "${BASE}/download/v${ENCYPHER_VERSION}/install.sh" | sh && ldd --version | head -n 1 && ~/.local/bin/encypher --version && ~/.local/bin/encypher verify /fixture/signed-test.jpg --json'
- name: install.ps1 under Windows PowerShell 5.1
if: runner.os == 'Windows'
shell: powershell
env:
ENCYPHER_CHECK_UNPINNED: ${{ !inputs.publish && matrix.unpinned && '1' || '' }}
run: ./public/.github/scripts/install-check.ps1
- name: install.ps1 under PowerShell 7
if: runner.os == 'Windows'
shell: pwsh
env:
ENCYPHER_INSTALL_DIR: ${{ runner.temp }}\pwsh7
run: ./public/.github/scripts/install-check.ps1
install-verdict:
name: Install checks passed
needs: [install-check]
if: always()
runs-on: ubuntu-24.04
timeout-minutes: 5
steps:
- name: Every install-check leg ran and passed
env:
RESULT: ${{ needs.install-check.result }}
run: |
if [[ "${RESULT}" != success ]]; then
echo "install-check: ${RESULT}" >&2
exit 1
fi
promote:
name: Mark v${{ needs.provenance.outputs.version }} latest
needs: [provenance, install-check]
if: inputs.publish && needs.install-check.result == 'success'
runs-on: ubuntu-24.04
timeout-minutes: 10
permissions:
contents: write
steps:
- name: Promote, then install the latest release unpinned
env:
GH_TOKEN: ${{ github.token }}
VERSION: ${{ needs.provenance.outputs.version }}
run: |
gh release edit "v${VERSION}" --repo "${GITHUB_REPOSITORY}" --prerelease=false --latest
curl -fsSL "https://github.com/${GITHUB_REPOSITORY}/releases/latest/download/install.sh" \
| ENCYPHER_INSTALL_DIR="${RUNNER_TEMP}/latest" sh
"${RUNNER_TEMP}/latest/encypher" --version | grep -qF "\"client\": \"${VERSION}\""