Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
95 changes: 95 additions & 0 deletions .github/workflows/test-acceptance.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
# Ask the private meshStack mono repo to run this repository's acceptance suite and report the
# result back as a check run. The suite needs a whole meshStack backend, so it cannot run here;
# this workflow only asks for it.
name: Acceptance Tests

# The push trigger is not decoration: running on the main push, and not only on pull requests, is
# what surfaces a CLI/backend regression before a release tag.
on:
pull_request_target:
push:
branches:
- main
# TEMPORARY, delete before merge. A pull_request_target run takes this file from the base
# branch, where it does not exist yet, so the dispatcher cannot try itself out on its own pull
# request. A push event takes the pushed branch's own file, and is the only trigger that can.
- feature/cli-satellite

permissions:
contents: read

jobs:
# Reads "Acceptance Tests / request" in the checks list. No `name:`, unlike test.yml's jobs: a
# name pins a stable string for a check that gates a merge, and nothing gates on this one. The
# gating check is "Acceptance Tests (meshStack backend)", which meshfed-release posts.
request:
runs-on: ubuntu-latest
env:
SATELLITE_REF: ${{ github.event_name == 'pull_request_target' && github.event.pull_request.head.ref || github.ref_name }}

# Two things have to hold, and naming them once here keeps the steps below to one condition each.
# The run has to be in our own repository, because a fork of it holds neither the app secrets nor
# a branch anyone gates on. And on a pull request the head branch has to live in this repository,
# which means its author has write access here: the code under test is then code we already trust.
DISPATCH: ${{ github.repository_owner == 'meshcloud' && (github.event_name == 'push' || github.event.pull_request.head.repo.full_name == github.repository) }}

# This workflow MUST NOT check out the pull request, and has no `actions/checkout` for that
# reason. `pull_request_target` runs in the base repo's context with its secrets, so checking out
# contributor code here would be the classic "pwn request" hole. Reading `github.event` and
# calling an API is passive use of that context and safe.
steps:
# Without this the contributor sees a required check that never reports and no reason for it.
- name: Explain a skipped fork pull request
if: env.DISPATCH != 'true' && github.event_name == 'pull_request_target'
env:
BASE_REPO: ${{ github.repository }}
run: echo "::notice::Acceptance tests are not dispatched for a fork pull request. A maintainer has to adopt the branch into $BASE_REPO before the suite can run against it."

# Downscoped to `actions: write` at mint time even though the installation carries nothing
# else, so a later widening of the app cannot leak into this workflow.
- name: Mint a token for the dispatch
id: token
if: env.DISPATCH == 'true'
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
with:
# The secret holds the numeric app id, not a client id, and that is fine: the action reads
# `client-id` or the deprecated `app-id` into one value, and GitHub takes either as the JWT
# issuer. The name stays because it is an organization secret every satellite reads.
client-id: ${{ secrets.SATELLITE_GH_APP_ID }}
private-key: ${{ secrets.SATELLITE_GH_APP_PRIVATE_KEY }}
owner: meshcloud
repositories: meshfed-release
permission-actions: write

# meshfed-release pairs a satellite branch with a same-named branch of its own, and the dispatch
# names that branch rather than always `develop`. A `workflow_dispatch` reads both the workflow
# file and the checkout it makes from the ref it is given, so dispatching to `develop` would run
# the orchestration that is already merged and never the change to it that a paired branch
# carries. This token may dispatch workflows in meshfed-release and read nothing there, so a
# rejected dispatch is the only branch lookup available here. It also answers the better
# question: not whether the branch exists, but whether it carries a workflow this can dispatch.
#
# `$SATELLITE_REF` reaches the script through the environment, and never as a `${{ }}` expression
# that GitHub would substitute into the script text before bash reads it. A branch named `$(id)`
# would otherwise run as a command.
- name: Request the acceptance run
if: env.DISPATCH == 'true'
env:
GH_TOKEN: ${{ steps.token.outputs.token }}
run: |
set -euo pipefail
request() {
gh workflow run ci-satellite.yml \
--repo meshcloud/meshfed-release \
--ref "$1" \
-f repo=meshstack-cli \
-f branch_name="$SATELLITE_REF"
}
if request "$SATELLITE_REF"; then
orchestrated_from="$SATELLITE_REF"
else
echo "::notice::meshfed-release has no branch $SATELLITE_REF to dispatch, so develop orchestrates this run."
request develop
orchestrated_from=develop
fi
echo "::notice::Requested an acceptance run for $SATELLITE_REF, orchestrated from meshfed-release $orchestrated_from. The result arrives as the \"Acceptance Tests (meshStack backend)\" check."
113 changes: 113 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
# meshStack CLI build, lint and test workflow.
name: Tests

