From c585b7ceb00fefe5436745be2550231c9125bb2f Mon Sep 17 00:00:00 2001 From: Reto Date: Sun, 12 Jul 2026 06:34:39 +0200 Subject: [PATCH 1/3] Automate releases and publish Homebrew cask to own tap Releases were cut from a laptop and the formula copied into the tap by hand. Tagging now drives everything from CI. Also migrates the deprecated brews config to homebrew_casks: formulas are meant to build from source, casks ship pre-compiled binaries, and casks gained Linux support in Homebrew/brew#19121. --- .github/workflows/release.yml | 32 ++++++++++++++++ .goreleaser.yml | 69 +++++++++++++++++++---------------- README.md | 8 +++- 3 files changed, 76 insertions(+), 33 deletions(-) create mode 100644 .github/workflows/release.yml diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 00000000..5cd47707 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,32 @@ +on: + push: + tags: ['v*'] + +name: Release + +permissions: + contents: write + +jobs: + goreleaser: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + # GoReleaser derives the changelog and build metadata from history. + fetch-depth: 0 + + - name: Setup Go + uses: actions/setup-go@v5 + with: + go-version: '^1.25.6' + + - name: Run GoReleaser + uses: goreleaser/goreleaser-action@v6 + with: + version: '~> v2' + args: release --clean + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + HOMEBREW_TAP_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }} diff --git a/.goreleaser.yml b/.goreleaser.yml index b16552be..c4d1f7d7 100644 --- a/.goreleaser.yml +++ b/.goreleaser.yml @@ -4,7 +4,7 @@ project_name: jira release: prerelease: auto name_template: "v{{.Version}}" - draft: true + draft: false mode: "keep-existing" before: @@ -59,50 +59,55 @@ checksum: name_template: 'checksums.txt' algorithm: sha256 -brews: +homebrew_casks: - name: jira-cli - homepage: "https://github.com/ankitpokhrel/jira-cli" + homepage: "https://github.com/rethab/jira-cli" description: "🔥 Feature-rich interactive Jira command-line" license: "MIT" ids: - nix + # The archives ship the binary under bin/, so the cask must reference it there. + binaries: + - bin/jira + + generate_completions_from_executable: + executable: bin/jira + args: + - completion + shells: + - bash + - zsh + - fish + + url: + verified: "github.com/rethab/jira-cli/" + + hooks: + post: + # The released binaries are neither signed nor notarized, so Gatekeeper + # quarantines them on download and every run would be blocked. + install: | + if OS.mac? + system_command "/usr/bin/xattr", args: ["-dr", "com.apple.quarantine", "#{staged_path}/bin/jira"] + end + repository: - owner: ankitpokhrel + owner: rethab name: homebrew-jira-cli - - custom_block: | - head do - url "https://github.com/ankitpokhrel/jira-cli.git", branch: "main" - depends_on "go" - end - - install: | - if build.head? - system "make", "install" - bin.install ENV["GOPATH"] + "/bin/jira" - else - bin.install File.exist?("bin/jira") ? "bin/jira" : "jira" - end - generate_completions_from_executable(bin/"jira", "completion", shells: [:bash, :zsh, :fish]) - - test: | - help_text = shell_output("#{bin}/jira version") - assert_includes help_text, "Version=\"#{version}\"" + # The default GITHUB_TOKEN is scoped to this repository only, so pushing + # the cask to the tap needs a token that can write to it. + token: "{{ .Env.HOMEBREW_TAP_TOKEN }}" commit_author: - name: ankitpokhrel - email: oss@ankit.pl - - commit_msg_template: "Formula update for version {{ .Tag }}" + name: github-actions[bot] + email: 41898282+github-actions[bot]@users.noreply.github.com - # Folder inside the repository to put the formula to. - directory: Formula + commit_msg_template: "Cask update for version {{ .Tag }}" - # This will prevent goreleaser to actually try to commit the updated - # formula - leaving the responsibility of publishing it to the user. - skip_upload: true + # Folder inside the tap repository to put the cask into. + directory: Casks changelog: disable: true diff --git a/README.md b/README.md index 28a16856..00c98818 100644 --- a/README.md +++ b/README.md @@ -69,7 +69,13 @@ nature of the data. Yet, we've attempted to make the experience as similar as po | **Jira** | Jira CloudJira Server | ## Installation -`jira-cli` is available as a downloadable packaged binary for Linux, macOS, and Windows from the [releases page](https://github.com/ankitpokhrel/jira-cli/releases). +`jira-cli` is available as a downloadable packaged binary for Linux, macOS, and Windows from the [releases page](https://github.com/rethab/jira-cli/releases). + +Install it with Homebrew from the [tap](https://github.com/rethab/homebrew-jira-cli): + +```sh +brew install --cask rethab/jira-cli/jira-cli +``` You can use Docker to quickly try out `jira-cli`. From f3da8345c382d61c8e1c9912426b141f58020519 Mon Sep 17 00:00:00 2001 From: Reto Date: Sun, 12 Jul 2026 06:49:51 +0200 Subject: [PATCH 2/3] Harden release pipeline and fix CI Go toolchain Supply-chain hardening based on GoReleaser's example-secure reference and a zizmor audit (now clean): - Pin all actions to commit SHAs; Dependabot with a 7-day cooldown keeps them fresh without picking up freshly-compromised releases. - Attest SLSA build provenance for all release artifacts; users verify with 'gh attestation verify'. Ship SPDX SBOMs per archive. - Scope workflow permissions per job, drop checkout credential persistence, disable Go build cache in the release job. - Build reproducibly: -trimpath, commit-pinned mod timestamps, exact Go toolchain from go.mod, no release-time go mod tidy. - Pin the golangci-lint install script to its release tag instead of master. CI lint was crashing because go-version '^1.25.6' resolved to Go 1.26.4, which golangci-lint 2.6.2 cannot typecheck; go-version-file follows the toolchain in go.mod instead. Verified locally: 0 issues under 1.25.6, reproduces the crash under 1.26.4. --- .github/dependabot.yml | 10 ++++++++++ .github/workflows/ci.yml | 15 ++++++++++----- .github/workflows/docker.yml | 14 ++++++++------ .github/workflows/release.yml | 35 ++++++++++++++++++++++++++++------- .goreleaser.yml | 12 ++++++++---- Makefile | 2 +- README.md | 8 ++++++++ 7 files changed, 73 insertions(+), 23 deletions(-) create mode 100644 .github/dependabot.yml diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 00000000..62911320 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,10 @@ +version: 2 +updates: + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" + # Don't pick up brand-new action releases immediately; compromised + # versions are usually yanked within days of publication. + cooldown: + default-days: 7 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 32a1517a..9f140da8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,24 +1,29 @@ on: pull_request: types: [opened, synchronize, reopened] - release: - types: [published] push: branches: [main] name: Build +permissions: + contents: read + jobs: tests: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false - name: Setup Go - uses: actions/setup-go@v5 + uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 with: - go-version: '^1.25.6' + # Follow the toolchain declared in go.mod: a floating constraint + # resolves to Go versions golangci-lint cannot typecheck yet. + go-version-file: 'go.mod' - name: Install dependencies run: make deps diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index b1daeefd..8b74074c 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -19,10 +19,12 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false - name: Log in to the Container registry - uses: docker/login-action@v3 + uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0 with: registry: ${{ env.REGISTRY }} username: ${{ github.actor }} @@ -30,20 +32,20 @@ jobs: - name: Extract metadata (tags, labels) for Docker id: meta - uses: docker/metadata-action@v5 + uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0 with: images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} - name: Set up QEMU - uses: docker/setup-qemu-action@v3 + uses: docker/setup-qemu-action@96fe6ef7f33517b61c61be40b68a1882f3264fb8 # v4.2.0 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v2 + uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 with: platforms: linux/amd64,linux/arm64,linux/arm/v6,linux/arm/v7,linux/arm/v8 - name: Build and push Docker image - uses: docker/build-push-action@v5 + uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 with: context: . file: ./Dockerfile diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5cd47707..06808925 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -4,29 +4,50 @@ on: name: Release -permissions: - contents: write +permissions: {} jobs: goreleaser: runs-on: ubuntu-latest + permissions: + contents: write # create the release and upload assets + id-token: write # OIDC token for keyless provenance signing + attestations: write # store the provenance attestation steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: # GoReleaser derives the changelog and build metadata from history. fetch-depth: 0 + # GoReleaser authenticates via env, not the checkout credentials; + # don't leave the token behind in .git/config. + persist-credentials: false - name: Setup Go - uses: actions/setup-go@v5 + uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 with: - go-version: '^1.25.6' + # Build releases with the exact toolchain declared in go.mod; a + # floating constraint makes builds unreproducible. + go-version-file: 'go.mod' + # Release builds must not restore a shared cache anyone with cache + # write access could poison. + cache: false + + - name: Install syft + uses: anchore/sbom-action/download-syft@e22c389904149dbc22b58101806040fa8d37a610 # v0.24.0 - name: Run GoReleaser - uses: goreleaser/goreleaser-action@v6 + uses: goreleaser/goreleaser-action@f06c13b6b1a9625abc9e6e439d9c05a8f2190e94 # v7.2.3 with: - version: '~> v2' + # Pinned exactly: a floating constraint would pull whatever the + # newest GoReleaser release is at tag time. + version: v2.17.0 args: release --clean env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} HOMEBREW_TAP_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }} + + - name: Attest build provenance + uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1 + with: + subject-checksums: dist/checksums.txt diff --git a/.goreleaser.yml b/.goreleaser.yml index c4d1f7d7..ec16ebf6 100644 --- a/.goreleaser.yml +++ b/.goreleaser.yml @@ -7,14 +7,15 @@ release: draft: false mode: "keep-existing" -before: - hooks: - - go mod tidy - builds: - <<: &build_defaults binary: bin/jira main: ./cmd/jira + # Reproducible builds: strip local paths and pin file timestamps to the + # commit, so anyone can rebuild the tag and compare checksums. + flags: + - -trimpath + mod_timestamp: "{{ .CommitTimestamp }}" ldflags: - -s -w - -X github.com/ankitpokhrel/jira-cli/internal/version.Version={{.Version}} @@ -59,6 +60,9 @@ checksum: name_template: 'checksums.txt' algorithm: sha256 +sboms: + - artifacts: archive + homebrew_casks: - name: jira-cli homepage: "https://github.com/rethab/jira-cli" diff --git a/Makefile b/Makefile index 2b99338d..1c305093 100644 --- a/Makefile +++ b/Makefile @@ -43,7 +43,7 @@ install: lint: @if ! command -v golangci-lint > /dev/null 2>&1; then \ - curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | \ + curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/v2.6.2/install.sh | \ sh -s -- -b "$$(go env GOPATH)/bin" v2.6.2 ; \ fi golangci-lint run ./... diff --git a/README.md b/README.md index 00c98818..c9c7c04f 100644 --- a/README.md +++ b/README.md @@ -77,6 +77,14 @@ Install it with Homebrew from the [tap](https://github.com/rethab/homebrew-jira- brew install --cask rethab/jira-cli/jira-cli ``` +Every release carries [SLSA build provenance](https://docs.github.com/en/actions/security-for-github-actions/using-artifact-attestations): a signed attestation that the archive was built by this repository's release workflow from the tagged commit. To verify a downloaded archive: + +```sh +gh attestation verify jira_1.8.0_macOS_arm64.tar.gz --repo rethab/jira-cli +``` + +Each archive also ships an SPDX SBOM (`*.sbom.json` on the release page) listing the full dependency tree, ready for scanners like `grype`. + You can use Docker to quickly try out `jira-cli`. ```sh From 3e0d58cf9e3287919b21c9f51a7891a726fab4e7 Mon Sep 17 00:00:00 2001 From: Reto Date: Sun, 12 Jul 2026 06:57:57 +0200 Subject: [PATCH 3/3] Publish verified snapshot builds on every merge to main MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GoReleaser OSS has no nightly mode, so the snapshot workflow publishes a prerelease itself and renders the jira-cli-snapshot cask from the build's checksums — snapshots get the same pinned SHA-256s, provenance attestation, and SBOMs as releases. Old snapshots are pruned, keeping 5. Also moves the Docker workflow to trigger on the tag push: releases are now created by a workflow with GITHUB_TOKEN, whose events GitHub suppresses, so the release:published trigger would never fire again. --- .github/scripts/render-snapshot-cask.sh | 81 +++++++++++++++++++++ .github/workflows/docker.yml | 7 +- .github/workflows/snapshot.yml | 95 +++++++++++++++++++++++++ .goreleaser.yml | 7 ++ README.md | 6 ++ 5 files changed, 194 insertions(+), 2 deletions(-) create mode 100755 .github/scripts/render-snapshot-cask.sh create mode 100644 .github/workflows/snapshot.yml diff --git a/.github/scripts/render-snapshot-cask.sh b/.github/scripts/render-snapshot-cask.sh new file mode 100755 index 00000000..0a8d86d6 --- /dev/null +++ b/.github/scripts/render-snapshot-cask.sh @@ -0,0 +1,81 @@ +#!/usr/bin/env bash +# Renders the jira-cli-snapshot cask from a GoReleaser snapshot dist/. +# GoReleaser OSS only writes casks on real releases, so the snapshot channel +# renders the same shape itself, with checksums from the snapshot build. +# +# Usage: render-snapshot-cask.sh +# The cask is written to stdout. +set -euo pipefail + +DIST="$1" +TAG="$2" + +VERSION=$(jq -r .version "$DIST/metadata.json") + +sha() { + awk -v f="jira_${VERSION}_$1.tar.gz" '$2 == f { print $1 }' "$DIST/checksums.txt" | grep . +} + +MACOS_INTEL=$(sha macOS_x86_64) +MACOS_ARM=$(sha macOS_arm64) +LINUX_INTEL=$(sha linux_x86_64) +LINUX_ARM=$(sha linux_arm64) + +cat <> "$GITHUB_OUTPUT" + + - name: Attest build provenance + uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1 + with: + subject-checksums: dist/checksums.txt + + - name: Update snapshot cask in tap + env: + GH_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }} + TAG: ${{ steps.publish.outputs.tag }} + # The contents API commits without the token ever touching disk, and + # GitHub signs the commit (shows as verified). + run: | + ./.github/scripts/render-snapshot-cask.sh dist "$TAG" > jira-cli-snapshot.rb + existing=$(gh api repos/rethab/homebrew-jira-cli/contents/Casks/jira-cli-snapshot.rb --jq .sha 2>/dev/null || true) + gh api -X PUT repos/rethab/homebrew-jira-cli/contents/Casks/jira-cli-snapshot.rb \ + -f message="Snapshot cask update for $TAG" \ + -f content="$(base64 -w0 jira-cli-snapshot.rb)" \ + -f "committer[name]=github-actions[bot]" \ + -f "committer[email]=41898282+github-actions[bot]@users.noreply.github.com" \ + ${existing:+-f sha="$existing"} > /dev/null + + - name: Prune old snapshots + env: + GH_TOKEN: ${{ github.token }} + KEEP: '5' + # Tolerates failure: with immutable releases enabled, old snapshots + # cannot be deleted and simply accumulate. + run: | + gh release list --limit 100 --json tagName,createdAt \ + --jq '[.[] | select(.tagName | startswith("snapshot-"))] | sort_by(.createdAt) | reverse | .[].tagName' \ + | tail -n "+$((KEEP + 1))" \ + | while read -r tag; do + gh release delete "$tag" --cleanup-tag --yes \ + || echo "could not prune $tag (immutable releases?)" + done diff --git a/.goreleaser.yml b/.goreleaser.yml index ec16ebf6..463ddfb8 100644 --- a/.goreleaser.yml +++ b/.goreleaser.yml @@ -7,6 +7,10 @@ release: draft: false mode: "keep-existing" +snapshot: + # Monotonic so Homebrew never sees a snapshot as a downgrade. + version_template: "{{ incpatch .Version }}-next.{{ .CommitTimestamp }}.{{ .ShortCommit }}" + builds: - <<: &build_defaults binary: bin/jira @@ -88,6 +92,9 @@ homebrew_casks: url: verified: "github.com/rethab/jira-cli/" + conflicts: + - cask: jira-cli-snapshot + hooks: post: # The released binaries are neither signed nor notarized, so Gatekeeper diff --git a/README.md b/README.md index c9c7c04f..ecd027ba 100644 --- a/README.md +++ b/README.md @@ -85,6 +85,12 @@ gh attestation verify jira_1.8.0_macOS_arm64.tar.gz --repo rethab/jira-cli Each archive also ships an SPDX SBOM (`*.sbom.json` on the release page) listing the full dependency tree, ready for scanners like `grype`. +Every merge to `main` also publishes a snapshot build with the same checksums, provenance, and SBOMs as a release: + +```sh +brew install --cask rethab/jira-cli/jira-cli-snapshot +``` + You can use Docker to quickly try out `jira-cli`. ```sh