Files
Catalina GavriloviciandCatalina Gavrilovici 430515a190 fix(pipeline): add failing tests retries (#897)
# 📥 Pull Request
Close #939 
## PR Description

### Rerun Example
### Example: a real rerun from this PR's pipeline run

```text
=== FAIL: internal/services/sqldatabase TestAcc_SQLDatabaseDataSource_Definition (2404.02s)
    data_sql_database_test.go:242: Step 1/2 error: Error running pre-apply plan: exit status 1
        Error: unknown error
        context deadline exceeded

  PASS internal/services/sqldatabase.TestAcc_SQLDatabaseDataSource_Definition (re-run 1) (186.07s)
  PASS Package internal/services/sqldatabase (3m6.094s)
       (coverage: 13.3% of statements in github.com/microsoft/terraform-provider-fabric/internal/auth, internal/common, internal/framework/..., internal/pkg/..., ...)

DONE 2 runs, 804 tests, 19 skipped, 1 failure in 4928.005s
```

`TestAcc_SQLDatabaseDataSource_Definition` failed once with a transient
`context deadline exceeded`, was retried automatically, passed on
`re-run 1`, and the pipeline completed green (`2 runs, 1 failure`
recovered). Coverage for the package still reports the full instrumented
package list (`13.3%` across all internal packages), confirming the
rerun didn’t overwrite the suite’s coverage data.

### Problem with adding re-run flags
Adding `gotestsum --rerun-fails` broke coverage reporting. On a rerun,
`gotestsum` re-invokes `go test` with the same
`-coverprofile=coverage.out` flag — but the rerun only covers the failed
subset, so it overwrites the full suite's profile with a partial one.
Overall coverage dropped below 40 %, failing coverage checks even when
all tests passed.

### Fix
Switched coverage collection from `-coverprofile` to Go's built-in
**binary coverage data** (`covdata`), which accumulates data across runs
instead of overwriting it.

**Old flow (`-coverprofile`)**
- `go test -coverprofile=coverage.out` writes a single text profile at
the end of each run.
- Each `go test` invocation **overwrites** coverage.out.
- That's why retries broke things: the rerun's partial profile replaced
the full suite's profile.

**New flow (`-cover` + `GOCOVERDIR`)**
- `go test -cover -args -test.gocoverdir=$GOCOVERDIR` writes binary
coverage data files into a shared directory.
- Each invocation **adds new files** (uniquely named with PID +
timestamp) — nothing gets overwritten.
- After all runs finish, a single merge step produces the final text
profile:
  ```
  go tool covdata textfmt -i=.coverdata -o=coverage.out
  ```

So instead of *each test run producing the final profile*, each run
**deposits raw data**, and one merge step at the end produces the final
profile. That's what makes retries safe.

### What stays the same
- coverage.out is the same `mode: atomic` text format as before.
- `gocover-cobertura` (coverage.xml) and `go tool cover -html`
(coverage.html) consume it unchanged.

### Changes
- gotest-cover.sh wraps `go test -cover` and forwards `$GOCOVERDIR`
(resolved to absolute path) to the test binary.
- New `test:precover` task wipes/recreates .coverdata before each run.
- `test:mergecover` is now a single `go tool covdata textfmt` call.
- Removed the `gocovmerge` dependency (no longer installed;
`install:gocovmerge` task deleted).
- Added .coverdata to .gitignore.

### Env vars set on the test tasks
```yaml
COVERPKG: "{{.GO_PKGS}}"
GOCOVERDIR: ".coverdata"
```

- **`GOCOVERDIR`** — standard Go env var pointing to the shared
directory where instrumented test binaries write their coverage data.
- **`COVERPKG`** — bridges the Task-side `{{.GO_PKGS}}` template
variable into the wrapper script's process environment. The wrapper
reads it and passes it to `go test` as `-coverpkg=<list>`. We can't
reference `{{.GO_PKGS}}` directly inside the bash script (Task templates
are only rendered in Taskfile.yml), so it's exposed as an env var.
`GO_PKGS` is built dynamically as a comma-separated list of all internal
packages (excluding `/testhelp`, `/fakes`, root), so tests in one
package also record coverage for the other internal packages they touch.

### Files inside `GOCOVERDIR`
Each test binary writes two file types:
- **`covmeta.<hash>`** — static metadata (packages, files, blocks). One
per unique build.
- **`covcounters.<hash>.<pid>.<nanotime>`** — runtime hit counts for one
execution. One per process invocation; `<hash>` links it to its
`covmeta`.

`go tool covdata textfmt` reads all matching pairs, merges hit counts
properly across runs (including reruns), and emits the standard
coverage.out profile.

---------

Co-authored-by: Catalina Gavrilovici (QUALITEST) <v-gcatalina@microsoft.com>
2026-04-28 13:46:35 +03:00

611 lines
18 KiB
YAML

# yaml-language-server: $schema=https://taskfile.dev/schema.json
# docs: https://taskfile.dev
#
# Windows:
# winget install Task.Task
#
# Linux:
# sh -c "$(curl --location https://taskfile.dev/install.sh)" -- -d -b ~/.local/bin
# echo 'command -v task >/dev/null || export PATH="$PATH:$HOME/.local/bin"' >> ~/.profile
# source ~/.profile
#
# macOS:
# brew install go-task/tap/go-task
---
version: "3"
vars:
PROJECT_NAME: terraform-provider-fabric
PWSH: pwsh -NonInteractive -NoProfile -NoLogo -Command
PWSH_SCRIPT: pwsh -NonInteractive -NoProfile -NoLogo -File
BUILD_DEV_OUTPUT_DIR: "bin/{{OS}}-{{ARCH}}"
BUILD_DEV_ARTIFACT: "{{.BUILD_DEV_OUTPUT_DIR}}/{{.PROJECT_NAME}}{{exeExt}}"
env:
FABRIC_PREVIEW: true
TFE_PARALLELISM: 3
TF_CLI_ARGS_plan: "-parallelism=3"
TF_CLI_ARGS_apply: "-parallelism=3"
TF_CLI_ARGS_destroy: "-parallelism=3"
tasks:
# ----------------------
# Development
# ----------------------
dev-overrides:
desc: Set up the environment to point to the right directories
vars:
BUILD_DEV_ARTIFACT_PATH: "{{.ROOT_DIR}}/{{.BUILD_DEV_ARTIFACT}}"
cmds:
- cmd: cp .github/dev/dev.tfrc.template dev.tfrc
platforms: [linux, darwin]
- cmd: sed -i '' 's|<output_dir>|{{.BUILD_DEV_ARTIFACT_PATH}}|g' dev.tfrc
platforms: [darwin]
- cmd: sed -i 's|<output_dir>|{{.BUILD_DEV_ARTIFACT_PATH}}|g' dev.tfrc
platforms: [linux]
- cmd: if [ -z $TF_CLI_CONFIG_FILE ]; then echo -e "### Run the following command:\nexport TF_CLI_CONFIG_FILE={{.ROOT_DIR}}/dev.tfrc\n###"; fi
platforms: [linux, darwin]
deps:
desc: Check if dependencies are up to date
cmds:
- go mod tidy
deps:verify:
desc: Verify dependencies
cmds:
- go mod verify
deps:download:
desc: Download dependencies
cmds:
- go mod download
deps:up:
desc: Update dependencies
deps: [deps]
cmds:
- go get -u ./...
- defer:
task: deps
lint:
desc: Run linters
cmds:
- task: lint:files
- task: lint:go
- task: lint:tf
- task: lint:md
lint:files:
desc: Run linters for various file types
cmds:
- copywrite headers
- copywrite license
lint:go:
desc: Run Go linters
cmds:
- task: govulncheck
- task: golangci-lint
- task: tfproviderlintx
golangci-lint:
desc: Run golangci-lint
cmds:
- golangci-lint run --fix
tfproviderlintx:
desc: Run tfproviderlintx
cmds:
- tfproviderlintx -fix ./...
lint:tf:
desc: Run Terraform linters
cmds:
- terraform fmt -recursive
- tflint --recursive
- tfsec .
- checkov --directory .
lint:md:
desc: Run Markdown linters
cmds:
- markdownlint-cli2 "./**/*.md" --config "./.github/linters/.markdownlint-cli2.yaml" --fix
lint:links:
desc: Run link checkers
cmds:
- lychee --config ./.github/linters/.lychee.toml --format markdown .
govulncheck:
desc: Run govulncheck
cmds:
- govulncheck -test -show verbose ./...
tools:
desc: Install required tools
cmds:
- for:
[
copywrite,
changie,
tfplugindocs,
dlv,
goimports,
golangci-lint,
gofumpt,
goreleaser,
govulncheck,
tfproviderlintx,
mkdocs,
yamllint,
markdownlint,
gh,
deadcode,
]
task: install:{{.ITEM}}
- task: test:tools
- task: lint:tf-tools
lint:tf-tools:
desc: Install Terraform lint tools
cmds:
- for: [tflint, tfsec, checkov]
task: install:{{.ITEM}}
tfp:
desc: Run Terraform plan
dir: "{{.USER_WORKING_DIR}}"
env:
TF_LOG: error
cmds:
- terraform plan
tfa:
desc: Run Terraform apply
dir: "{{.USER_WORKING_DIR}}"
env:
TF_LOG: error
cmds:
- terraform apply -auto-approve
otp:
desc: Run OpenTofu plan
dir: "{{.USER_WORKING_DIR}}"
env:
TF_LOG: error
TERRAFORM_CLI: tofu
TERRAFORM_PROVIDER_HOST: registry.opentofu.org
TF_ACC_TERRAFORM_PATH: tofu
TF_ACC_PROVIDER_HOST: registry.opentofu.org
cmds:
- tofu plan
ota:
desc: Run OpenTofu apply
dir: "{{.USER_WORKING_DIR}}"
env:
TF_LOG: error
TERRAFORM_CLI: tofu
TERRAFORM_PROVIDER_HOST: registry.opentofu.org
TF_ACC_TERRAFORM_PATH: tofu
TF_ACC_PROVIDER_HOST: registry.opentofu.org
cmds:
- tofu apply -auto-approve
# ----------------------
# Build
# ----------------------
build:
desc: Build development Provider binary with GoReleaser
deps: [build:clean]
cmds:
- goreleaser build --snapshot --single-target --clean --output {{.BUILD_DEV_ARTIFACT}}
sources:
- "**/*.go"
- go.mod
- go.sum
generates:
- "{{.BUILD_DEV_ARTIFACT}}"
build:all:
desc: Build snapshot Provider binaries with GoReleaser
cmds:
- goreleaser build --snapshot --clean
sources:
- "**/*.go"
- go.mod
- go.sum
build:clean:
desc: Cleanup build workspace
cmds:
- cmd: |
{{.PWSH}} 'Remove-Item -Path {{.BUILD_DEV_OUTPUT_DIR}} -Recurse -Force -ErrorAction SilentlyContinue; exit 0'
platforms: [windows]
- cmd: rm -rf {{.BUILD_DEV_OUTPUT_DIR}}
platforms: [linux, darwin]
- defer:
task: build:output
silent: true
internal: true
build:output:
desc: Create build output directories
cmds:
- cmd: |
{{.PWSH}} 'New-Item -ItemType Directory -Force -Path {{.BUILD_DEV_OUTPUT_DIR}} -ErrorAction SilentlyContinue > $null'
platforms: [windows]
- cmd: mkdir -p {{.BUILD_DEV_OUTPUT_DIR}}
platforms: [linux, darwin]
silent: true
internal: true
docs:
desc: Auto generate documentation
cmds:
- tfplugindocs generate --provider-name {{.PROJECT_NAME}}
- task: lint:md
site:build:
desc: Build docs site
deps: [docs]
cmds:
- cmd: |
{{.PWSH}} 'Remove-Item -Path .\site -Recurse -Force -ErrorAction SilentlyContinue; exit 0'
platforms: [windows]
- cmd: rm -rf ./site
platforms: [linux, darwin]
- mkdocs build --verbose --clean
env:
TZ: UTC
site:preview:
desc: Preview a docs site locally
cmds:
- mkdocs serve --verbose --clean --strict
env:
TZ: UTC
# ----------------------
# Test
# ----------------------
testunit:
desc: Run unit tests
cmds:
- task: test:precover
- 'gotestsum --format-hivis --format {{.FORMAT}} --jsonfile "testresults.json" {{.RERUN_FLAGS}} --packages {{.TEST_PATH}} --raw-command -- bash ./tools/scripts/gotest-cover.sh -run "^TestUnit_{{.TEST_NAME}}" -timeout 10m -ldflags="{{.LDFLAGS}}" {{.TEST_PATH}}'
- task: test:mergecover
- task: test:getcover
vars:
TEST_NAME: "{{if gt (len (splitArgs .CLI_ARGS)) 0}}{{index (splitArgs .CLI_ARGS) 0}}{{end}}"
TEST_PATH: "{{if gt (len (splitArgs .CLI_ARGS)) 1}}{{index (splitArgs .CLI_ARGS) 1}}{{else}}./...{{end}}"
FORMAT: '{{if eq .GITHUB_ACTIONS "true"}}github-actions{{else}}pkgname-and-test-fails{{end}}'
RERUN_FLAGS: '{{if eq .GITHUB_ACTIONS "true"}}--rerun-fails=5 --rerun-fails-run-root-test{{end}}'
LDFLAGS: "-s -w -X main.version=testUnit"
env:
TF_LOG: error
TF_ACC: 0
GOCOVERDIR: ".coverdata"
# CGO_ENABLED: 1
testacc:
desc: Run acceptance tests
cmds:
- go clean -testcache
- task: test:precover
- 'gotestsum --format-hivis --format {{.FORMAT}} --jsonfile "testresults.json" {{.RERUN_FLAGS}} --packages {{.TEST_PATH}} --raw-command -- bash ./tools/scripts/gotest-cover.sh -run "^TestAcc_{{.TEST_NAME}}" -timeout 100m -ldflags="{{.LDFLAGS}}" {{.TEST_PATH}}'
- task: test:mergecover
- task: test:getcover
vars:
TEST_NAME: "{{if gt (len (splitArgs .CLI_ARGS)) 0}}{{index (splitArgs .CLI_ARGS) 0}}{{end}}"
TEST_PATH: "{{if gt (len (splitArgs .CLI_ARGS)) 1}}{{index (splitArgs .CLI_ARGS) 1}}{{else}}./...{{end}}"
FORMAT: '{{if eq .GITHUB_ACTIONS "true"}}github-actions{{else}}pkgname-and-test-fails{{end}}'
RERUN_FLAGS: '{{if eq .GITHUB_ACTIONS "true"}}--rerun-fails=5 --rerun-fails-run-root-test{{end}}'
LDFLAGS: "-s -w -X main.version=testAcc"
env:
TF_LOG: error
TF_ACC: 1
GOCOVERDIR: ".coverdata"
# CGO_ENABLED: 1
test:
desc: Run tests
cmds:
- go clean -testcache
- go test -failfast -run ^TestDevEnv_WellKnown$ ./internal/testhelp
- task: test:precover
- 'gotestsum --format-hivis --format {{.FORMAT}} --jsonfile "testresults.json" {{.RERUN_FLAGS}} --packages {{.TEST_PATH}} --raw-command -- bash ./tools/scripts/gotest-cover.sh -run "^Test(Acc|Unit)_{{.TEST_NAME}}" -timeout 100m -ldflags="{{.LDFLAGS}}" {{.TEST_PATH}}'
- task: test:mergecover
- task: test:getcover
vars:
TEST_NAME: "{{if gt (len (splitArgs .CLI_ARGS)) 0}}{{index (splitArgs .CLI_ARGS) 0}}{{end}}"
TEST_PATH: "{{if gt (len (splitArgs .CLI_ARGS)) 1}}{{index (splitArgs .CLI_ARGS) 1}}{{else}}./...{{end}}"
FORMAT: '{{if eq .GITHUB_ACTIONS "true"}}github-actions{{else}}pkgname-and-test-fails{{end}}'
RERUN_FLAGS: '{{if eq .GITHUB_ACTIONS "true"}}--rerun-fails=5 --rerun-fails-run-root-test{{end}}'
LDFLAGS: "-s -w -X main.version=testAcc"
GO_PKGS_EXCLUDE: "/testhelp|/fakes|/terraform-provider-fabric"
GO_PKGS:
sh: |
{{if eq OS "windows"}}
{{.PWSH}} "(go list ./... | Select-String -NotMatch '({{ .GO_PKGS_EXCLUDE }})$') -join ','"
{{else}}
go list ./... | grep -Ev '({{ .GO_PKGS_EXCLUDE }})$' | tr '\n' ','
{{end}}
env:
TF_LOG: error
TF_ACC: 1
COVERPKG: "{{.GO_PKGS}}"
GOCOVERDIR: ".coverdata"
# CGO_ENABLED: 1
testacc:setup:
desc: Setup acceptence/development test environment
dotenv: ["wellknown.env"]
preconditions:
- sh: |
{{if ne OS "windows"}}
command -v pwsh &>/dev/null || exit 1
{{end}}
msg: "First install PowerShell: https://learn.microsoft.com/powershell/scripting/install/installing-powershell"
cmds:
- cmd: |
{{.PWSH_SCRIPT}} ./tools/scripts/Set-WellKnown.ps1
test:tools:
desc: Install test tools
cmds:
- for: [gotestsum, gocover-cobertura]
task: install:{{.ITEM}}
test:precover:
desc: Prepare a clean GOCOVERDIR for binary coverage data
internal: true
cmds:
- cmd: rm -rf .coverdata && mkdir -p .coverdata
platforms: [linux, darwin]
- cmd: |
{{ .PWSH }} 'if (Test-Path .coverdata) { Remove-Item -Recurse -Force .coverdata }; New-Item -ItemType Directory -Path .coverdata | Out-Null'
platforms: [windows]
test:mergecover:
desc: Convert covdata binary files in GOCOVERDIR into coverage.out (text format)
internal: true
cmds:
- go tool covdata textfmt -i=.coverdata -o=coverage.out
test:getcover:
desc: Get coverage results
internal: true
cmds:
- cmd: |
{{ .PWSH }} 'Get-Content coverage.out | gocover-cobertura > coverage.xml'
platforms: [windows]
- cmd: gocover-cobertura < coverage.out > coverage.xml
platforms: [linux, darwin]
- go tool cover -html coverage.out -o coverage.html
# ----------------------
# Release
# ----------------------
release:prep:
desc: Prepare the project for a new release
preconditions:
- sh: |
{{if eq OS "windows"}}
{{.PWSH}} 'if ((git rev-parse --abbrev-ref HEAD) -ne "main") { exit 1 }'
{{else}}
test $(git rev-parse --abbrev-ref HEAD) = "main"
{{end}}
msg: You must be on the main branch to release
# - sh: |
# {{if eq OS "windows"}}
# {{.PWSH}} 'if (-not (git --no-pager diff --shortstat main)) { exit 0 } else { exit 1 }'
# {{else}}
# "[[ -z $(git --no-pager diff --shortstat main) ]]"
# {{end}}
# msg: You must have a clean working tree to release
- sh: |
{{if ne .CLI_ARGS ""}}
{{if (regexMatch "^v[0-9]+\\.[0-9]+\\.[0-9]+(-[a-z]+\\.[0-9]+)?$" .CLI_ARGS) }}
exit 0
{{else}}
exit 1
{{end}}
{{end}}
msg: "Version must be in the valid semver format vX.Y.Z"
- sh: |
gh auth status
msg: "You must be authenticated to GitHub CLI"
vars:
VERSION:
sh: |
{{if eq .CLI_ARGS ""}}
changie next auto
{{else}}
echo {{.CLI_ARGS}}
{{end}}
env:
CHANGIE_GITHUB_REPOSITORY: "microsoft/terraform-provider-fabric"
cmds:
- echo "{{.VERSION}}"
- cmd: |
git checkout main && git fetch && git pull
git checkout -b "release/{{.VERSION}}"
- changie batch {{.VERSION}}
- changie merge
- task: docs
- cmd: |
git add --all
git commit -a -m "chore(release): {{.VERSION}}"
git push --set-upstream origin "release/{{.VERSION}}"
- cmd: |
gh pr create --base main --title "chore(release): {{.VERSION}}" --body-file ".changes/{{.VERSION}}.md" --label "skip-changelog"
gh pr merge --auto --delete-branch --squash
- git checkout main
# ----------------------
# Install Helpers
# ----------------------
install:copywrite:
desc: Install copywrite
cmds:
- go install github.com/hashicorp/copywrite@latest
install:changie:
desc: Install changie
cmds:
- go install github.com/miniscruff/changie@latest
install:gotestsum:
desc: Install gotestsum
cmds:
- go install gotest.tools/gotestsum@latest
install:dlv:
desc: Install dlv
cmds:
- go install github.com/go-delve/delve/cmd/dlv@latest
install:gocover-cobertura:
desc: Install gocover-cobertura
cmds:
- go install github.com/boumenot/gocover-cobertura@latest
install:go-test-coverage:
desc: Install go-test-coverage
cmds:
- go install github.com/vladopajic/go-test-coverage/v2@latest
install:tfplugindocs:
desc: Install tfplugindocs
cmds:
- go install github.com/hashicorp/terraform-plugin-docs/cmd/tfplugindocs@latest
install:deadcode:
desc: Install deadcode
cmds:
- go install golang.org/x/tools/cmd/deadcode@latest
install:goimports:
desc: Install goimports
cmds:
- go install golang.org/x/tools/cmd/goimports@latest
install:golangci-lint:
desc: Install golangci-lint
cmds:
- go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@latest
# - cmd: winget install GolangCI.golangci-lint
# ignore_error: true
# platforms: [windows]
# - cmd: curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s -- -b "$(go env GOPATH)/bin" latest
# platforms: [linux]
# - cmd: brew install golangci-lint
# platforms: [darwin]
install:gofumpt:
desc: Install gofumpt
cmds:
- go install mvdan.cc/gofumpt@latest
install:goreleaser:
desc: Install goreleaser
cmds:
- go install github.com/goreleaser/goreleaser/v2@latest
install:tfproviderlintx:
desc: Install tfproviderlintx
cmds:
- go install github.com/bflad/tfproviderlint/cmd/tfproviderlintx@latest
install:markdownlint:
desc: Install markdownlint
cmds:
- npm install -g markdownlint-cli2
install:tflint:
desc: Install tflint
cmds:
- go install github.com/terraform-linters/tflint@latest
install:tfsec:
desc: Install tfsec
cmds:
- go install github.com/aquasecurity/tfsec/cmd/tfsec@latest
install:govulncheck:
desc: Install govulncheck
cmds:
- go install golang.org/x/vuln/cmd/govulncheck@latest
install:checkov:
desc: Install checkov
preconditions:
- sh: |
{{if eq OS "windows"}}
{{.PWSH}} 'if (-not (Get-Command pipx -ErrorAction SilentlyContinue)) { exit 1 }'
{{else}}
command -v pipx &>/dev/null || exit 1
{{end}}
msg: "First install pipx: https://pipx.pypa.io/"
cmds:
- pipx install --force checkov
install:mkdocs:
desc: Install mkdocs
preconditions:
- sh: |
{{if eq OS "windows"}}
{{.PWSH}} 'if (-not (Get-Command pipx -ErrorAction SilentlyContinue)) { exit 1 }'
{{else}}
command -v pipx &>/dev/null || exit 1
{{end}}
msg: "First install pipx: https://pipx.pypa.io/"
cmds:
- pipx install --force mkdocs
- pipx inject --include-deps --force mkdocs $(mkdocs get-deps)
install:yamllint:
desc: Install yamllint
preconditions:
- sh: |
{{if eq OS "windows"}}
{{.PWSH}} 'if (-not (Get-Command pipx -ErrorAction SilentlyContinue)) { exit 1 }'
{{else}}
command -v pipx &>/dev/null || exit 1
{{end}}
msg: "First install pipx: https://pipx.pypa.io/"
cmds:
- pipx install --force yamllint
install:lychee:
desc: Install lychee
cmds:
- cmd: winget install lycheeverse.lychee
platforms: [windows]
- cmd: cargo install lychee
ignore_error: true
platforms: [linux]
- cmd: brew install lychee
platforms: [darwin]
install:gh:
desc: Install GitHub CLI
cmds:
- cmd: winget install GitHub.cli
platforms: [windows]
- cmd: brew install gh
platforms: [darwin]
- cmd: |
(type -p wget >/dev/null || (sudo apt update && sudo apt-get install wget -y)) \
&& sudo mkdir -p -m 755 /etc/apt/keyrings \
&& out=$(mktemp) && wget -nv -O$out https://cli.github.com/packages/githubcli-archive-keyring.gpg \
&& cat $out | sudo tee /etc/apt/keyrings/githubcli-archive-keyring.gpg > /dev/null \
&& sudo chmod go+r /etc/apt/keyrings/githubcli-archive-keyring.gpg \
&& echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" | sudo tee /etc/apt/sources.list.d/github-cli.list > /dev/null \
&& sudo apt update \
&& sudo apt install gh -y
platforms: [linux]