# No `paths-ignore` on either trigger, deliberately: a workflow skipped that way never reports its
# checks, so a required check on it stays "expected" forever and blocks the merge. A skipped *job*
# reports success; a skipped *workflow* does not.
on:
pull_request:
push:
branches:
- main

# Testing only needs permissions to read the repository contents.
permissions:
contents: read

concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true

jobs:
build:
name: Go Build
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
with:
go-version-file: 'go.mod'
cache: true
- run: go mod tidy
- run: go build -v ./...
- name: git diff
run: |
git diff --compact-summary --exit-code || \
(echo; echo "Unexpected difference in directories after 'go mod tidy'. Run 'go mod tidy' command and commit."; exit 1)

golangci:
needs: [ build ]
name: Go Lint and Format Check
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
with:
# The repository's pinned Go, because it is what builds the linter in the next step.
go-version-file: 'go.mod'
cache: true
# Built here rather than downloaded by the action below, because golangci-lint's formatters
# use the go/format compiled into the binary: the formatting they enforce comes from the Go
# release that BUILT the linter, not from the toolchain on PATH. The tool directive in go.mod
# is the single pin, so no version is repeated here, and `go install` puts it where the action
# will find it.
- name: Build the pinned golangci-lint
run: go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint
- name: golangci-lint
uses: golangci/golangci-lint-action@ba0d7d2ec06a0ea1cb5fa41b2e4a3ab91d21278a # v9.3.0
with:
# The step above already installed it; the action is here for the annotations it puts on
# the pull request diff, which a bare `run:` does not produce.
install-mode: none
# Deliberately no only-new-issues: this repository starts clean and CI keeps it that way,
# so filtering to changed code cannot help and can only hide a finding.
- name: Suggest fix command on failure
if: failure()
run: |
echo "::error::Linting or formatting issues detected. Run 'task lint -- --fix' locally to automatically fix these issues, then commit the changes."

test:
name: Go Test
needs: [ build ]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
with:
go-version-file: 'go.mod'
cache: true
# Coverage lands in a binary coverage-data directory (GOCOVERDIR format) rather than a text
# profile, because only the binary form can be merged with the acceptance run's coverage — and
# the instrumented binaries flush it on exit even when a test fails. `-coverpkg=./...` has to
# match what meshfed-release runs the acceptance suite with: instrument different package sets
# and the merged figure is a ratio over two different denominators.
- name: Run unit tests with gotestsum
run: |
mkdir -p covdata/unit
go tool gotestsum --junitfile junit.xml --format testdox -- \
-coverpkg=./... ./... -args -test.gocoverdir="$PWD/covdata/unit"

# meshfed-release merges this with its acceptance coverage and posts the single coverage
# comment. It finds the run by the pull request head sha, so this has to be uploaded from a
# `pull_request` job. Contract with that side: the name `covdata-unit`, and covmeta.*/
# covcounters.* at the artifact root — which naming the directory gives, where a glob would
# nest them.
- name: Upload unit coverage data
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: covdata-unit
path: covdata/unit
retention-days: 3

# The merged comment needs an acceptance run; this figure does not, so it is still reported on
# a fork pull request and on a push to main.
- name: Report unit coverage
if: always()
run: |
if ! ls covdata/unit/covmeta.* >/dev/null 2>&1; then
echo "Unit coverage: no data produced." >> "$GITHUB_STEP_SUMMARY"
exit 0
fi
go tool covdata textfmt -i=covdata/unit -o=unit.txt
echo "Unit coverage: $(go tool cover -func=unit.txt | tail -1 | awk '{print $NF}')" >> "$GITHUB_STEP_SUMMARY"
34 changes: 34 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
# Binary produced by 'task build'
/meshstack

# Release artifacts produced by goreleaser
/dist/

# Go environment created by the Nix dev shell (flake.nix shellHook)
/.nix-go/

# Store symlink left behind by 'nix build'
/result
/result-*

# Local meshStack credentials, read by the Taskfile's dotenv
.env

# A go.work names other checkouts by path, so it describes one developer's or one CI runner's
# directory layout and never the repository. meshfed-release's go-satellite plugin writes one when
# it runs the acceptance suite, and the meshStack Terraform provider writes one to build against a
# paired branch of this repository.
go.work
go.work.sum

# What the CI test command leaves behind when you reproduce it locally.
covdata/
junit.xml
unit.txt

# Editor and IDE directories
.vscode/
.idea/

# Per-developer Claude Code settings; .claude/settings.json is shared and committed
.claude/settings.local.json
1 change: 1 addition & 0 deletions .golangci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
version: "2"
Loading
Loading