Harden release artifact publishing with immutable checksums #149
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: Release | |
| on: | |
| push: | |
| tags: | |
| - "v*" | |
| pull_request: | |
| workflow_dispatch: | |
| permissions: {} | |
| jobs: | |
| build: | |
| name: Build executable for ${{ matrix.os }}-${{ matrix.arch }} | |
| runs-on: ${{ matrix.os }} | |
| permissions: | |
| contents: read | |
| strategy: | |
| matrix: | |
| include: | |
| - os: ubuntu-latest | |
| arch: x64 | |
| platform: linux-x64 | |
| target: bun-linux-x64 | |
| - os: ubuntu-24.04-arm | |
| arch: arm64 | |
| platform: linux-arm64 | |
| target: bun-linux-arm64 | |
| - os: macos-latest | |
| arch: x64 | |
| platform: darwin-x64 | |
| target: bun-darwin-x64 | |
| - os: macos-latest | |
| arch: arm64 | |
| platform: darwin-arm64 | |
| target: bun-darwin-arm64 | |
| steps: | |
| - name: Checkout repository | |
| uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 | |
| with: | |
| fetch-depth: 0 # Full history needed for git operations | |
| persist-credentials: false | |
| - name: Setup Node.js | |
| uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 | |
| with: | |
| node-version: "22.22" | |
| package-manager-cache: false | |
| - name: Setup pnpm | |
| uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0 | |
| with: | |
| version: 10.22.0 | |
| - name: Setup Bun | |
| uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 | |
| with: | |
| bun-version: latest | |
| no-cache: true | |
| - name: Install dependencies | |
| run: pnpm install | |
| - name: Build executable | |
| run: | | |
| VERSION=$(node -p "require('./package.json').version") | |
| NODE_ENV=production bun build --compile --target=${{ matrix.target }} \ | |
| --define "CLI_VERSION='$VERSION'" \ | |
| src/index.ts --outfile=./bin/linear-release | |
| - name: Import code signing certificate | |
| if: matrix.os == 'macos-latest' && startsWith(github.ref, 'refs/tags/') | |
| env: | |
| CSC_LINK: ${{ secrets.CSC_LINK }} | |
| CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }} | |
| run: | | |
| echo "$CSC_LINK" | base64 --decode > certificate.p12 | |
| security create-keychain -p "" build.keychain | |
| security default-keychain -s build.keychain | |
| security unlock-keychain -p "" build.keychain | |
| security import certificate.p12 -k build.keychain -P "$CSC_KEY_PASSWORD" -T /usr/bin/codesign | |
| security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "" build.keychain | |
| rm certificate.p12 | |
| - name: Code sign macOS executable | |
| if: matrix.os == 'macos-latest' && startsWith(github.ref, 'refs/tags/') | |
| run: | | |
| codesign --entitlements entitlements.mac.plist --force --options runtime \ | |
| --sign "Developer ID Application: Linear Orbit, Inc. (${{ secrets.APPLE_TEAM_ID }})" ./bin/linear-release | |
| codesign --verify --verbose ./bin/linear-release | |
| - name: Notarize macOS executable | |
| if: matrix.os == 'macos-latest' && startsWith(github.ref, 'refs/tags/') | |
| env: | |
| APPLE_ID: ${{ secrets.APPLE_ID }} | |
| APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} | |
| APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }} | |
| run: | | |
| ditto -c -k --keepParent ./bin/linear-release ./bin/linear-release.zip | |
| xcrun notarytool submit ./bin/linear-release.zip \ | |
| --apple-id "$APPLE_ID" \ | |
| --team-id "$APPLE_TEAM_ID" \ | |
| --password "$APPLE_APP_SPECIFIC_PASSWORD" \ | |
| --wait | |
| rm ./bin/linear-release.zip | |
| - name: Upload artifact | |
| uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 | |
| with: | |
| name: linear-release-${{ matrix.platform }} | |
| path: bin/linear-release* | |
| retention-days: 7 | |
| release: | |
| name: Create Release | |
| needs: build | |
| runs-on: ubuntu-latest | |
| if: startsWith(github.ref, 'refs/tags/') | |
| permissions: | |
| contents: write | |
| outputs: | |
| tag_name: ${{ steps.tag.outputs.tag_name }} | |
| version_number: ${{ steps.tag.outputs.version_number }} | |
| steps: | |
| - name: Checkout code | |
| uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 | |
| with: | |
| fetch-depth: 0 | |
| persist-credentials: false | |
| - name: Download all artifacts | |
| uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 | |
| with: | |
| path: ./artifacts | |
| - name: List artifacts | |
| run: | | |
| find ./artifacts -type f | |
| ls -la ./artifacts/ | |
| - name: Extract tag name | |
| id: tag | |
| run: | | |
| TAG_NAME=${GITHUB_REF#refs/tags/} | |
| VERSION_NUMBER=${TAG_NAME#v} | |
| echo "tag_name=$TAG_NAME" >> $GITHUB_OUTPUT | |
| echo "version_number=$VERSION_NUMBER" >> $GITHUB_OUTPUT | |
| echo "Tag name: $TAG_NAME" | |
| echo "Version number: $VERSION_NUMBER" | |
| - name: Prepare release files | |
| run: | | |
| mkdir -p ./release-files | |
| # Copy each platform's executable with platform-specific name | |
| for dir in ./artifacts/linear-release-*/; do | |
| platform=$(basename "$dir" | sed 's/linear-release-//') | |
| if [ -f "$dir/linear-release" ]; then | |
| cp "$dir/linear-release" "./release-files/linear-release-${platform}" | |
| elif [ -f "$dir/linear-release.exe" ]; then | |
| cp "$dir/linear-release.exe" "./release-files/linear-release-${platform}.exe" | |
| fi | |
| done | |
| expected_assets=( | |
| linear-release-linux-x64 | |
| linear-release-linux-arm64 | |
| linear-release-darwin-x64 | |
| linear-release-darwin-arm64 | |
| ) | |
| for asset in "${expected_assets[@]}"; do | |
| if [ ! -f "./release-files/$asset" ]; then | |
| echo "::error::Missing release asset: $asset" | |
| exit 1 | |
| fi | |
| done | |
| actual_asset_count=$(find ./release-files -maxdepth 1 -type f -name 'linear-release-*' | wc -l | tr -d ' ') | |
| if [ "$actual_asset_count" -ne "${#expected_assets[@]}" ]; then | |
| echo "::error::Expected ${#expected_assets[@]} release assets, found $actual_asset_count" | |
| exit 1 | |
| fi | |
| ( | |
| cd ./release-files | |
| LC_ALL=C sha256sum linear-release-* > checksums.txt | |
| sha256sum --check --strict checksums.txt | |
| ) | |
| ls -la ./release-files/ | |
| - name: Create draft release | |
| uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2.6.2 | |
| with: | |
| tag_name: ${{ steps.tag.outputs.tag_name }} | |
| name: Release ${{ steps.tag.outputs.tag_name }} | |
| files: | | |
| ./release-files/* | |
| generate_release_notes: true | |
| token: ${{ secrets.GITHUB_TOKEN }} | |
| draft: true | |
| prerelease: false | |
| - name: Publish immutable release | |
| env: | |
| GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| TAG_NAME: ${{ steps.tag.outputs.tag_name }} | |
| run: gh release edit "$TAG_NAME" --draft=false | |
| verify-release: | |
| name: Verify published release | |
| needs: release | |
| runs-on: ubuntu-latest | |
| permissions: | |
| contents: read | |
| steps: | |
| - name: Verify immutable metadata and assets | |
| env: | |
| GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| TAG_NAME: ${{ needs.release.outputs.tag_name }} | |
| run: | | |
| release=$(gh api "repos/${GITHUB_REPOSITORY}/releases/tags/${TAG_NAME}") | |
| if [ "$(jq -r '.immutable' <<<"$release")" != "true" ]; then | |
| echo "::error::Published release is not immutable" | |
| exit 1 | |
| fi | |
| for asset in \ | |
| linear-release-linux-x64 \ | |
| linear-release-linux-arm64 \ | |
| linear-release-darwin-x64 \ | |
| linear-release-darwin-arm64 \ | |
| checksums.txt; do | |
| count=$(jq --arg asset "$asset" '[.assets[] | select(.name == $asset)] | length' <<<"$release") | |
| if [ "$count" -ne 1 ]; then | |
| echo "::error::Expected one published $asset asset, found $count" | |
| exit 1 | |
| fi | |
| done | |
| - name: Verify published checksums | |
| env: | |
| GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| TAG_NAME: ${{ needs.release.outputs.tag_name }} | |
| run: | | |
| mkdir release-assets | |
| gh release download "$TAG_NAME" --dir release-assets | |
| cd release-assets | |
| sha256sum --check --strict checksums.txt | |
| label-release: | |
| name: Label release with version | |
| needs: [release, verify-release] | |
| permissions: | |
| contents: read | |
| uses: ./.github/workflows/run-linear-release.yml | |
| with: | |
| action: sync | |
| name: Release ${{ needs.release.outputs.version_number }} | |
| version: ${{ needs.release.outputs.tag_name }} | |
| secrets: | |
| LINEAR_RELEASE_MANAGEMENT_ACCESS_KEY: ${{ secrets.LINEAR_RELEASE_MANAGEMENT_ACCESS_KEY }} | |
| complete-release: | |
| name: Complete release | |
| needs: [release, label-release] | |
| permissions: | |
| contents: read | |
| uses: ./.github/workflows/run-linear-release.yml | |
| with: | |
| action: complete | |
| name: Release ${{ needs.release.outputs.version_number }} | |
| version: ${{ needs.release.outputs.tag_name }} | |
| secrets: | |
| LINEAR_RELEASE_MANAGEMENT_ACCESS_KEY: ${{ secrets.LINEAR_RELEASE_MANAGEMENT_ACCESS_KEY }} |