From b3d36aa126e200e85a4ff8b9a475eae1bde722ac Mon Sep 17 00:00:00 2001 From: Rian Stockbower Date: Sat, 30 May 2026 09:09:20 -0400 Subject: [PATCH] ci: conform CI/CD to the shared repo/CI-CD standard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Migrate CI onto the shared composite actions and release/distribution onto the reusable workflows (open-cli-collective/.github @v1), declaring identity once in packaging/identity.yml. Published package identities (cask google-readonly, winget/chocolatey/linux google-readonly) are unchanged. - New packaging/identity.yml: binary gro, goreleaser_config .goreleaser.yaml, tag prefix v, canonical_cask google-readonly (no alias), winget/choco/linux google-readonly, and a JSON keychain_probe using gro's `config show -j` control-plane flag (verified backend=keychain/auto locally). - ci.yml on composites: build-platform matrix + required build aggregate, go-test, go-lint, identity-check, pr-title; plus preserved required gates — a tidy job (go mod tidy + git diff --exit-code, from the old `make tidy test build`), the static-release build guard, and the make test-cover-check coverage floor (ci.md §6). - auto-release/release callers replace the hand-rolled pipelines; the reusable owns the darwin keychain-probe gate and the homebrew/winget/chocolatey/linux fan-out. - goreleaser homebrew_casks: skip_upload + tap token removed (the reusable homebrew step is the single tap writer); add release.replace_existing_artifacts; fix the mutating before-hook to fail on a dirty go.mod/go.sum. - Remove the orphaned snap/ (disabled job; distribution.md §7 — Linux via apt/rpm). - Demote chocolatey-publish/winget-publish to workflow_dispatch-only manual escape hatches (drop the release: published auto-trigger; the reusable now owns the common-path publish, avoiding double-publish). Closes #148 --- .github/workflows/auto-release.yml | 63 +++----- .github/workflows/chocolatey-publish.yml | 5 +- .github/workflows/ci.yml | 97 ++++++++++-- .github/workflows/release.yml | 184 ++++------------------- .github/workflows/winget-publish.yml | 5 +- .goreleaser.yaml | 17 ++- packaging/identity.yml | 35 +++++ snap/snapcraft.yaml | 61 -------- 8 files changed, 186 insertions(+), 281 deletions(-) create mode 100644 packaging/identity.yml delete mode 100644 snap/snapcraft.yaml diff --git a/.github/workflows/auto-release.yml b/.github/workflows/auto-release.yml index ae0d4c3..c7866d5 100644 --- a/.github/workflows/auto-release.yml +++ b/.github/workflows/auto-release.yml @@ -1,47 +1,32 @@ name: Auto Release +# On push to main, decide whether to cut a release and mint the tag. Identity +# (tag prefix + version file) comes from packaging/identity.yml. The release gate +# (Go-code paths + feat/fix commit) and the version.txt + GITHUB_RUN_NUMBER tag +# scheme are handled by the shared reusable workflow; this caller wires triggers +# and secrets. on: push: - branches: - - main - # Gate 1: Only trigger when Go code actually changes - paths: - - '**.go' - - 'go.mod' - - 'go.sum' + branches: [main] + workflow_dispatch: + inputs: + dry-run: + description: "Compute the tag but do not push it" + type: boolean + default: true +# The tag push uses the dedicated TAP_GITHUB_TOKEN via a credential helper, not +# GITHUB_TOKEN — so the pushed tag retriggers release.yml. permissions: - contents: write + contents: read jobs: - create-release: - runs-on: ubuntu-latest - # Gate 2: Only release for feat: and fix: commits (actual functionality changes) - if: >- - startsWith(github.event.head_commit.message, 'feat:') || - startsWith(github.event.head_commit.message, 'feat(') || - startsWith(github.event.head_commit.message, 'fix:') || - startsWith(github.event.head_commit.message, 'fix(') - steps: - # TAP_GITHUB_TOKEN (a PAT) is required here instead of the default GITHUB_TOKEN - # because tag pushes made with GITHUB_TOKEN do not trigger other workflows. - # Without this, the Release workflow would not run when we push the tag. - - uses: actions/checkout@v4 - with: - fetch-depth: 0 - token: ${{ secrets.TAP_GITHUB_TOKEN }} - - - name: Read version - id: version - run: | - BASE_VERSION=$(cat version.txt | tr -d '\n') - VERSION="v${BASE_VERSION}.${GITHUB_RUN_NUMBER}" - echo "version=$VERSION" >> $GITHUB_OUTPUT - echo "Creating release: $VERSION" - - - name: Create and push tag - run: | - git config user.name "github-actions[bot]" - git config user.email "github-actions[bot]@users.noreply.github.com" - git tag ${{ steps.version.outputs.version }} - git push origin ${{ steps.version.outputs.version }} + auto-release: + uses: open-cli-collective/.github/.github/workflows/auto-release.yml@v1 + with: + # push is live (false); workflow_dispatch honors the checkbox. Compare + # against both true and 'true' because a workflow_dispatch boolean input + # may surface as a real boolean or the string "true" depending on context. + dry-run: ${{ github.event_name == 'workflow_dispatch' && (inputs.dry-run == true || inputs.dry-run == 'true') }} + secrets: + tag-token: ${{ secrets.TAP_GITHUB_TOKEN }} diff --git a/.github/workflows/chocolatey-publish.yml b/.github/workflows/chocolatey-publish.yml index 9157843..a382639 100644 --- a/.github/workflows/chocolatey-publish.yml +++ b/.github/workflows/chocolatey-publish.yml @@ -1,8 +1,9 @@ name: Publish to Chocolatey on: - release: - types: [published] + # Manual escape hatch only. The common-path Chocolatey publish is now handled by + # the reusable release.yml@v1's own manifest-driven channel job; the old + # `release: published` auto-trigger is removed to avoid double-publishing. workflow_dispatch: inputs: version: diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b904fe7..3cc7b81 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,21 +4,77 @@ on: push: branches: [main] pull_request: - branches: [main] + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read jobs: - build-and-test: - runs-on: ubuntu-latest + build-platform: # OS matrix — NOT a required check + name: build (${{ matrix.os }}) + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@v4 + - uses: open-cli-collective/.github/actions/go-build@v1 + with: + go-version-file: go.mod + build: # required aggregate — stable bare name + needs: [build-platform] + if: ${{ always() }} + runs-on: ubuntu-latest + steps: + # Fail only on a real failure/cancellation; a skipped leg must NOT fail + # the gate (which `== 'success'` would do). + - if: ${{ contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled') }} + run: echo "::error::a build-platform leg failed or was cancelled" && exit 1 + + # Preserve the pre-migration `make tidy test build` tidy gate: `make tidy` runs + # `go mod tidy && git diff --exit-code go.mod go.sum`, failing a PR whose module + # files aren't tidy. The go-build/go-test composites don't tidy, so this stays. + tidy: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 - uses: actions/setup-go@v5 with: - go-version: '1.26' + go-version-file: go.mod + - run: make tidy - - name: Tidy, test, and build - run: make tidy test build + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: open-cli-collective/.github/actions/go-test@v1 + with: + go-version-file: go.mod + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: open-cli-collective/.github/actions/go-lint@v1 + with: + go-version-file: go.mod + + # Regression sentinel (preserved): the shared go-build@v1 matrix legs build with + # default CGO and would NOT catch a static-linux regression. This cross-compiles + # the release targets CGO-off and asserts the static build graph never pulls in + # the 1Password/keyring stack. Kept required to preserve its blocking semantics. + static-release-guard: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod - name: Static release build guard run: | set -euo pipefail @@ -32,24 +88,33 @@ jobs: for goarch in amd64 arm64; do deps=$(CGO_ENABLED=0 GOOS=linux GOARCH="$goarch" go list -deps ./cmd/gro) if printf '%s\n' "$deps" | grep -E '^(github.com/byteness/keyring|github.com/1password/onepassword-sdk-go)(/|$)'; then - echo "static Linux $goarch build graph must not include byteness/keyring or onepassword-sdk-go" + echo "static Linux gro $goarch build graph must not include byteness/keyring or onepassword-sdk-go" exit 1 fi done - - name: Coverage gate - run: make test-cover-check - - lint: + # Preserve gro's opt-in coverage floor (ci.md §6): `make test-cover-check` runs + # the suite with -race + coverage and fails below the 60% threshold. Kept as its + # own required check so the floor stays blocking (not advisory). + coverage: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - uses: actions/setup-go@v5 with: - go-version: '1.26' + go-version-file: go.mod + - run: make test-cover-check + + identity-check: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: open-cli-collective/.github/actions/identity-check@v1 # distribution.md §8.2 - - name: golangci-lint - uses: golangci/golangci-lint-action@v7 + pr-title: + if: github.event_name == 'pull_request' + runs-on: ubuntu-latest + steps: + - uses: open-cli-collective/.github/actions/pr-title@v1 with: - version: v2.12.2 + title: ${{ github.event.pull_request.title }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c003b1c..33a662c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,170 +1,38 @@ name: Release +# Triggered by the release tag that auto-release.yml mints (or a manual tag push). +# Builds with GoReleaser on macOS (CGO darwin can't cross-compile), gates the +# Keychain backend via the manifest keychain_probe, publishes the GitHub release, +# then fans out to the packaging channels packaging/identity.yml declares (homebrew +# cask, winget, chocolatey, linux). The inline goreleaser/darwin-gate jobs, the +# disabled snap job, and the inline linux-dispatch are replaced by the reusable +# workflow's manifest-driven channel jobs (snap is decommissioned — distribution.md +# §7, Linux ships via apt/rpm). on: push: tags: - - 'v*' + - "v*" workflow_dispatch: inputs: - tag: - description: 'Tag to build (e.g., v1.0.0)' - required: true - type: string + dry-run: + description: "Build + render but skip publishing (only meaningful from a tag ref)" + type: boolean + default: true +# The reusable workflow's goreleaser job publishes the release via GITHUB_TOKEN. permissions: contents: write jobs: - goreleaser: - # INT-449: darwin must build with cgo (Keychain backend). cgo+darwin - # cannot cross-compile from Linux, so this job runs on macOS. Pinned - # image (not the moving macos-latest label) for a reproducible release. - runs-on: macos-15 - env: - TAG: ${{ github.ref_name || inputs.tag }} - steps: - - uses: actions/checkout@v4 - with: - ref: ${{ env.TAG }} - fetch-depth: 0 - - - uses: actions/setup-go@v5 - with: - go-version: "1.26" - - - name: Install GoReleaser - uses: goreleaser/goreleaser-action@v6 - with: - version: "~> v2" - install-only: true - - - name: GoReleaser check - run: goreleaser check - - - name: Build (snapshot, no publish) - run: goreleaser release --snapshot --clean - - # INT-449 pre-publish gate: prove the darwin binaries actually carry - # the Keychain backend BEFORE anything is published. A CGO_ENABLED=0 - # darwin build links no Security.framework and fails closed at - # runtime; this gate makes that impossible to ship silently. - - name: Pre-publish gate — darwin Keychain backend present - run: | - set -euo pipefail - art=dist/artifacts.json - arm_bin=$(jq -r '.[]|select(.type=="Binary" and .goos=="darwin" and .goarch=="arm64")|.path' "$art") - amd_bin=$(jq -r '.[]|select(.type=="Binary" and .goos=="darwin" and .goarch=="amd64")|.path' "$art") - [ -n "$arm_bin" ] && [ -n "$amd_bin" ] || { echo "missing a darwin binary in artifacts.json"; exit 1; } - # darwin archives: exactly one per arch, no duplicate names - tot=$(jq '[.[]|select(.type=="Archive" and .goos=="darwin")|.name]|length' "$art") - uniq=$(jq '[.[]|select(.type=="Archive" and .goos=="darwin")|.name]|unique|length' "$art") - [ "$tot" = "$uniq" ] || { echo "duplicate darwin archive names"; exit 1; } - [ "$(jq '[.[]|select(.type=="Archive" and .goos=="darwin" and .goarch=="arm64")]|length' "$art")" = 1 ] || { echo "expected exactly one darwin/arm64 archive"; exit 1; } - [ "$(jq '[.[]|select(.type=="Archive" and .goos=="darwin" and .goarch=="amd64")]|length' "$art")" = 1 ] || { echo "expected exactly one darwin/amd64 archive"; exit 1; } - # Mach-O arch sanity (both slices) - file "$arm_bin" | grep -q 'arm64' || { echo "arm64 binary is not arm64 Mach-O"; exit 1; } - file "$amd_bin" | grep -q 'x86_64' || { echo "amd64 binary is not x86_64 Mach-O"; exit 1; } - lipo -archs "$arm_bin" | grep -qw arm64 || { echo "lipo: arm64 slice missing"; exit 1; } - lipo -archs "$amd_bin" | grep -qw x86_64 || { echo "lipo: x86_64 slice missing"; exit 1; } - # amd64 cannot run on the arm64 runner: assert Security.framework - # is linked. CGO_ENABLED=0 omits it entirely, so its presence is a - # sound *necessary* cgo signal for the slice we can't execute. - otool -L "$amd_bin" | grep -q '/System/Library/Frameworks/Security.framework' \ - || { echo "amd64 binary not linked against Security.framework (cgo missing)"; exit 1; } - # arm64 authoritative functional check: with no backend override - # and a seeded config, credstore must auto-select the Keychain. - # config.yml lives under $XDG_CONFIG_HOME/google-readonly/config.yml. - # -u GOOGLE_READONLY_KEYRING_BACKEND: derived from DirName - # "google-readonly" upper-snake-cased. - tmp=$(mktemp -d) - mkdir -p "$tmp/xdg/google-readonly" - printf 'credential_ref: google-readonly/default\n' > "$tmp/xdg/google-readonly/config.yml" - out=$(env -u GOOGLE_READONLY_KEYRING_BACKEND HOME="$tmp" XDG_CONFIG_HOME="$tmp/xdg" "$arm_bin" config show -j) - echo "$out" - b=$(echo "$out" | jq -r '.backend'); s=$(echo "$out" | jq -r '.backend_source'); r=$(echo "$out" | jq -r '.credential_ref') - [ "$b" = "keychain" ] && [ "$s" = "auto" ] && [ "$r" = "google-readonly/default" ] \ - || { echo "GATE FAIL: backend=$b source=$s ref=$r (want keychain/auto/google-readonly/default)"; exit 1; } - echo "GATE OK: darwin/arm64 backend=keychain source=auto; darwin/amd64 Security.framework linked" - - - name: Release notes - run: | - set -euo pipefail - cat > "$RUNNER_TEMP/release-notes.md" <<'EOF' - ### macOS Keychain storage restored - - Builds since the credential-store migration were compiled without - cgo and failed closed on macOS (no Keychain backend). This release - builds the darwin binaries with cgo enabled, restoring native - macOS Keychain storage. Upgrade and re-run your normal commands; - no other action is required. - EOF - - - name: Release (publish) - run: goreleaser release --clean --release-notes="$RUNNER_TEMP/release-notes.md" - env: - # TAP_GITHUB_TOKEN (PAT) is required here instead of the default - # GITHUB_TOKEN because releases created with GITHUB_TOKEN do not fire - # downstream workflow triggers (release: published). Using the PAT - # lets chocolatey-publish.yml and winget-publish.yml auto-trigger. - GITHUB_TOKEN: ${{ secrets.TAP_GITHUB_TOKEN }} - TAP_GITHUB_TOKEN: ${{ secrets.TAP_GITHUB_TOKEN }} - - - name: Verify release notes published - env: - GH_TOKEN: ${{ secrets.TAP_GITHUB_TOKEN }} - run: | - set -euo pipefail - body=$(gh release view "$TAG" --repo "$GITHUB_REPOSITORY" --json body -q .body) - if ! printf '%s' "$body" | grep -q 'macOS Keychain'; then - gh release edit "$TAG" --repo "$GITHUB_REPOSITORY" --notes-file "$RUNNER_TEMP/release-notes.md" - fi - - # Snap is temporarily disabled — waiting for personal-files interface approval. - snap: - if: false - needs: goreleaser - runs-on: ubuntu-latest - env: - TAG: ${{ github.ref_name || inputs.tag }} - steps: - - uses: actions/checkout@v4 - with: - ref: ${{ env.TAG }} - fetch-depth: 0 - - - name: Set snap version from tag - run: | - VERSION="${TAG#v}" - sed -i "s/^version: git$/version: '${VERSION}'/" snap/snapcraft.yaml - echo "Snap version: ${VERSION}" - grep '^version:' snap/snapcraft.yaml - - - name: Build snap - uses: snapcore/action-build@v1 - id: build - - - name: Publish to Snapcraft Store - uses: snapcore/action-publish@v1 - env: - SNAPCRAFT_STORE_CREDENTIALS: ${{ secrets.SNAPCRAFT_STORE_CREDENTIALS }} - with: - snap: ${{ steps.build.outputs.snap }} - release: stable - - linux-packages: - needs: goreleaser - runs-on: ubuntu-latest - continue-on-error: true - steps: - - name: Trigger linux-packages repo update - uses: peter-evans/repository-dispatch@v3 - with: - token: ${{ secrets.LINUX_PACKAGES_DISPATCH_TOKEN }} - repository: open-cli-collective/linux-packages - event-type: package-release - client-payload: |- - { - "package": "google-readonly", - "version": "${{ github.ref_name }}", - "repo": "open-cli-collective/google-readonly" - } + release: + uses: open-cli-collective/.github/.github/workflows/release.yml@v1 + with: + # tag push is live (false); workflow_dispatch honors the checkbox. Compare + # against both true and 'true' because a workflow_dispatch boolean input + # may surface as a real boolean or the string "true" depending on context. + dry-run: ${{ github.event_name == 'workflow_dispatch' && (inputs.dry-run == true || inputs.dry-run == 'true') }} + secrets: + homebrew-tap-token: ${{ secrets.TAP_GITHUB_TOKEN }} + chocolatey-api-key: ${{ secrets.CHOCOLATEY_API_KEY }} + winget-token: ${{ secrets.WINGET_GITHUB_TOKEN }} + linux-dispatch-token: ${{ secrets.LINUX_PACKAGES_DISPATCH_TOKEN }} diff --git a/.github/workflows/winget-publish.yml b/.github/workflows/winget-publish.yml index 26c3194..fac61c2 100644 --- a/.github/workflows/winget-publish.yml +++ b/.github/workflows/winget-publish.yml @@ -1,8 +1,9 @@ name: Publish to Winget on: - release: - types: [published] + # Manual escape hatch only. The common-path winget submission is now handled by + # the reusable release.yml@v1's own manifest-driven channel job; the old + # `release: published` auto-trigger is removed to avoid double-submitting. workflow_dispatch: inputs: version: diff --git a/.goreleaser.yaml b/.goreleaser.yaml index 5c05f46..ec74aa6 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -4,7 +4,8 @@ project_name: google-readonly before: hooks: - - go mod tidy + # a mutating tidy must fail the release, not build from a dirty tree + - sh -c "go mod tidy && git diff --exit-code go.mod go.sum" - go test ./... # INT-449: darwin builds with CGO so 99designs/keyring's Keychain backend @@ -93,13 +94,18 @@ nfpms: - src: LICENSE dst: /usr/share/licenses/google-readonly/LICENSE -# Homebrew cask with auto-quarantine removal for unsigned binaries +# Canonical Homebrew cask with auto-quarantine removal for unsigned binaries. +# skip_upload: true → goreleaser RENDERS the cask into dist/ but does NOT push +# it; the reusable release workflow's homebrew step is the single atomic tap +# writer. No `token:` here — the tap token belongs only to that writer, and +# skip_upload needs none. No url.template: this is a flat-v repo (no tag rename), +# so goreleaser's default download URL already pins the published tag. homebrew_casks: - name: google-readonly + skip_upload: true repository: owner: open-cli-collective name: homebrew-tap - token: "{{ .Env.TAP_GITHUB_TOKEN }}" homepage: https://github.com/open-cli-collective/google-readonly description: "Read-only command-line interface for Google services" binaries: [gro] @@ -133,3 +139,8 @@ changelog: order: 1 - title: Other order: 999 + +release: + # idempotent re-runs: a retried release overwrites the same assets instead of + # erroring on "asset already exists" (release-preflight hard-requires this). + replace_existing_artifacts: true diff --git a/packaging/identity.yml b/packaging/identity.yml new file mode 100644 index 0000000..3bb0335 --- /dev/null +++ b/packaging/identity.yml @@ -0,0 +1,35 @@ +schema: open-cli-identity/v1 +repo: google-readonly +binary: gro +goreleaser_config: .goreleaser.yaml +tag: + prefix: v + version_scheme: major_minor_run_patch +archives: + name_template: "gro_v{{ .Version }}_{{ .Os }}_{{ .Arch }}" +packages: + homebrew: + canonical_cask: google-readonly + winget: { id: OpenCLICollective.google-readonly } + chocolatey: { id: google-readonly } + linux: { package_name: google-readonly } + +# Faithful port of the darwin Keychain gate: with the backend override unset and +# a seeded config, the arm64 binary must auto-select the macOS Keychain backend. +# The reusable release.yml's darwin-gate runs this. gro's JSON control-plane flag +# is the short `-j` (config show -j) — NOT the global `--output json`, which the +# output-rendering rollout restricts to a closed set on the other CLIs. +keychain_probe: + env_unset: [GOOGLE_READONLY_KEYRING_BACKEND] + seed_config: + path: google-readonly/config.yml + content: | + credential_ref: google-readonly/default + command: ["config", "show", "-j"] + output: json + assertions: + .backend: keychain + .backend_source: auto + .credential_ref: google-readonly/default + +version_file: version.txt diff --git a/snap/snapcraft.yaml b/snap/snapcraft.yaml deleted file mode 100644 index f153759..0000000 --- a/snap/snapcraft.yaml +++ /dev/null @@ -1,61 +0,0 @@ -name: ocli-google-readonly -base: core22 -version: git -summary: Read-only command-line interface for Google services -description: | - gro is a read-only CLI for accessing Google services. - - Features: - - Gmail: Search, read, view threads, download attachments - - Google Calendar: List calendars, view events, today/week shortcuts - - Google Contacts: List, search, view details - - Google Drive: List, search, download files - - Run 'gro init' to configure your Google API credentials. - -grade: stable -confinement: strict - -architectures: - - build-on: amd64 - - build-on: arm64 - -plugs: - dot-config-google-readonly: - interface: personal-files - read: - - $HOME/.config/google-readonly - write: - - $HOME/.config/google-readonly - -apps: - ocli-google-readonly: - command: bin/gro - plugs: - - home - - network - - dot-config-google-readonly - aliases: - - gro - -parts: - gro: - plugin: go - source: . - build-snaps: - - go/1.24/stable - build-environment: - - CGO_ENABLED: "0" - override-build: | - # Get version from git - VERSION=$(git describe --tags --always --dirty 2>/dev/null || echo "dev") - COMMIT=$(git rev-parse --short HEAD 2>/dev/null || echo "unknown") - DATE=$(date -u +"%Y-%m-%dT%H:%M:%SZ") - - # Build with ldflags - go build -o $SNAPCRAFT_PART_INSTALL/bin/gro \ - -ldflags "-s -w \ - -X github.com/open-cli-collective/google-readonly/internal/version.Version=${VERSION} \ - -X github.com/open-cli-collective/google-readonly/internal/version.Commit=${COMMIT} \ - -X github.com/open-cli-collective/google-readonly/internal/version.Date=${DATE}" \ - ./cmd/gro