ci: create notarytool profile in CI temp keychain #31
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: CI | |
| on: | |
| push: | |
| branches: [main, "feature/*", "fix/*"] | |
| tags: ["v*"] | |
| pull_request: | |
| branches: [main] | |
| workflow_dispatch: | |
| # Cancel in-progress runs for the same branch / PR | |
| concurrency: | |
| group: ${{ github.workflow }}-${{ github.ref }} | |
| cancel-in-progress: true | |
| env: | |
| MIX_ENV: test | |
| OTP_VERSION: "29.0" | |
| ELIXIR_VERSION: "1.20.1" | |
| jobs: | |
| # ── Linux: full CI quality gate ───────────────────────────────────── | |
| linux: | |
| name: "Linux · Elixir 1.20.1 · OTP 29.0" | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 20 | |
| steps: | |
| - uses: actions/checkout@v4 | |
| - name: Set up Elixir | |
| uses: erlef/setup-beam@v1 | |
| with: | |
| otp-version: ${{ env.OTP_VERSION }} | |
| elixir-version: ${{ env.ELIXIR_VERSION }} | |
| - name: Cache build artifacts | |
| uses: actions/cache@v4 | |
| with: | |
| path: | | |
| _build | |
| deps | |
| priv/plts | |
| key: ${{ runner.os }}-mix-${{ hashFiles('**/mix.lock') }} | |
| restore-keys: | | |
| ${{ runner.os }}-mix- | |
| ${{ runner.os }}- | |
| - name: Install dependencies | |
| run: mix deps.get | |
| - name: Compile (warnings-as-errors) | |
| # We intentionally don't pass --all-warnings: that flag treats | |
| # third-party dep warnings (hpax bitstring ops, yamerl's | |
| # deprecated catch, etc.) as errors. We only want to enforce | |
| # warnings-as-errors on our own code, which `mix compile | |
| # --warnings-as-errors` already does. | |
| run: mix compile --warnings-as-errors | |
| - name: Check formatting | |
| run: mix format --check-formatted | |
| - name: Credo (strict) | |
| run: mix credo --strict | |
| - name: Audit dependencies for CVEs | |
| run: mix deps.audit | |
| - name: Check for unused dependencies | |
| run: mix deps.unlock --check-unused | |
| - name: xref (no orphan modules) | |
| run: mix xref graph --label compile-connected --fail-above 0 | |
| - name: Dialyzer | |
| run: mix ci.dialyzer | |
| timeout-minutes: 15 | |
| - name: Run tests with coverage | |
| # mix test --cover runs the tests once and produces both | |
| # the Elixir coverage report (cover/*.html) and the | |
| # excoveralls JSON (cover/excoveralls.json via tool: ExCoveralls). | |
| # The strict threshold check is disabled (threshold: 0 in | |
| # mix.exs) until CLI integration tests are added. | |
| run: mix test --cover | |
| - name: Post coverage to Codecov | |
| # Push the JSON to codecov.io using their bash uploader. This | |
| # is the path recommended by excoveralls for Codecov. | |
| # | |
| # Requires CODECOV_TOKEN in repo Settings -> Secrets -> Actions. | |
| # Get the token at https://codecov.io/gh/gilbertwong96/ado_cli | |
| # -> Settings -> Upload Token. The bash uploader reads it from | |
| # the CODECOV_TOKEN env var. | |
| # | |
| # Skip the step if the secret is not set. We don't fail the | |
| # build over missing coverage uploads — coverage is still | |
| # available as an action artifact download. | |
| env: | |
| CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} | |
| run: | | |
| if [ -z "$CODECOV_TOKEN" ]; then | |
| echo "::notice::CODECOV_TOKEN not set — skipping Codecov upload." | |
| echo "Add it at https://codecov.io/gh/gilbertwong96/ado_cli" | |
| echo "to enable the coverage badge." | |
| exit 0 | |
| fi | |
| curl -Os https://uploader.codecov.io/latest/linux/codecov | |
| chmod +x codecov | |
| ./codecov --token "$CODECOV_TOKEN" --file ./cover/excoveralls.json | |
| continue-on-error: true | |
| - name: Upload coverage artifact | |
| if: always() | |
| uses: actions/upload-artifact@v4 | |
| with: | |
| name: coverage-linux | |
| path: cover/excoveralls.json | |
| retention-days: 30 | |
| # ── macOS: smoke test (the CLI is cross-compiled via Burrito) ────── | |
| macos: | |
| name: "macOS · Elixir 1.20.1" | |
| runs-on: macos-latest | |
| timeout-minutes: 15 | |
| steps: | |
| - uses: actions/checkout@v4 | |
| - name: Set up Elixir | |
| uses: erlef/setup-beam@v1 | |
| with: | |
| otp-version: ${{ env.OTP_VERSION }} | |
| elixir-version: ${{ env.ELIXIR_VERSION }} | |
| - name: Cache build artifacts | |
| uses: actions/cache@v4 | |
| with: | |
| path: | | |
| _build | |
| deps | |
| priv/plts | |
| key: ${{ runner.os }}-mix-${{ hashFiles('**/mix.lock') }} | |
| restore-keys: | | |
| ${{ runner.os }}-mix- | |
| ${{ runner.os }}- | |
| - name: Install dependencies | |
| run: mix deps.get | |
| - name: Build escript | |
| run: mix escript.build | |
| - name: "Smoke test: --help works" | |
| run: "./ado --help" | |
| - name: "Smoke test: whoami with no auth returns friendly error" | |
| run: | | |
| rm -f "$HOME/.ado_cli/config.json" | |
| ./ado whoami || true | |
| - name: "Smoke test: logout is idempotent" | |
| run: ./ado logout | |
| - name: Run tests | |
| run: mix test | |
| # ── Summary job — gate merge on all green ─────────────────────────── | |
| ci-status: | |
| name: CI Status | |
| if: always() | |
| needs: [linux, macos] | |
| runs-on: ubuntu-latest | |
| steps: | |
| - name: Fail if any matrix job failed | |
| if: needs.linux.result != 'success' || needs.macos.result != 'success' | |
| run: | | |
| echo "Linux result: ${{ needs.linux.result }}" | |
| echo "macOS result: ${{ needs.macos.result }}" | |
| exit 1 | |
| # ── Release build: cross-compile via Burrito, rename, upload artifacts ─ | |
| release: | |
| name: "Release · Burrito · ${{ matrix.label }}" | |
| # Only build releases on pushes to main (not on PRs) and on tag pushes. | |
| # Use workflow_dispatch to trigger manually for ad-hoc builds. | |
| if: | | |
| github.event_name == 'push' && | |
| (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/')) | |
| needs: [ci-status] | |
| runs-on: ${{ matrix.os }} | |
| timeout-minutes: 30 | |
| strategy: | |
| # Don't cancel other platforms if one fails — we want all | |
| # binaries even if e.g. macOS signing fails. | |
| fail-fast: false | |
| matrix: | |
| include: | |
| - os: ubuntu-latest | |
| target: linux | |
| cpu: x86_64 | |
| ext: "" | |
| label: "Linux x86_64" | |
| artifact: ado-linux-x86_64 | |
| sign: "false" | |
| - os: ubuntu-24.04-arm | |
| target: linux_arm | |
| cpu: aarch64 | |
| ext: "" | |
| label: "Linux ARM64" | |
| artifact: ado-linux-aarch64 | |
| sign: "false" | |
| - os: macos-latest | |
| target: macos | |
| cpu: aarch64 | |
| ext: "" | |
| label: "macOS Apple Silicon" | |
| artifact: ado-macos-aarch64 | |
| sign: "true" | |
| - os: macos-latest | |
| target: macos_x86 | |
| cpu: x86_64 | |
| ext: "" | |
| label: "macOS Intel" | |
| artifact: ado-macos-x86_64 | |
| sign: "true" | |
| steps: | |
| - uses: actions/checkout@v4 | |
| - name: Set up Elixir | |
| uses: erlef/setup-beam@v1 | |
| with: | |
| otp-version: ${{ env.OTP_VERSION }} | |
| elixir-version: ${{ env.ELIXIR_VERSION }} | |
| env: | |
| # On ubuntu-24.04-arm, ImageOS is reported as | |
| # 'ubuntu24-arm64' which erlef/setup-beam doesn't recognize. | |
| # Override to 'ubuntu24' (Erlang/Elixir prebuilt binaries | |
| # are architecture-independent, so the ubuntu24 ones work | |
| # fine on the arm64 runner). | |
| ImageOS: ubuntu24 | |
| - name: Install Zig | |
| # Burrito needs a specific zig version (0.16.0) to cross-compile | |
| # BEAM. Install it via mlugg/setup-zig (the recommended | |
| # successor to goto-bus-stop/setup-zig, which is now | |
| # deprecated and hits GitHub's cache service unreliability). | |
| uses: mlugg/setup-zig@v2 | |
| with: | |
| version: 0.16.0 | |
| - name: Install xz | |
| # Burrito needs xz to extract zig's tarball and musl. xz-utils | |
| # ships with the Linux build image but not always; install | |
| # defensively. | |
| if: runner.os == 'Linux' | |
| run: sudo apt-get -y install xz-utils | |
| - name: Install xz (macOS) | |
| # xz is rarely preinstalled on macOS runners. | |
| if: runner.os == 'macOS' | |
| run: brew install xz | |
| - name: Verify zig/xz | |
| run: | | |
| zig version | |
| xz --version | |
| - name: Cache build artifacts | |
| uses: actions/cache@v4 | |
| with: | |
| path: | | |
| _build | |
| deps | |
| priv/plts | |
| ~/.cache/burrito_file_cache | |
| key: release-${{ runner.os }}-${{ hashFiles('**/mix.lock') }} | |
| restore-keys: | | |
| release-${{ runner.os }}- | |
| - name: Install dependencies | |
| run: mix deps.get | |
| - name: Build Burrito release | |
| env: | |
| MIX_ENV: prod | |
| # Burrito accepts BURRITO_TARGET=<target-key> to build only | |
| # that target from the targets list in mix.exs. This avoids | |
| # cross-compiling for all 4 platforms on each runner. | |
| BURRITO_TARGET: ${{ matrix.target }} | |
| # Burrito writes the binary to burrito_out/ado_<target> by | |
| # convention. We rename the output below to a stable, | |
| # versioned name. | |
| run: | | |
| mkdir -p release_bin | |
| mix release --overwrite | |
| ls -lh burrito_out/ | |
| - name: Get version | |
| id: version | |
| run: | | |
| # Read the version from mix.exs so the release artifact name | |
| # always matches the source of truth. | |
| VERSION=$(grep -E '^\s*version:\s*"' mix.exs | head -1 | sed -E 's/.*"([^"]+)".*/\1/') | |
| echo "version=$VERSION" >> "$GITHUB_OUTPUT" | |
| - name: Rename binary | |
| # Normalize the binary name to: | |
| # ado-<version>-<os>-<arch><.exe?> | |
| # e.g. ado-0.1.0-macos-aarch64 | |
| # ado-0.1.0-linux-x86_64 | |
| run: | | |
| set -euo pipefail | |
| VERSION="${{ steps.version.outputs.version }}" | |
| # Map matrix.target to a clean {os}-{arch} string | |
| case "${{ matrix.target }}" in | |
| linux) SUFFIX="linux-x86_64" ;; | |
| linux_arm) SUFFIX="linux-aarch64" ;; | |
| macos) SUFFIX="macos-aarch64" ;; | |
| macos_x86) SUFFIX="macos-x86_64" ;; | |
| *) echo "::error::Unknown target ${{ matrix.target }}"; exit 1 ;; | |
| esac | |
| OUT_NAME="ado-${VERSION}-${SUFFIX}${{ matrix.ext }}" | |
| SRC="burrito_out/ado_${{ matrix.target }}${{ matrix.ext }}" | |
| DEST="release_bin/${OUT_NAME}" | |
| if [[ ! -f "${SRC}" ]]; then | |
| echo "::error::Expected Burrito output not found: ${SRC}" | |
| ls -lh burrito_out/ | |
| exit 1 | |
| fi | |
| mv "${SRC}" "${DEST}" | |
| chmod +x "${DEST}" | |
| echo "Renamed ${SRC} -> ${DEST}" | |
| ls -lh release_bin/ | |
| - name: Smoke test the binary | |
| # All release jobs run on a native runner for the target | |
| # platform (e.g. ubuntu-24.04-arm for the Linux ARM build), | |
| # so the binary can always be executed here. | |
| run: | | |
| set -euo pipefail | |
| # Glob is intentional here — there's exactly one file in | |
| # the dir, but actionlint warns about unquoted globs. | |
| BINARY=$(ls release_bin/ado-*) | |
| echo "Testing $BINARY" | |
| file "$BINARY" | |
| "$BINARY" --version || "$BINARY" --help | head -20 | |
| # ── macOS code signing + notarization ───────────────────────────── | |
| # Only runs for matrix entries with sign: "true" (i.e. the two | |
| # macOS targets). Requires these GitHub Secrets: | |
| # MACOS_SIGN_IDENTITY e.g. "Developer ID Application: Foo (TEAMID1234)" | |
| # MACOS_CERT_P12_BASE64 base64 of the .p12 export of the cert | |
| # MACOS_CERT_P12_PASSWORD the .p12's export password | |
| # MACOS_KEYCHAIN_PROFILE name of a notarytool keychain profile | |
| # (set up locally with | |
| # `xcrun notarytool store-credentials`) | |
| # If any of these are missing on a non-tag push, the step | |
| # skips with a warning so PR builds still succeed. | |
| - name: Import code-signing certificate | |
| if: matrix.sign == 'true' | |
| env: | |
| MACOS_CERT_P12_BASE64: ${{ secrets.MACOS_CERT_P12_BASE64 }} | |
| MACOS_CERT_P12_PASSWORD: ${{ secrets.MACOS_CERT_P12_PASSWORD }} | |
| run: | | |
| set -euo pipefail | |
| if [[ -z "${MACOS_CERT_P12_BASE64}" || -z "${MACOS_CERT_P12_PASSWORD}" ]]; then | |
| echo "::warning::MACOS_CERT_P12_BASE64 / MACOS_CERT_P12_PASSWORD secrets not set; skipping signing." | |
| echo "::warning::See SIGNING.md for the one-time setup." | |
| exit 0 | |
| fi | |
| KEYCHAIN_PATH="$RUNNER_TEMP/ado-signing.keychain-db" | |
| KEYCHAIN_PASSWORD="$(openssl rand -hex 24)" | |
| security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" | |
| security set-keychain-settings -lut 21600 "$KEYCHAIN_PATH" | |
| security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" | |
| # shellcheck disable=SC2046 | |
| security list-keychains -d user -s "$KEYCHAIN_PATH" $(security list-keychains -d user | tr -d '"') | |
| echo "$MACOS_CERT_P12_BASE64" | base64 --decode > "$RUNNER_TEMP/cert.p12" | |
| security import "$RUNNER_TEMP/cert.p12" \ | |
| -k "$KEYCHAIN_PATH" \ | |
| -P "$MACOS_CERT_P12_PASSWORD" \ | |
| -T /usr/bin/codesign \ | |
| -T /usr/bin/security | |
| security set-key-partition-list -S apple-tool:,apple: -s -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" | |
| echo "MACOS_KEYCHAIN_PATH=$KEYCHAIN_PATH" >> "$GITHUB_ENV" | |
| echo "MACOS_KEYCHAIN_PASSWORD=$KEYCHAIN_PASSWORD" >> "$GITHUB_ENV" | |
| - name: Sign macOS binary | |
| if: matrix.sign == 'true' | |
| env: | |
| MACOS_SIGN_IDENTITY: ${{ secrets.MACOS_SIGN_IDENTITY }} | |
| run: | | |
| set -euo pipefail | |
| if [[ -z "${MACOS_SIGN_IDENTITY}" ]]; then | |
| echo "::warning::MACOS_SIGN_IDENTITY secret not set; skipping signing." | |
| exit 0 | |
| fi | |
| if [[ ! -f "$MACOS_KEYCHAIN_PATH" ]]; then | |
| echo "::warning::No keychain (secrets not set); skipping signing." | |
| exit 0 | |
| fi | |
| BINARY=$(ls release_bin/ado-*) | |
| echo "Signing $BINARY with: $MACOS_SIGN_IDENTITY" | |
| codesign \ | |
| --force \ | |
| --options runtime \ | |
| --timestamp \ | |
| --entitlements bin/ado.entitlements \ | |
| --sign "$MACOS_SIGN_IDENTITY" \ | |
| --keychain "$MACOS_KEYCHAIN_PATH" \ | |
| "$BINARY" | |
| echo "Verifying signature" | |
| codesign --verify --verbose=2 "$BINARY" | |
| codesign -d --entitlements - "$BINARY" 2>&1 | head -20 | |
| - name: Notarize macOS binary | |
| if: matrix.sign == 'true' | |
| env: | |
| # Profile name + the underlying Apple ID + app-specific password | |
| # needed to recreate the profile in the temp keychain. | |
| # (The profile itself only lives in the developer's local | |
| # keychain; the CI runner has its own fresh keychain.) | |
| MACOS_KEYCHAIN_PROFILE: ${{ secrets.MACOS_KEYCHAIN_PROFILE }} | |
| MACOS_NOTARY_APPLE_ID: ${{ secrets.MACOS_NOTARY_APPLE_ID }} | |
| MACOS_NOTARY_TEAM_ID: ${{ secrets.MACOS_NOTARY_TEAM_ID || '87R27UKGSF' }} | |
| MACOS_NOTARY_PASSWORD: ${{ secrets.MACOS_NOTARY_PASSWORD }} | |
| run: | | |
| set -euo pipefail | |
| BINARY=$(ls release_bin/ado-*) | |
| if [[ -z "${MACOS_KEYCHAIN_PROFILE}" || -z "${MACOS_NOTARY_APPLE_ID}" || -z "${MACOS_NOTARY_PASSWORD}" ]]; then | |
| echo "::warning::MACOS_KEYCHAIN_PROFILE / MACOS_NOTARY_APPLE_ID / MACOS_NOTARY_PASSWORD secrets not set; skipping notarization." | |
| echo "::warning::Signed binary is OK for local use but Gatekeeper will warn on first run." | |
| exit 0 | |
| fi | |
| if [[ ! -f "$MACOS_KEYCHAIN_PATH" ]]; then | |
| echo "::warning::No keychain (cert secrets not set); skipping notarization." | |
| exit 0 | |
| fi | |
| echo "Recreating notarytool profile '$MACOS_KEYCHAIN_PROFILE' in temp keychain" | |
| xcrun notarytool store-credentials "$MACOS_KEYCHAIN_PROFILE" \ | |
| --apple-id "$MACOS_NOTARY_APPLE_ID" \ | |
| --team-id "$MACOS_NOTARY_TEAM_ID" \ | |
| --password "$MACOS_NOTARY_PASSWORD" \ | |
| --keychain "$MACOS_KEYCHAIN_PATH" \ | |
| --keychain-password "$MACOS_KEYCHAIN_PASSWORD" | |
| echo "Submitting for notarization (profile: $MACOS_KEYCHAIN_PROFILE)" | |
| xcrun notarytool submit "$BINARY" \ | |
| --keychain-profile "$MACOS_KEYCHAIN_PROFILE" \ | |
| --keychain "$MACOS_KEYCHAIN_PATH" \ | |
| --keychain-password "$MACOS_KEYCHAIN_PASSWORD" \ | |
| --wait | |
| echo "Stapling notarization ticket" | |
| xcrun stapler staple "$BINARY" | |
| xcrun stapler validate "$BINARY" | |
| echo "spctl assessment:" | |
| spctl --assess --verbose=2 "$BINARY" | |
| - name: Clean up keychain | |
| if: always() && matrix.sign == 'true' && env.MACOS_KEYCHAIN_PATH != '' | |
| run: | | |
| if [[ -n "${MACOS_KEYCHAIN_PATH:-}" && -f "$MACOS_KEYCHAIN_PATH" ]]; then | |
| security delete-keychain "$MACOS_KEYCHAIN_PATH" || true | |
| # shellcheck disable=SC2046 | |
| security list-keychains -d user -s $(security list-keychains -d user | grep -v ado-signing | tr -d '"') || true | |
| fi | |
| - name: Upload release binary | |
| uses: actions/upload-artifact@v4 | |
| with: | |
| name: ${{ matrix.artifact }} | |
| path: release_bin/ado-* | |
| retention-days: 90 | |
| if-no-files-found: error | |
| # ── Attach binaries to GitHub Release on tag push ────────────────── | |
| release-attach: | |
| name: Publish GitHub Release | |
| if: startsWith(github.ref, 'refs/tags/') | |
| needs: [release] | |
| runs-on: ubuntu-latest | |
| permissions: | |
| contents: write | |
| steps: | |
| - uses: actions/download-artifact@v4 | |
| with: | |
| path: dist/ | |
| merge-multiple: true | |
| - name: Display downloaded artifacts | |
| run: ls -lh dist/ | |
| - name: Create GitHub Release | |
| uses: softprops/action-gh-release@v2 | |
| with: | |
| tag_name: ${{ github.ref_name }} | |
| name: "AdoCli ${{ github.ref_name }}" | |
| body: | | |
| ## AdoCli ${{ github.ref_name }} | |
| Pre-built binaries for all supported platforms. | |
| | Platform | Architecture | Binary | | |
| |----------|--------------|--------| | |
| | Linux | x86_64 | `ado-*-linux-x86_64` | | |
| | Linux | aarch64 | `ado-*-linux-aarch64` | | |
| | macOS | Apple Silicon | `ado-*-macos-aarch64` | | |
| | macOS | Intel | `ado-*-macos-x86_64` | | |
| See [README.md](https://github.com/gilbertwong96/ado_cli) for | |
| installation instructions. | |
| draft: false | |
| prerelease: false | |
| files: | | |
| dist/ado-* |