name: Deployment run-name: ${{ inputs.tag_name }} / ${{ inputs.environment }} concurrency: group: ${{ github.workflow }}-${{ github.ref_name }} cancel-in-progress: true permissions: attestations: write contents: write id-token: write on: workflow_dispatch: inputs: tag_name: required: true type: string environment: default: production type: environment platforms: default: "linux,macos,windows" type: string release: description: "Whether to create a GitHub Release" type: boolean default: true jobs: validate-tag-name: runs-on: ubuntu-latest steps: - name: Validate tag name format env: TAG_NAME: ${{ inputs.tag_name }} run: | if [[ ! "$TAG_NAME" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then echo "Invalid tag name format. Must be in the form v1.2.3" exit 1 fi linux: needs: validate-tag-name runs-on: ubuntu-latest environment: ${{ inputs.environment }} if: contains(inputs.platforms, 'linux') steps: - name: Checkout uses: actions/checkout@v6 - name: Set up Go uses: actions/setup-go@v6 with: go-version-file: 'go.mod' - name: Install GoReleaser uses: goreleaser/goreleaser-action@1a80836c5c9d9e5755a25cb59ec6f45a3b5f41a8 # v7.2.1 with: # The version is pinned not only for security purposes, but also to avoid breaking # our scripts, which rely on the specific file names generated by GoReleaser. version: v2.13.1 install-only: true # We temporarily create a tag on HEAD to make the right version embedded # in the built binaries, BUT we don't push it to the remote. - name: Create temporary tag env: TAG_NAME: ${{ inputs.tag_name }} run: git tag "$TAG_NAME" - name: Build release binaries env: TAG_NAME: ${{ inputs.tag_name }} run: script/release --local "$TAG_NAME" --platform linux - name: Generate web manual pages run: | go run ./cmd/gen-docs --website --doc-path dist/manual tar -czvf dist/manual.tar.gz -C dist -- manual - uses: actions/upload-artifact@v7 with: name: linux if-no-files-found: error retention-days: 7 path: | dist/*.tar.gz dist/*.rpm dist/*.deb macos: needs: validate-tag-name runs-on: macos-latest environment: ${{ inputs.environment }} if: contains(inputs.platforms, 'macos') steps: - name: Checkout uses: actions/checkout@v6 - name: Set up Go uses: actions/setup-go@v6 with: go-version-file: 'go.mod' - name: Configure macOS signing if: inputs.environment == 'production' env: APPLE_DEVELOPER_ID: ${{ vars.APPLE_DEVELOPER_ID }} APPLE_APPLICATION_CERT: ${{ secrets.APPLE_APPLICATION_CERT }} APPLE_APPLICATION_CERT_PASSWORD: ${{ secrets.APPLE_APPLICATION_CERT_PASSWORD }} run: | keychain="$RUNNER_TEMP/buildagent.keychain" keychain_password="password1" security create-keychain -p "$keychain_password" "$keychain" security default-keychain -s "$keychain" security unlock-keychain -p "$keychain_password" "$keychain" base64 -D <<<"$APPLE_APPLICATION_CERT" > "$RUNNER_TEMP/cert.p12" security import "$RUNNER_TEMP/cert.p12" -k "$keychain" -P "$APPLE_APPLICATION_CERT_PASSWORD" -T /usr/bin/codesign security set-key-partition-list -S "apple-tool:,apple:,codesign:" -s -k "$keychain_password" "$keychain" rm "$RUNNER_TEMP/cert.p12" - name: Install GoReleaser uses: goreleaser/goreleaser-action@1a80836c5c9d9e5755a25cb59ec6f45a3b5f41a8 # v7.2.1 with: # The version is pinned not only for security purposes, but also to avoid breaking # our scripts, which rely on the specific file names generated by GoReleaser. version: v2.13.1 install-only: true # We temporarily create a tag on HEAD to make the right version embedded # in the built binaries, BUT we don't push it to the remote. - name: Create temporary tag env: TAG_NAME: ${{ inputs.tag_name }} run: git tag "$TAG_NAME" - name: Build release binaries env: TAG_NAME: ${{ inputs.tag_name }} APPLE_DEVELOPER_ID: ${{ vars.APPLE_DEVELOPER_ID }} run: script/release --local "$TAG_NAME" --platform macos - name: Notarize macOS archives if: inputs.environment == 'production' env: APPLE_ID: ${{ vars.APPLE_ID }} APPLE_ID_PASSWORD: ${{ secrets.APPLE_ID_PASSWORD }} APPLE_DEVELOPER_ID: ${{ vars.APPLE_DEVELOPER_ID }} run: | shopt -s failglob script/sign dist/gh_*_macOS_*.zip - name: Build universal macOS pkg installer if: inputs.environment != 'production' env: TAG_NAME: ${{ inputs.tag_name }} run: script/pkgmacos "$TAG_NAME" - name: Build & notarize universal macOS pkg installer if: inputs.environment == 'production' env: TAG_NAME: ${{ inputs.tag_name }} APPLE_DEVELOPER_INSTALLER_ID: ${{ vars.APPLE_DEVELOPER_INSTALLER_ID }} run: | shopt -s failglob script/pkgmacos "$TAG_NAME" - uses: actions/upload-artifact@v7 with: name: macos if-no-files-found: error retention-days: 7 path: | dist/*.tar.gz dist/*.zip dist/*.pkg windows: needs: validate-tag-name runs-on: windows-2022 environment: ${{ inputs.environment }} if: contains(inputs.platforms, 'windows') steps: - name: Checkout uses: actions/checkout@v6 - name: Set up Go uses: actions/setup-go@v6 with: go-version-file: 'go.mod' - name: Install GoReleaser uses: goreleaser/goreleaser-action@1a80836c5c9d9e5755a25cb59ec6f45a3b5f41a8 # v7.2.1 with: # The version is pinned not only for security purposes, but also to avoid breaking # our scripts, which rely on the specific file names generated by GoReleaser. version: v2.13.1 install-only: true - name: Install Azure Code Signing Client shell: pwsh env: ACS_DIR: ${{ runner.temp }}\acs ACS_ZIP: ${{ runner.temp }}\acs.zip CORRELATION_ID: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} METADATA_PATH: ${{ runner.temp }}\acs\metadata.json run: | # Download Azure Code Signing client containing the DLL needed for signtool in script/sign Invoke-WebRequest -Uri https://www.nuget.org/api/v2/package/Microsoft.Trusted.Signing.Client/1.0.95 -OutFile $Env:ACS_ZIP -Verbose Expand-Archive $Env:ACS_ZIP -Destination $Env:ACS_DIR -Force -Verbose # Generate metadata file for signtool, used in signing box .exe and .msi @{ CertificateProfileName = "GitHubInc" CodeSigningAccountName = "GitHubInc" CorrelationId = $Env:CORRELATION_ID Endpoint = "https://wus3.codesigning.azure.net/" } | ConvertTo-Json | Out-File -FilePath $Env:METADATA_PATH # We temporarily create a tag on HEAD to make the right version embedded # in the built binaries, BUT we don't push it to the remote. - name: Create temporary tag shell: bash env: TAG_NAME: ${{ inputs.tag_name }} run: git tag "$TAG_NAME" - name: Authenticate to Azure for code signing if: inputs.environment == 'production' uses: azure/login@532459ea530d8321f2fb9bb10d1e0bcf23869a43 # v3.0.0 with: client-id: ${{ secrets.SPN_GITHUB_CLI_SIGNING_CLIENT_ID }} tenant-id: ${{ secrets.SPN_GITHUB_CLI_SIGNING_TENANT_ID }} allow-no-subscriptions: true # Azure Code Signing authenticates via OIDC (azure/login above). AZURE_CLIENT_ID and AZURE_TENANT_ID # are still passed so DefaultAzureCredential can identify the service principal. - name: Build release binaries shell: bash env: AZURE_CLIENT_ID: ${{ secrets.SPN_GITHUB_CLI_SIGNING_CLIENT_ID }} AZURE_TENANT_ID: ${{ secrets.SPN_GITHUB_CLI_SIGNING_TENANT_ID }} DLIB_PATH: ${{ runner.temp }}\acs\bin\x64\Azure.CodeSigning.Dlib.dll METADATA_PATH: ${{ runner.temp }}\acs\metadata.json TAG_NAME: ${{ inputs.tag_name }} run: script/release --local "$TAG_NAME" --platform windows - name: Set up MSBuild id: setupmsbuild uses: microsoft/setup-msbuild@30375c66a4eea26614e0d39710365f22f8b0af57 - name: Build MSI shell: bash env: MSBUILD_PATH: ${{ steps.setupmsbuild.outputs.msbuildPath }} run: | for ZIP_FILE in dist/gh_*_windows_*.zip; do MSI_NAME="$(basename "$ZIP_FILE" ".zip")" MSI_VERSION="$(cut -d_ -f2 <<<"$MSI_NAME" | cut -d- -f1)" case "$MSI_NAME" in *_386 ) source_dir="$PWD/dist/windows_windows_386_sse2" platform="x86" ;; *_amd64 ) source_dir="$PWD/dist/windows_windows_amd64_v1" platform="x64" ;; *_arm64 ) source_dir="$PWD/dist/windows_windows_arm64_v8.0" platform="arm64" ;; * ) printf "unsupported architecture: %s\n" "$MSI_NAME" >&2 exit 1 ;; esac "${MSBUILD_PATH}\MSBuild.exe" ./build/windows/gh.wixproj -p:SourceDir="$source_dir" -p:OutputPath="$PWD/dist" -p:OutputName="$MSI_NAME" -p:ProductVersion="${MSI_VERSION#v}" -p:Platform="$platform" done - name: Sign .msi release binaries if: inputs.environment == 'production' shell: pwsh env: AZURE_CLIENT_ID: ${{ secrets.SPN_GITHUB_CLI_SIGNING_CLIENT_ID }} AZURE_TENANT_ID: ${{ secrets.SPN_GITHUB_CLI_SIGNING_TENANT_ID }} DLIB_PATH: ${{ runner.temp }}\acs\bin\x64\Azure.CodeSigning.Dlib.dll METADATA_PATH: ${{ runner.temp }}\acs\metadata.json run: | Get-ChildItem -Path .\dist -Filter *.msi | ForEach-Object { .\script\sign.ps1 $_.FullName } - uses: actions/upload-artifact@v7 with: name: windows if-no-files-found: error retention-days: 7 path: | dist/*.zip dist/*.msi release: runs-on: ubuntu-latest needs: [linux, macos, windows] environment: ${{ inputs.environment }} if: inputs.release steps: - name: Checkout cli/cli uses: actions/checkout@v6 - name: Merge built artifacts uses: actions/download-artifact@v8 - name: Checkout documentation site uses: actions/checkout@v6 with: repository: github/cli.github.com path: site fetch-depth: 0 token: ${{ secrets.SITE_DEPLOY_PAT }} - name: Update site man pages env: GIT_COMMITTER_NAME: cli automation GIT_AUTHOR_NAME: cli automation GIT_COMMITTER_EMAIL: noreply@github.com GIT_AUTHOR_EMAIL: noreply@github.com TAG_NAME: ${{ inputs.tag_name }} run: | git -C site rm 'manual/gh*.md' 2>/dev/null || true tar -xzvf linux/manual.tar.gz -C site git -C site add 'manual/gh*.md' sed -i.bak -E "s/(assign version = )\".+\"/\1\"${TAG_NAME#v}\"/" site/index.html rm -f site/index.html.bak git -C site add index.html git -C site diff --quiet --cached || git -C site commit -m "gh ${TAG_NAME#v}" - name: Prepare release assets env: TAG_NAME: ${{ inputs.tag_name }} run: | shopt -s failglob rm -rf dist mkdir dist mv -v {linux,macos,windows}/gh_* dist/ - name: Install packaging dependencies run: sudo apt-get install -y rpm reprepro - name: Set up GPG if: inputs.environment == 'production' env: GPG_PUBKEY: ${{ secrets.GPG_PUBKEY }} GPG_KEY: ${{ secrets.GPG_KEY }} GPG_PASSPHRASE: ${{ secrets.GPG_PASSPHRASE }} GPG_KEYGRIP: ${{ secrets.GPG_KEYGRIP }} run: | base64 -d <<<"$GPG_PUBKEY" | gpg --import --no-tty --batch --yes base64 -d <<<"$GPG_KEY" | gpg --import --no-tty --batch --yes echo "allow-preset-passphrase" > ~/.gnupg/gpg-agent.conf gpg-connect-agent RELOADAGENT /bye base64 -d <<<"$GPG_PASSPHRASE" | /usr/lib/gnupg2/gpg-preset-passphrase --preset "$GPG_KEYGRIP" - name: Sign RPMs if: inputs.environment == 'production' run: | cp script/rpmmacros ~/.rpmmacros rpmsign --addsign dist/*.rpm - name: Attest release artifacts if: inputs.environment == 'production' uses: actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32 # v4.1.0 with: subject-path: "dist/gh_*" create-storage-record: false # (default: true) - name: Run createrepo env: GPG_SIGN: ${{ inputs.environment == 'production' }} run: | mkdir -p site/packages/rpm cp dist/*.rpm site/packages/rpm/ ./script/createrepo.sh cp -r dist/repodata site/packages/rpm/ pushd site/packages/rpm [ "$GPG_SIGN" = "false" ] || gpg --yes --detach-sign --armor repodata/repomd.xml popd - name: Run reprepro env: GPG_SIGN: ${{ inputs.environment == 'production' }} # We are no longer adding to the distribution list. # All apt distributions should use "stable" according to our install documentation. # In the future we will remove legacy distributions listed here. RELEASES: "cosmic eoan disco groovy focal stable oldstable testing sid unstable buster bullseye stretch jessie bionic trusty precise xenial hirsute impish kali-rolling" run: | mkdir -p upload [ "$GPG_SIGN" = "true" ] || sed -i.bak '/^SignWith:/d' script/distributions for release in $RELEASES; do for file in dist/*.deb; do reprepro --confdir="+b/script" includedeb "$release" "$file" done done cp -a dists/ pool/ upload/ mkdir -p site/packages cp -a upload/* site/packages/ - name: Create the release env: # In non-production environments, the assets will not have been signed DO_PUBLISH: ${{ inputs.environment == 'production' }} TAG_NAME: ${{ inputs.tag_name }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | shopt -s failglob pushd dist shasum -a 256 gh_* > checksums.txt mv checksums.txt gh_${TAG_NAME#v}_checksums.txt popd release_args=( "$TAG_NAME" --title "GitHub CLI ${TAG_NAME#v}" --target "$GITHUB_SHA" --generate-notes ) if [[ $TAG_NAME == *-* ]]; then release_args+=( --prerelease ) fi guard="echo" [ "$DO_PUBLISH" = "false" ] || guard="" script/label-assets dist/gh_* | xargs $guard gh release create "${release_args[@]}" -- - name: Publish site env: DO_PUBLISH: ${{ inputs.environment == 'production' && !contains(inputs.tag_name, '-') }} TAG_NAME: ${{ inputs.tag_name }} GIT_COMMITTER_NAME: cli automation GIT_AUTHOR_NAME: cli automation GIT_COMMITTER_EMAIL: noreply@github.com GIT_AUTHOR_EMAIL: noreply@github.com working-directory: ./site run: | git add packages git commit -m "Add rpm and deb packages for $TAG_NAME" if [ "$DO_PUBLISH" = "true" ]; then git push else git log --oneline @{upstream}.. git diff --name-status @{upstream}.. fi - name: Bump homebrew-core formula uses: mislav/bump-homebrew-formula-action@ccf2332299a883f6af50a1d2d41e5df7904dd769 if: inputs.environment == 'production' && !contains(inputs.tag_name, '-') with: formula-name: gh formula-path: Formula/g/gh.rb tag-name: ${{ inputs.tag_name }} push-to: williammartin/homebrew-core env: COMMITTER_TOKEN: ${{ secrets.HOMEBREW_PR_PAT }}