From 8e67da66eb5fbdb9b5cd1369accb82786c8e4fa9 Mon Sep 17 00:00:00 2001 From: Gary Darnell Date: Thu, 28 May 2026 12:48:27 +0100 Subject: [PATCH 01/65] DS-5676: Added skills for api, browser and mcp monitors --- skills/embed/setup-api-monitor.md | 102 ++++++++++++++++++++++++++ skills/embed/setup-browser-monitor.md | 86 ++++++++++++++++++++++ skills/embed/setup-mcp-monitor.md | 93 +++++++++++++++++++++++ 3 files changed, 281 insertions(+) create mode 100644 skills/embed/setup-api-monitor.md create mode 100644 skills/embed/setup-browser-monitor.md create mode 100644 skills/embed/setup-mcp-monitor.md diff --git a/skills/embed/setup-api-monitor.md b/skills/embed/setup-api-monitor.md new file mode 100644 index 0000000..9b6fbbd --- /dev/null +++ b/skills/embed/setup-api-monitor.md @@ -0,0 +1,102 @@ +--- +name: setup-api-monitor +description: > + Set up an API monitor (HTTP call) in APImetrics: create the monitor, + attach or create a schedule, run on-demand, and verify the result. + Use when asked to create, configure, or test an API monitor or API call. +--- + +## Steps + +### 1. Create the API monitor + +```bash +apimetrics calls create --body '{ + "meta": { + "name": "" + }, + "request": { + "method": "GET", + "url": "" + } +}' --jq '.id' +``` + +Save the returned `id` — this is the call ID used in all subsequent steps. + +To add request headers or auth: +```bash +apimetrics calls create --body '{ + "meta": { "name": "" }, + "request": { + "method": "GET", + "url": "", + "headers": [{"key": "Accept", "value": "application/json"}], + "auth_id": "" + } +}' +``` + +**Validation gate:** Response must contain an `id` field. If the call creation fails with 400, check that `meta.name` and `request.url` and `request.method` are all present. + +### 2. Attach a schedule + +**Option A — attach to an existing schedule:** +```bash +apimetrics schedules add-call-to --schedule-id --target-id +``` + +**Option B — create a new schedule with the monitor already attached:** +```bash +apimetrics schedules create \ + --name "" \ + --frequency 300 \ + --targets +``` + +`--frequency` is in seconds. Common values: `60` (1 min), `300` (5 min), `3600` (1 hour). + +**Validation gate:** For option A, a 200 response confirms the target was added. For option B, confirm the returned schedule includes the call ID in its targets. + +### 3. Run the monitor on-demand + +```bash +apimetrics calls run +``` + +The response contains a `result_id`. Save it for the next step. + +To run from a specific location: +```bash +apimetrics calls run --body '{"location_id": ""}' +``` + +**Validation gate:** Response must contain `result_id`. A 422 response means the project is out of quota. + +### 4. Poll results to verify + +```bash +apimetrics calls list-results --jq '.results[0]' +``` + +A successful result has `result.success: true` and an HTTP status code in the 2xx range. Poll this command until the result from step 3 appears (match by `result_id`). + +To fetch a specific result directly: +```bash +apimetrics results get-result-content --result-id --path / +``` + +**Validation gate:** Confirm `result.success` is `true`. If `false`, inspect `result.failure_reason` and the response body for details. + +## Hard rules + +- Always verify the monitor ID before attaching to a schedule — attaching the wrong ID silently succeeds. +- Do not poll results in a tight loop. Wait 5–10 seconds between checks; on-demand runs typically complete within 30 seconds. +- `--frequency` on schedules is in seconds, not minutes. + +## Error recovery + +- **400 on create:** Missing required fields. Check `meta.name`, `request.url`, and `request.method` are all present and non-empty. +- **401/403:** Confirm `--api-key` or `--apimetrics-project-id` is set, or that environment variable `APIMETRICS_APIMETRICS_PROJECT_ID` is configured. +- **422 on run:** Project is out of quota. Check billing or reduce monitor frequency. +- **No result after 60s:** The run may have been queued behind other runs. Increase wait time or check monitor status in the dashboard. diff --git a/skills/embed/setup-browser-monitor.md b/skills/embed/setup-browser-monitor.md new file mode 100644 index 0000000..f4da5e1 --- /dev/null +++ b/skills/embed/setup-browser-monitor.md @@ -0,0 +1,86 @@ +--- +name: setup-browser-monitor +description: > + Set up a Browser monitor in APImetrics: create the monitor, attach or + create a schedule, run on-demand, and verify the result. + Use when asked to create, configure, or test a browser monitor. +--- + +## Steps + +### 1. Create the browser monitor + +```bash +apimetrics browser-monitors create \ + --name "" \ + --url "" +``` + +Save the returned monitor ID — used in all subsequent steps. + +Optional flags: +- `--description` — human-readable description +- `--browser-types` — comma-separated list of browsers to run on +- `--tags` — tags for grouping (repeatable) +- `--user-agent` — override the User-Agent header +- `--workspace` — workspace ID if using workspaces + +**Validation gate:** Response must contain an `id` field. If creation fails with 400, confirm `--name` and `--url` are both present and that `--url` is a valid URL. + +### 2. Attach a schedule + +**Option A — attach to an existing schedule:** +```bash +apimetrics schedules add-call-to --schedule-id --target-id +``` + +**Option B — create a new schedule with the monitor already attached:** +```bash +apimetrics schedules create \ + --name "" \ + --frequency 300 \ + --targets +``` + +`--frequency` is in seconds. Common values: `60` (1 min), `300` (5 min), `3600` (1 hour). + +**Validation gate:** Confirm the schedule lists the monitor ID in its targets before proceeding. + +### 3. Run the monitor on-demand + +```bash +apimetrics monitors run --monitor-id +``` + +Save the `result_id` from the response — used in the next step. + +**Validation gate:** Response must include a `result_id`. A missing result ID means the run was not queued. + +### 4. Poll result to verify + +```bash +apimetrics results get-result-content --result-id --path / +``` + +Poll until the result is available (typically within 60 seconds for browser monitors). A successful result has no error and a completed status. + +To get a screenshot of the page at the time of the run: +```bash +apimetrics results get-result-screenshot --result-id +``` + +**Validation gate:** Confirm the result shows a successful page load with no errors. If the result shows a failure, inspect the error message and screenshot for details. + +## Hard rules + +- Always verify the monitor ID before attaching to a schedule — attaching the wrong ID silently succeeds. +- Browser monitors take longer to complete than API monitors — wait at least 60 seconds before polling results. +- Do not poll results in a tight loop. Wait 10–15 seconds between checks. +- `--frequency` on schedules is in seconds, not minutes. + +## Error recovery + +- **400 on create:** Missing required fields. Confirm `--name` and `--url` are both provided and that the URL includes the scheme (`https://`). +- **401/403:** Confirm `--api-key` or `--apimetrics-project-id` is set, or that environment variable `APIMETRICS_APIMETRICS_PROJECT_ID` is configured. +- **No result after 120s:** Browser monitors may take longer in high-load periods. Check the monitor status via `apimetrics browser-monitors read --monitor-id ` and retry the run. +- **Screenshot unavailable:** Not all result types include screenshots. Fall back to inspecting result content via `get-result-content`. diff --git a/skills/embed/setup-mcp-monitor.md b/skills/embed/setup-mcp-monitor.md new file mode 100644 index 0000000..1c166de --- /dev/null +++ b/skills/embed/setup-mcp-monitor.md @@ -0,0 +1,93 @@ +--- +name: setup-mcp-monitor +description: > + Set up an MCP monitor in APImetrics: create the monitor with session steps, + attach or create a schedule, run on-demand, and verify the result. + Use when asked to create, configure, or test an MCP monitor. +--- + +## Steps + +### 1. Create the MCP monitor + +```bash +apimetrics MCP-monitors create \ + --name "" \ + --url "" +``` + +Save the returned monitor ID — used in all subsequent steps. + +Optional flags: +- `--steps` — steps to execute during the MCP session (tool calls, prompts, etc.) +- `--auth-id` — Auth Settings ID if the MCP server requires authentication +- `--token-id` — Auth Token ID for token-based auth +- `--overall-timeout-ms` — session timeout in milliseconds (default: 30000) +- `--description` — human-readable description +- `--tags` — tags for grouping (repeatable) +- `--workspace` — workspace ID if using workspaces + +Example with steps: +```bash +apimetrics MCP-monitors create \ + --name "Check tools endpoint" \ + --url "https://mcp.example.com/sse" \ + --steps "" +``` + +**Validation gate:** Response must contain an `id` field. If creation fails with 400, confirm `--name` and `--url` are both present and that the URL points to a reachable MCP SSE endpoint. + +### 2. Attach a schedule + +**Option A — attach to an existing schedule:** +```bash +apimetrics schedules add-call-to --schedule-id --target-id +``` + +**Option B — create a new schedule with the monitor already attached:** +```bash +apimetrics schedules create \ + --name "" \ + --frequency 300 \ + --targets +``` + +`--frequency` is in seconds. Common values: `60` (1 min), `300` (5 min), `3600` (1 hour). + +**Validation gate:** Confirm the schedule lists the monitor ID in its targets before proceeding. + +### 3. Run the monitor on-demand + +```bash +apimetrics monitors run --monitor-id +``` + +Save the `result_id` from the response — used in the next step. + +**Validation gate:** Response must include a `result_id`. A missing result ID means the run was not queued. A 422 means the project is out of quota. + +### 4. Poll result to verify + +```bash +apimetrics results get-result-content --result-id --path / +``` + +Poll until the result is available. MCP monitors run a full session against the server — allow up to the `--overall-timeout-ms` value plus processing time before concluding a run has failed. + +**Validation gate:** Confirm the result shows a successful session with no errors. If the result shows a failure, inspect the error details — common causes are unreachable server, auth failure, or a step that did not return the expected tool response. + +## Hard rules + +- Always verify the monitor ID before attaching to a schedule — attaching the wrong ID silently succeeds. +- MCP monitor runs are bounded by `--overall-timeout-ms`. If the session takes longer than this value, it will be terminated and reported as a failure. +- Do not poll results in a tight loop. Wait 10–15 seconds between checks. +- `--frequency` on schedules is in seconds, not minutes. +- The `--url` must be an SSE endpoint, not a plain HTTP endpoint. + +## Error recovery + +- **400 on create:** Missing required fields. Confirm `--name` and `--url` are both provided and that the URL is a valid SSE endpoint. +- **401/403:** Confirm `--api-key` or `--apimetrics-project-id` is set, or that environment variable `APIMETRICS_APIMETRICS_PROJECT_ID` is configured. If the MCP server itself requires auth, ensure `--auth-id` or `--token-id` is set. +- **Session timeout failures:** Increase `--overall-timeout-ms` on the monitor via `apimetrics MCP-monitors update` and re-run. +- **422 on run:** Project is out of quota. Check billing or reduce monitor frequency. +- **No result after 120s:** Check the monitor is reachable with a direct request to the MCP server URL before retrying. From 3f1ba916201e53a0dd27ad1b60f2a6405f441221 Mon Sep 17 00:00:00 2001 From: Gary Darnell Date: Thu, 28 May 2026 13:01:47 +0100 Subject: [PATCH 02/65] DS-5676: Updated skills and added skills into the binary --- main.go | 2 + skills/embed/setup-api-monitor.md | 34 ++++---- skills/embed/setup-browser-monitor.md | 26 +++--- skills/embed/setup-mcp-monitor.md | 30 +++---- skills/skills.go | 111 ++++++++++++++++++++++++++ 5 files changed, 155 insertions(+), 48 deletions(-) create mode 100644 skills/skills.go diff --git a/main.go b/main.go index fe38132..db8bc01 100644 --- a/main.go +++ b/main.go @@ -7,6 +7,7 @@ import ( "apicontext.com/apimetrics/cli" "apicontext.com/apimetrics/oauth" "apicontext.com/apimetrics/openapi" + "apicontext.com/apimetrics/skills" ) var version string = "dev" @@ -38,6 +39,7 @@ func main() { cli.Defaults() bulk.Init(cli.Root) + skills.Init(cli.Root) // Register format loaders to auto-discover API descriptions cli.AddLoader(openapi.New()) diff --git a/skills/embed/setup-api-monitor.md b/skills/embed/setup-api-monitor.md index 9b6fbbd..2dc1499 100644 --- a/skills/embed/setup-api-monitor.md +++ b/skills/embed/setup-api-monitor.md @@ -11,7 +11,7 @@ description: > ### 1. Create the API monitor ```bash -apimetrics calls create --body '{ +apimetrics create-call --body '{ "meta": { "name": "" }, @@ -19,14 +19,14 @@ apimetrics calls create --body '{ "method": "GET", "url": "" } -}' --jq '.id' +}' ``` -Save the returned `id` — this is the call ID used in all subsequent steps. +Save the returned `id` — used in all subsequent steps. To add request headers or auth: ```bash -apimetrics calls create --body '{ +apimetrics create-call --body '{ "meta": { "name": "" }, "request": { "method": "GET", @@ -37,18 +37,18 @@ apimetrics calls create --body '{ }' ``` -**Validation gate:** Response must contain an `id` field. If the call creation fails with 400, check that `meta.name` and `request.url` and `request.method` are all present. +**Validation gate:** Response must contain an `id` field. If creation fails with 400, check that `meta.name`, `request.url`, and `request.method` are all present. ### 2. Attach a schedule **Option A — attach to an existing schedule:** ```bash -apimetrics schedules add-call-to --schedule-id --target-id +apimetrics add-call-to-schedule --schedule-id --target-id ``` **Option B — create a new schedule with the monitor already attached:** ```bash -apimetrics schedules create \ +apimetrics create-schedule \ --name "" \ --frequency 300 \ --targets @@ -61,42 +61,36 @@ apimetrics schedules create \ ### 3. Run the monitor on-demand ```bash -apimetrics calls run +apimetrics run-call ``` The response contains a `result_id`. Save it for the next step. -To run from a specific location: -```bash -apimetrics calls run --body '{"location_id": ""}' -``` - **Validation gate:** Response must contain `result_id`. A 422 response means the project is out of quota. ### 4. Poll results to verify ```bash -apimetrics calls list-results --jq '.results[0]' +apimetrics list-call-results ``` -A successful result has `result.success: true` and an HTTP status code in the 2xx range. Poll this command until the result from step 3 appears (match by `result_id`). +A successful result has `result.success: true` and an HTTP status code in the 2xx range. Poll until the result from step 3 appears (match by `result_id`). Use `--rsh-filter` to narrow output: -To fetch a specific result directly: ```bash -apimetrics results get-result-content --result-id --path / +apimetrics list-call-results -f body.results[0] ``` **Validation gate:** Confirm `result.success` is `true`. If `false`, inspect `result.failure_reason` and the response body for details. ## Hard rules -- Always verify the monitor ID before attaching to a schedule — attaching the wrong ID silently succeeds. +- Always verify the call ID before attaching to a schedule — attaching the wrong ID silently succeeds. - Do not poll results in a tight loop. Wait 5–10 seconds between checks; on-demand runs typically complete within 30 seconds. - `--frequency` on schedules is in seconds, not minutes. ## Error recovery - **400 on create:** Missing required fields. Check `meta.name`, `request.url`, and `request.method` are all present and non-empty. -- **401/403:** Confirm `--api-key` or `--apimetrics-project-id` is set, or that environment variable `APIMETRICS_APIMETRICS_PROJECT_ID` is configured. +- **401/403:** Confirm `--api-key` or project is configured. Run `apimetrics project` to check the active project. - **422 on run:** Project is out of quota. Check billing or reduce monitor frequency. -- **No result after 60s:** The run may have been queued behind other runs. Increase wait time or check monitor status in the dashboard. +- **No result after 60s:** The run may be queued behind other runs. Increase wait time or check monitor status. diff --git a/skills/embed/setup-browser-monitor.md b/skills/embed/setup-browser-monitor.md index f4da5e1..283e6c0 100644 --- a/skills/embed/setup-browser-monitor.md +++ b/skills/embed/setup-browser-monitor.md @@ -11,7 +11,7 @@ description: > ### 1. Create the browser monitor ```bash -apimetrics browser-monitors create \ +apimetrics create-browser-monitor \ --name "" \ --url "" ``` @@ -20,23 +20,23 @@ Save the returned monitor ID — used in all subsequent steps. Optional flags: - `--description` — human-readable description -- `--browser-types` — comma-separated list of browsers to run on +- `--browser-types` — browsers the monitor is permitted to run on - `--tags` — tags for grouping (repeatable) - `--user-agent` — override the User-Agent header - `--workspace` — workspace ID if using workspaces -**Validation gate:** Response must contain an `id` field. If creation fails with 400, confirm `--name` and `--url` are both present and that `--url` is a valid URL. +**Validation gate:** Response must contain an `id` field. If creation fails with 400, confirm `--name` and `--url` are both present and that `--url` includes the scheme (`https://`). ### 2. Attach a schedule **Option A — attach to an existing schedule:** ```bash -apimetrics schedules add-call-to --schedule-id --target-id +apimetrics add-call-to-schedule --schedule-id --target-id ``` **Option B — create a new schedule with the monitor already attached:** ```bash -apimetrics schedules create \ +apimetrics create-schedule \ --name "" \ --frequency 300 \ --targets @@ -49,7 +49,7 @@ apimetrics schedules create \ ### 3. Run the monitor on-demand ```bash -apimetrics monitors run --monitor-id +apimetrics run-monitor --monitor-id ``` Save the `result_id` from the response — used in the next step. @@ -59,14 +59,14 @@ Save the `result_id` from the response — used in the next step. ### 4. Poll result to verify ```bash -apimetrics results get-result-content --result-id --path / +apimetrics get-result-content --result-id --path / ``` -Poll until the result is available (typically within 60 seconds for browser monitors). A successful result has no error and a completed status. +Poll until the result is available (typically within 60 seconds for browser monitors). Wait 10–15 seconds between checks. To get a screenshot of the page at the time of the run: ```bash -apimetrics results get-result-screenshot --result-id +apimetrics get-result-screenshot --result-id ``` **Validation gate:** Confirm the result shows a successful page load with no errors. If the result shows a failure, inspect the error message and screenshot for details. @@ -80,7 +80,7 @@ apimetrics results get-result-screenshot --result-id ## Error recovery -- **400 on create:** Missing required fields. Confirm `--name` and `--url` are both provided and that the URL includes the scheme (`https://`). -- **401/403:** Confirm `--api-key` or `--apimetrics-project-id` is set, or that environment variable `APIMETRICS_APIMETRICS_PROJECT_ID` is configured. -- **No result after 120s:** Browser monitors may take longer in high-load periods. Check the monitor status via `apimetrics browser-monitors read --monitor-id ` and retry the run. -- **Screenshot unavailable:** Not all result types include screenshots. Fall back to inspecting result content via `get-result-content`. +- **400 on create:** Confirm `--name` and `--url` are both provided and that the URL includes the scheme (`https://`). +- **401/403:** Confirm `--api-key` or project is configured. Run `apimetrics project` to check the active project. +- **No result after 120s:** Browser monitors may take longer in high-load periods. Check the monitor with `apimetrics read-browser-monitor --monitor-id ` and retry. +- **Screenshot unavailable:** Not all result types include screenshots. Fall back to `get-result-content`. diff --git a/skills/embed/setup-mcp-monitor.md b/skills/embed/setup-mcp-monitor.md index 1c166de..2a99cf7 100644 --- a/skills/embed/setup-mcp-monitor.md +++ b/skills/embed/setup-mcp-monitor.md @@ -11,7 +11,7 @@ description: > ### 1. Create the MCP monitor ```bash -apimetrics MCP-monitors create \ +apimetrics create-mcp-monitor \ --name "" \ --url "" ``` @@ -19,7 +19,7 @@ apimetrics MCP-monitors create \ Save the returned monitor ID — used in all subsequent steps. Optional flags: -- `--steps` — steps to execute during the MCP session (tool calls, prompts, etc.) +- `--steps` — steps to execute during the MCP session - `--auth-id` — Auth Settings ID if the MCP server requires authentication - `--token-id` — Auth Token ID for token-based auth - `--overall-timeout-ms` — session timeout in milliseconds (default: 30000) @@ -29,7 +29,7 @@ Optional flags: Example with steps: ```bash -apimetrics MCP-monitors create \ +apimetrics create-mcp-monitor \ --name "Check tools endpoint" \ --url "https://mcp.example.com/sse" \ --steps "" @@ -41,12 +41,12 @@ apimetrics MCP-monitors create \ **Option A — attach to an existing schedule:** ```bash -apimetrics schedules add-call-to --schedule-id --target-id +apimetrics add-call-to-schedule --schedule-id --target-id ``` **Option B — create a new schedule with the monitor already attached:** ```bash -apimetrics schedules create \ +apimetrics create-schedule \ --name "" \ --frequency 300 \ --targets @@ -59,35 +59,35 @@ apimetrics schedules create \ ### 3. Run the monitor on-demand ```bash -apimetrics monitors run --monitor-id +apimetrics run-monitor --monitor-id ``` Save the `result_id` from the response — used in the next step. -**Validation gate:** Response must include a `result_id`. A missing result ID means the run was not queued. A 422 means the project is out of quota. +**Validation gate:** Response must include a `result_id`. A 422 means the project is out of quota. ### 4. Poll result to verify ```bash -apimetrics results get-result-content --result-id --path / +apimetrics get-result-content --result-id --path / ``` -Poll until the result is available. MCP monitors run a full session against the server — allow up to the `--overall-timeout-ms` value plus processing time before concluding a run has failed. +Poll until the result is available. Allow up to the `--overall-timeout-ms` value plus processing time. Wait 10–15 seconds between checks. -**Validation gate:** Confirm the result shows a successful session with no errors. If the result shows a failure, inspect the error details — common causes are unreachable server, auth failure, or a step that did not return the expected tool response. +**Validation gate:** Confirm the result shows a successful session with no errors. Common failure causes: unreachable server, auth failure, or a step that did not return the expected tool response. ## Hard rules - Always verify the monitor ID before attaching to a schedule — attaching the wrong ID silently succeeds. -- MCP monitor runs are bounded by `--overall-timeout-ms`. If the session takes longer than this value, it will be terminated and reported as a failure. +- MCP monitor runs are bounded by `--overall-timeout-ms`. Sessions exceeding this limit are terminated and reported as failures. - Do not poll results in a tight loop. Wait 10–15 seconds between checks. - `--frequency` on schedules is in seconds, not minutes. - The `--url` must be an SSE endpoint, not a plain HTTP endpoint. ## Error recovery -- **400 on create:** Missing required fields. Confirm `--name` and `--url` are both provided and that the URL is a valid SSE endpoint. -- **401/403:** Confirm `--api-key` or `--apimetrics-project-id` is set, or that environment variable `APIMETRICS_APIMETRICS_PROJECT_ID` is configured. If the MCP server itself requires auth, ensure `--auth-id` or `--token-id` is set. -- **Session timeout failures:** Increase `--overall-timeout-ms` on the monitor via `apimetrics MCP-monitors update` and re-run. +- **400 on create:** Confirm `--name` and `--url` are both provided and that the URL is a valid SSE endpoint. +- **401/403:** Confirm `--api-key` or project is configured. Run `apimetrics project` to check the active project. If the MCP server itself requires auth, ensure `--auth-id` or `--token-id` is set. +- **Session timeout failures:** Increase `--overall-timeout-ms` via `apimetrics update-mcp-monitor` and re-run. - **422 on run:** Project is out of quota. Check billing or reduce monitor frequency. -- **No result after 120s:** Check the monitor is reachable with a direct request to the MCP server URL before retrying. +- **No result after 120s:** Verify the MCP server URL is reachable before retrying. diff --git a/skills/skills.go b/skills/skills.go new file mode 100644 index 0000000..15377fc --- /dev/null +++ b/skills/skills.go @@ -0,0 +1,111 @@ +package skills + +import ( + "embed" + "fmt" + "io/fs" + "os" + "path/filepath" + + "apicontext.com/apimetrics/cli" + "github.com/spf13/cobra" +) + +//go:embed embed/*.md +var skillFiles embed.FS + +// Init registers the skills command group on the parent command. +func Init(cmd *cobra.Command) { + skills := cobra.Command{ + Use: "skills", + Short: "Manage agent skills for APImetrics", + } + + install := cobra.Command{ + Use: "install", + Short: "Install APImetrics skills into an agent skills directory", + Example: " " + os.Args[0] + " skills install --claude-code\n" + + " " + os.Args[0] + " skills install --codex\n" + + " " + os.Args[0] + " skills install --dir ./custom", + RunE: func(cmd *cobra.Command, args []string) error { + claudeCode, _ := cmd.Flags().GetBool("claude-code") + codex, _ := cmd.Flags().GetBool("codex") + dir, _ := cmd.Flags().GetString("dir") + + target, err := resolveTarget(claudeCode, codex, dir) + if err != nil { + return err + } + + return installSkills(target) + }, + } + + install.Flags().Bool("claude-code", false, "Install into .claude/skills/") + install.Flags().Bool("codex", false, "Install into .codex/skills/") + install.Flags().StringP("dir", "d", "", "Install into a custom directory path") + + skills.AddCommand(&install) + cmd.AddCommand(&skills) +} + +func resolveTarget(claudeCode, codex bool, dir string) (string, error) { + count := 0 + if claudeCode { + count++ + } + if codex { + count++ + } + if dir != "" { + count++ + } + + if count == 0 { + return "", fmt.Errorf("specify a target: --claude-code, --codex, or --dir ") + } + if count > 1 { + return "", fmt.Errorf("specify only one of --claude-code, --codex, or --dir") + } + + switch { + case claudeCode: + return ".claude/skills", nil + case codex: + return ".codex/skills", nil + default: + return dir, nil + } +} + +func installSkills(target string) error { + if err := os.MkdirAll(target, 0755); err != nil { + return fmt.Errorf("creating directory %s: %w", target, err) + } + + entries, err := fs.ReadDir(skillFiles, "embed") + if err != nil { + return fmt.Errorf("reading embedded skills: %w", err) + } + + for _, entry := range entries { + if entry.IsDir() { + continue + } + + data, err := skillFiles.ReadFile("embed/" + entry.Name()) + if err != nil { + return fmt.Errorf("reading skill %s: %w", entry.Name(), err) + } + + dest := filepath.Join(target, entry.Name()) + if err := os.WriteFile(dest, data, 0644); err != nil { + return fmt.Errorf("writing skill %s: %w", dest, err) + } + + fmt.Fprintf(cli.Stdout, "Installed %s\n", dest) + } + + fmt.Fprintf(cli.Stdout, "\nSkills installed to %s\n", target) + return nil +} From 3b5f88b4af7a8c95628d3b46131985b792eef715 Mon Sep 17 00:00:00 2001 From: Gary Darnell Date: Fri, 29 May 2026 13:08:09 +0100 Subject: [PATCH 03/65] DS-5676: Added more detail for monitor creation, got passed issue of claude trying basic cli commands instead of the skills --- skills/embed/setup-api-monitor.md | 46 ++++++-- skills/embed/setup-browser-monitor.md | 59 +++++++--- skills/embed/setup-mcp-monitor.md | 82 ++++++++++---- skills/skills.go | 157 ++++++++++++++++++++++++-- 4 files changed, 285 insertions(+), 59 deletions(-) diff --git a/skills/embed/setup-api-monitor.md b/skills/embed/setup-api-monitor.md index 2dc1499..6dd4fbf 100644 --- a/skills/embed/setup-api-monitor.md +++ b/skills/embed/setup-api-monitor.md @@ -6,12 +6,25 @@ description: > Use when asked to create, configure, or test an API monitor or API call. --- +## Input format + +All `apimetrics` create commands read JSON from stdin. Use heredoc syntax: + +```bash +apimetrics create-call <<'EOF' +{ ... } +EOF +``` + +There is no `--body`, `--data`, or `-d` flag on any `apimetrics` command. + ## Steps ### 1. Create the API monitor ```bash -apimetrics create-call --body '{ +apimetrics create-call <<'EOF' +{ "meta": { "name": "" }, @@ -19,14 +32,16 @@ apimetrics create-call --body '{ "method": "GET", "url": "" } -}' +} +EOF ``` Save the returned `id` — used in all subsequent steps. To add request headers or auth: ```bash -apimetrics create-call --body '{ +apimetrics create-call <<'EOF' +{ "meta": { "name": "" }, "request": { "method": "GET", @@ -34,19 +49,34 @@ apimetrics create-call --body '{ "headers": [{"key": "Accept", "value": "application/json"}], "auth_id": "" } -}' +} +EOF ``` **Validation gate:** Response must contain an `id` field. If creation fails with 400, check that `meta.name`, `request.url`, and `request.method` are all present. ### 2. Attach a schedule -**Option A — attach to an existing schedule:** +Always offer scheduling after creating the monitor, unless the user has explicitly said they do not want one. + +First, list existing schedules so the user can choose: +```bash +apimetrics list-schedules +``` + +Present the options to the user: +- Attach to one of the existing schedules +- Create a new schedule and attach this monitor to it +- Skip scheduling for now + +Wait for the user's choice before proceeding. + +**To attach to an existing schedule:** ```bash apimetrics add-call-to-schedule --schedule-id --target-id ``` -**Option B — create a new schedule with the monitor already attached:** +**To create a new schedule:** ```bash apimetrics create-schedule \ --name "" \ @@ -56,7 +86,7 @@ apimetrics create-schedule \ `--frequency` is in seconds. Common values: `60` (1 min), `300` (5 min), `3600` (1 hour). -**Validation gate:** For option A, a 200 response confirms the target was added. For option B, confirm the returned schedule includes the call ID in its targets. +**Validation gate:** Confirm the schedule lists the monitor ID in its targets before proceeding. ### 3. Run the monitor on-demand @@ -74,7 +104,7 @@ The response contains a `result_id`. Save it for the next step. apimetrics list-call-results ``` -A successful result has `result.success: true` and an HTTP status code in the 2xx range. Poll until the result from step 3 appears (match by `result_id`). Use `--rsh-filter` to narrow output: +A successful result has `result.success: true` and an HTTP status code in the 2xx range. Poll until the result from step 3 appears (match by `result_id`). Use `-f` to narrow output: ```bash apimetrics list-call-results -f body.results[0] diff --git a/skills/embed/setup-browser-monitor.md b/skills/embed/setup-browser-monitor.md index 283e6c0..5cf9095 100644 --- a/skills/embed/setup-browser-monitor.md +++ b/skills/embed/setup-browser-monitor.md @@ -6,35 +6,64 @@ description: > Use when asked to create, configure, or test a browser monitor. --- +## Input format + +All `apimetrics` create commands read JSON from stdin. Use heredoc syntax: + +```bash +apimetrics create-browser-monitor <<'EOF' +{ ... } +EOF +``` + +There is no `--body`, `--data`, or `-d` flag on any `apimetrics` command. + ## Steps ### 1. Create the browser monitor ```bash -apimetrics create-browser-monitor \ - --name "" \ - --url "" +apimetrics create-browser-monitor <<'EOF' +{ + "name": "", + "url": "" +} +EOF ``` -Save the returned monitor ID — used in all subsequent steps. +Save the returned `id` — used in all subsequent steps. -Optional flags: -- `--description` — human-readable description -- `--browser-types` — browsers the monitor is permitted to run on -- `--tags` — tags for grouping (repeatable) -- `--user-agent` — override the User-Agent header -- `--workspace` — workspace ID if using workspaces +Optional fields: +- `"description"` — human-readable description +- `"browser_types"` — list of browsers the monitor is permitted to run on +- `"tags"` — list of tags for grouping +- `"user_agent"` — override the User-Agent header +- `"workspace"` — workspace ID if using workspaces -**Validation gate:** Response must contain an `id` field. If creation fails with 400, confirm `--name` and `--url` are both present and that `--url` includes the scheme (`https://`). +**Validation gate:** Response must contain an `id` field. If creation fails with 400, confirm `name` and `url` are both present and that `url` includes the scheme (`https://`). ### 2. Attach a schedule -**Option A — attach to an existing schedule:** +Always offer scheduling after creating the monitor, unless the user has explicitly said they do not want one. + +First, list existing schedules so the user can choose: +```bash +apimetrics list-schedules +``` + +Present the options to the user: +- Attach to one of the existing schedules +- Create a new schedule and attach this monitor to it +- Skip scheduling for now + +Wait for the user's choice before proceeding. + +**To attach to an existing schedule:** ```bash apimetrics add-call-to-schedule --schedule-id --target-id ``` -**Option B — create a new schedule with the monitor already attached:** +**To create a new schedule:** ```bash apimetrics create-schedule \ --name "" \ @@ -69,7 +98,7 @@ To get a screenshot of the page at the time of the run: apimetrics get-result-screenshot --result-id ``` -**Validation gate:** Confirm the result shows a successful page load with no errors. If the result shows a failure, inspect the error message and screenshot for details. +**Validation gate:** Confirm the result shows a successful page load with no errors. ## Hard rules @@ -80,7 +109,7 @@ apimetrics get-result-screenshot --result-id ## Error recovery -- **400 on create:** Confirm `--name` and `--url` are both provided and that the URL includes the scheme (`https://`). +- **400 on create:** Confirm `name` and `url` are both provided and that the URL includes the scheme (`https://`). - **401/403:** Confirm `--api-key` or project is configured. Run `apimetrics project` to check the active project. - **No result after 120s:** Browser monitors may take longer in high-load periods. Check the monitor with `apimetrics read-browser-monitor --monitor-id ` and retry. - **Screenshot unavailable:** Not all result types include screenshots. Fall back to `get-result-content`. diff --git a/skills/embed/setup-mcp-monitor.md b/skills/embed/setup-mcp-monitor.md index 2a99cf7..f06214c 100644 --- a/skills/embed/setup-mcp-monitor.md +++ b/skills/embed/setup-mcp-monitor.md @@ -6,45 +6,77 @@ description: > Use when asked to create, configure, or test an MCP monitor. --- +## Input format + +All `apimetrics` create commands read JSON from stdin. Use heredoc syntax: + +```bash +apimetrics create-mcp-monitor <<'EOF' +{ ... } +EOF +``` + +There is no `--body`, `--data`, or `-d` flag on any `apimetrics` command. + ## Steps ### 1. Create the MCP monitor ```bash -apimetrics create-mcp-monitor \ - --name "" \ - --url "" +apimetrics create-mcp-monitor <<'EOF' +{ + "name": "", + "url": "" +} +EOF ``` -Save the returned monitor ID — used in all subsequent steps. +Save the returned `id` — used in all subsequent steps. -Optional flags: -- `--steps` — steps to execute during the MCP session -- `--auth-id` — Auth Settings ID if the MCP server requires authentication -- `--token-id` — Auth Token ID for token-based auth -- `--overall-timeout-ms` — session timeout in milliseconds (default: 30000) -- `--description` — human-readable description -- `--tags` — tags for grouping (repeatable) -- `--workspace` — workspace ID if using workspaces +Optional fields: +- `"steps"` — steps to execute during the MCP session +- `"auth_id"` — Auth Settings ID if the MCP server requires authentication +- `"token_id"` — Auth Token ID for token-based auth +- `"overall_timeout_ms"` — session timeout in milliseconds (default: 30000) +- `"description"` — human-readable description +- `"tags"` — list of tags for grouping +- `"workspace"` — workspace ID if using workspaces Example with steps: ```bash -apimetrics create-mcp-monitor \ - --name "Check tools endpoint" \ - --url "https://mcp.example.com/sse" \ - --steps "" +apimetrics create-mcp-monitor <<'EOF' +{ + "name": "Check tools endpoint", + "url": "https://mcp.example.com/sse", + "steps": [{"step_type": "list_tools", "timeout_ms": 10000}] +} +EOF ``` -**Validation gate:** Response must contain an `id` field. If creation fails with 400, confirm `--name` and `--url` are both present and that the URL points to a reachable MCP SSE endpoint. +**Validation gate:** Response must contain an `id` field. If creation fails with 400, confirm `name` and `url` are both present and that the URL points to a reachable MCP SSE endpoint. ### 2. Attach a schedule -**Option A — attach to an existing schedule:** +Always offer scheduling after creating the monitor, unless the user has explicitly said they do not want one. + +First, list existing schedules so the user can choose: +```bash +apimetrics list-schedules +``` + +Present the options to the user: +- Attach to one of the existing schedules +- Create a new schedule and attach this monitor to it +- Skip scheduling for now + +Wait for the user's choice before proceeding. + +**To attach to an existing schedule:** ```bash apimetrics add-call-to-schedule --schedule-id --target-id ``` -**Option B — create a new schedule with the monitor already attached:** +**To create a new schedule:** ```bash apimetrics create-schedule \ --name "" \ @@ -72,22 +104,22 @@ Save the `result_id` from the response — used in the next step. apimetrics get-result-content --result-id --path / ``` -Poll until the result is available. Allow up to the `--overall-timeout-ms` value plus processing time. Wait 10–15 seconds between checks. +Poll until the result is available. Allow up to the `overall_timeout_ms` value plus processing time. Wait 10–15 seconds between checks. **Validation gate:** Confirm the result shows a successful session with no errors. Common failure causes: unreachable server, auth failure, or a step that did not return the expected tool response. ## Hard rules - Always verify the monitor ID before attaching to a schedule — attaching the wrong ID silently succeeds. -- MCP monitor runs are bounded by `--overall-timeout-ms`. Sessions exceeding this limit are terminated and reported as failures. +- MCP monitor runs are bounded by `overall_timeout_ms`. Sessions exceeding this limit are terminated and reported as failures. - Do not poll results in a tight loop. Wait 10–15 seconds between checks. - `--frequency` on schedules is in seconds, not minutes. -- The `--url` must be an SSE endpoint, not a plain HTTP endpoint. +- The `url` must be an SSE endpoint, not a plain HTTP endpoint. ## Error recovery -- **400 on create:** Confirm `--name` and `--url` are both provided and that the URL is a valid SSE endpoint. -- **401/403:** Confirm `--api-key` or project is configured. Run `apimetrics project` to check the active project. If the MCP server itself requires auth, ensure `--auth-id` or `--token-id` is set. -- **Session timeout failures:** Increase `--overall-timeout-ms` via `apimetrics update-mcp-monitor` and re-run. +- **400 on create:** Confirm `name` and `url` are both provided and that the URL is a valid SSE endpoint. +- **401/403:** Confirm `--api-key` or project is configured. Run `apimetrics project` to check the active project. +- **Session timeout failures:** Increase `overall_timeout_ms` and re-run. - **422 on run:** Project is out of quota. Check billing or reduce monitor frequency. - **No result after 120s:** Verify the MCP server URL is reachable before retrying. diff --git a/skills/skills.go b/skills/skills.go index 15377fc..d7163ff 100644 --- a/skills/skills.go +++ b/skills/skills.go @@ -1,11 +1,13 @@ package skills import ( + "bytes" "embed" "fmt" "io/fs" "os" "path/filepath" + "strings" "apicontext.com/apimetrics/cli" "github.com/spf13/cobra" @@ -14,7 +16,15 @@ import ( //go:embed embed/*.md var skillFiles embed.FS -// Init registers the skills command group on the parent command. +type agent int + +const ( + agentCustom agent = iota + agentClaudeCode + agentCodex +) + +// Init registers the skills command group and the top-level onboard command. func Init(cmd *cobra.Command) { skills := cobra.Command{ Use: "skills", @@ -32,24 +42,36 @@ func Init(cmd *cobra.Command) { codex, _ := cmd.Flags().GetBool("codex") dir, _ := cmd.Flags().GetString("dir") - target, err := resolveTarget(claudeCode, codex, dir) + target, kind, err := resolveTarget(claudeCode, codex, dir) if err != nil { return err } - return installSkills(target) + return installSkills(target, kind) }, } - install.Flags().Bool("claude-code", false, "Install into .claude/skills/") + install.Flags().Bool("claude-code", false, "Install into .claude/commands/ as slash commands and write .claude/agents.md") install.Flags().Bool("codex", false, "Install into .codex/skills/") install.Flags().StringP("dir", "d", "", "Install into a custom directory path") + onboard := cobra.Command{ + Use: "onboard", + Short: "Print all APImetrics skills to stdout for agent onboarding", + Long: "Prints all embedded skill workflows to stdout.\n" + + "Run this command and include the output in your agent's context\n" + + "to onboard it on the correct APImetrics CLI workflows.", + RunE: func(cmd *cobra.Command, args []string) error { + return printSkills() + }, + } + skills.AddCommand(&install) cmd.AddCommand(&skills) + cmd.AddCommand(&onboard) } -func resolveTarget(claudeCode, codex bool, dir string) (string, error) { +func resolveTarget(claudeCode, codex bool, dir string) (string, agent, error) { count := 0 if claudeCode { count++ @@ -62,23 +84,23 @@ func resolveTarget(claudeCode, codex bool, dir string) (string, error) { } if count == 0 { - return "", fmt.Errorf("specify a target: --claude-code, --codex, or --dir ") + return "", agentCustom, fmt.Errorf("specify a target: --claude-code, --codex, or --dir ") } if count > 1 { - return "", fmt.Errorf("specify only one of --claude-code, --codex, or --dir") + return "", agentCustom, fmt.Errorf("specify only one of --claude-code, --codex, or --dir") } switch { case claudeCode: - return ".claude/skills", nil + return ".claude/commands", agentClaudeCode, nil case codex: - return ".codex/skills", nil + return ".codex/skills", agentCodex, nil default: - return dir, nil + return dir, agentCustom, nil } } -func installSkills(target string) error { +func installSkills(target string, kind agent) error { if err := os.MkdirAll(target, 0755); err != nil { return fmt.Errorf("creating directory %s: %w", target, err) } @@ -88,6 +110,7 @@ func installSkills(target string) error { return fmt.Errorf("reading embedded skills: %w", err) } + var names []string for _, entry := range entries { if entry.IsDir() { continue @@ -104,8 +127,120 @@ func installSkills(target string) error { } fmt.Fprintf(cli.Stdout, "Installed %s\n", dest) + names = append(names, strings.TrimSuffix(entry.Name(), ".md")) } fmt.Fprintf(cli.Stdout, "\nSkills installed to %s\n", target) + + switch kind { + case agentClaudeCode: + fmt.Fprintf(cli.Stdout, "\nAvailable as slash commands in Claude Code:\n") + for _, name := range names { + fmt.Fprintf(cli.Stdout, " /%s\n", name) + } + if err := writeAgentsMD(names, os.Args[0]); err != nil { + return err + } + if err := updateClaudeMD(); err != nil { + return err + } + case agentCodex: + fmt.Fprintf(cli.Stdout, "\nReference these files from your agent's context configuration.\n") + } + + fmt.Fprintf(cli.Stdout, "\nTo onboard any agent directly, run: %s onboard\n", os.Args[0]) + return nil +} + +func writeAgentsMD(skillNames []string, bin string) error { + const path = ".claude/agents.md" + + content := buildAgentsMD(skillNames, bin) + if err := os.WriteFile(path, []byte(content), 0644); err != nil { + return fmt.Errorf("writing %s: %w", path, err) + } + + fmt.Fprintf(cli.Stdout, "\nWritten %s\n", path) + return nil +} + +// updateClaudeMD appends an @.claude/agents.md reference to CLAUDE.md if not already present. +func updateClaudeMD() error { + const path = "CLAUDE.md" + const ref = "@.claude/agents.md" + + existing, _ := os.ReadFile(path) + + if bytes.Contains(existing, []byte(ref)) { + return nil + } + + updated := existing + if len(updated) > 0 && updated[len(updated)-1] != '\n' { + updated = append(updated, '\n') + } + if len(updated) > 0 { + updated = append(updated, '\n') + } + updated = append(updated, []byte(ref+"\n")...) + + if err := os.WriteFile(path, updated, 0644); err != nil { + return fmt.Errorf("updating CLAUDE.md: %w", err) + } + + fmt.Fprintf(cli.Stdout, "Added @.claude/agents.md reference to CLAUDE.md\n") + return nil +} + +func buildAgentsMD(skillNames []string, bin string) string { + var b strings.Builder + + b.WriteString("# APImetrics CLI Agent Guide\n\n") + b.WriteString(fmt.Sprintf("Use `%s` for all apimetrics commands in this project.\n\n", bin)) + + b.WriteString("## Prerequisites\n\n") + b.WriteString("Before running any command:\n\n") + b.WriteString(fmt.Sprintf("1. Run `%s project show` to confirm a project is active — all commands fail without one. If none is set, run `%s project select`.\n", bin, bin)) + b.WriteString("2. Invoke the relevant skill for the task.\n\n") + + b.WriteString("## Critical facts\n\n") + b.WriteString("- Commands are flat, not grouped: use `create-call`, not `calls create`\n") + b.WriteString("- All create commands read JSON from stdin using heredoc syntax — there is no `--body`, `--data`, or `-d` flag\n") + b.WriteString(fmt.Sprintf("- Correct pattern: `%s create-call <<'EOF' ... EOF`\n\n", bin)) + + b.WriteString("## Skills\n\n") + for _, name := range skillNames { + b.WriteString(fmt.Sprintf("- `/%s`\n", name)) + } + b.WriteString(fmt.Sprintf("\nIf slash commands are unavailable or you need to refresh context, run:\n\n```bash\n%s onboard\n```\n\nThis works for any agent and always reflects the current skill set.\n", bin)) + + return b.String() +} + +func printSkills() error { + entries, err := fs.ReadDir(skillFiles, "embed") + if err != nil { + return fmt.Errorf("reading embedded skills: %w", err) + } + + first := true + for _, entry := range entries { + if entry.IsDir() { + continue + } + + data, err := skillFiles.ReadFile("embed/" + entry.Name()) + if err != nil { + return fmt.Errorf("reading skill %s: %w", entry.Name(), err) + } + + if !first { + fmt.Fprintln(cli.Stdout) + } + first = false + + fmt.Fprint(cli.Stdout, string(data)) + } + return nil } From 7eccf083d644fd0f5707650dee6b49924efb57d1 Mon Sep 17 00:00:00 2001 From: Gary Darnell Date: Fri, 29 May 2026 13:32:48 +0100 Subject: [PATCH 04/65] DS-5676: Caught error in project show skill --- skills/embed/setup-api-monitor.md | 2 +- skills/embed/setup-browser-monitor.md | 2 +- skills/embed/setup-mcp-monitor.md | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/skills/embed/setup-api-monitor.md b/skills/embed/setup-api-monitor.md index 6dd4fbf..4e49946 100644 --- a/skills/embed/setup-api-monitor.md +++ b/skills/embed/setup-api-monitor.md @@ -121,6 +121,6 @@ apimetrics list-call-results -f body.results[0] ## Error recovery - **400 on create:** Missing required fields. Check `meta.name`, `request.url`, and `request.method` are all present and non-empty. -- **401/403:** Confirm `--api-key` or project is configured. Run `apimetrics project` to check the active project. +- **401/403:** Confirm `--api-key` or project is configured. Run `apimetrics project show` to check the active project. - **422 on run:** Project is out of quota. Check billing or reduce monitor frequency. - **No result after 60s:** The run may be queued behind other runs. Increase wait time or check monitor status. diff --git a/skills/embed/setup-browser-monitor.md b/skills/embed/setup-browser-monitor.md index 5cf9095..4ad5642 100644 --- a/skills/embed/setup-browser-monitor.md +++ b/skills/embed/setup-browser-monitor.md @@ -110,6 +110,6 @@ apimetrics get-result-screenshot --result-id ## Error recovery - **400 on create:** Confirm `name` and `url` are both provided and that the URL includes the scheme (`https://`). -- **401/403:** Confirm `--api-key` or project is configured. Run `apimetrics project` to check the active project. +- **401/403:** Confirm `--api-key` or project is configured. Run `apimetrics project show` to check the active project. - **No result after 120s:** Browser monitors may take longer in high-load periods. Check the monitor with `apimetrics read-browser-monitor --monitor-id ` and retry. - **Screenshot unavailable:** Not all result types include screenshots. Fall back to `get-result-content`. diff --git a/skills/embed/setup-mcp-monitor.md b/skills/embed/setup-mcp-monitor.md index f06214c..ec38b8a 100644 --- a/skills/embed/setup-mcp-monitor.md +++ b/skills/embed/setup-mcp-monitor.md @@ -119,7 +119,7 @@ Poll until the result is available. Allow up to the `overall_timeout_ms` value p ## Error recovery - **400 on create:** Confirm `name` and `url` are both provided and that the URL is a valid SSE endpoint. -- **401/403:** Confirm `--api-key` or project is configured. Run `apimetrics project` to check the active project. +- **401/403:** Confirm `--api-key` or project is configured. Run `apimetrics project show` to check the active project. - **Session timeout failures:** Increase `overall_timeout_ms` and re-run. - **422 on run:** Project is out of quota. Check billing or reduce monitor frequency. - **No result after 120s:** Verify the MCP server URL is reachable before retrying. From 46417bbe5540653f1bd67909c3e1a6f569337276 Mon Sep 17 00:00:00 2001 From: Ruairidh James Date: Wed, 3 Jun 2026 11:55:12 +0100 Subject: [PATCH 05/65] DS-5672: Add quality check workflow for PRs --- .github/workflows/quality.yml | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 .github/workflows/quality.yml diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml new file mode 100644 index 0000000..1a560e2 --- /dev/null +++ b/.github/workflows/quality.yml @@ -0,0 +1,32 @@ +name: Quality Checks + +on: pull_request + +permissions: + contents: read + issues: read + checks: write + pull-requests: write + +jobs: + test: + name: Verify build and run unit tests + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v5 + + - name: Set up Go + uses: actions/setup-go@v6 + with: + go-version-file: go.mod + cache: true + + - name: Download modules + run: go mod download + + - name: Verify build + run: go build + + - name: Run tests + run: go test ./... From d7fe84414fac6bdf7738900afa6e00bd6aab5cc5 Mon Sep 17 00:00:00 2001 From: Ruairidh James Date: Wed, 3 Jun 2026 11:55:26 +0100 Subject: [PATCH 06/65] DS-5672: Fix/tidy existing unit tests - Remove redundant tests - Update commands test so we don't trigger ensureProject --- bulk/commands_test.go | 3 + cli/api.go | 1 + cli/apiconfig.go | 4 +- cli/apiconfig_test.go | 64 --------- cli/cli.go | 3 + cli/cli_test.go | 299 ---------------------------------------- cli/interactive_test.go | 1 + cli/request_test.go | 5 +- 8 files changed, 13 insertions(+), 367 deletions(-) diff --git a/bulk/commands_test.go b/bulk/commands_test.go index 1d96f0f..3690921 100644 --- a/bulk/commands_test.go +++ b/bulk/commands_test.go @@ -127,6 +127,7 @@ func TestWorkflow(t *testing.T) { cli.Init("test", "1.0.0") cli.Defaults() Init(cli.Root) + require.NoError(t, cli.SaveState(cli.State{ProjectID: "test-project"})) // Init // ==== @@ -377,6 +378,7 @@ func TestPullFailure(t *testing.T) { cli.Init("test", "1.0.0") cli.Defaults() Init(cli.Root) + require.NoError(t, cli.SaveState(cli.State{ProjectID: "test-project"})) // Init // ==== @@ -423,6 +425,7 @@ func TestPushFailure(t *testing.T) { cli.Init("test", "1.0.0") cli.Defaults() Init(cli.Root) + require.NoError(t, cli.SaveState(cli.State{ProjectID: "test-project"})) // Init // ==== diff --git a/cli/api.go b/cli/api.go index 2c574d9..435cc23 100644 --- a/cli/api.go +++ b/cli/api.go @@ -279,6 +279,7 @@ func Load(entrypoint string, root *cobra.Command) (API, error) { } api, err := load(root, *opsBase, *resolved, resp, name, l) if err == nil { + api.CLIVersion = root.Version cacheAPI(name, &api) } return api, err diff --git a/cli/apiconfig.go b/cli/apiconfig.go index 789a6af..dbc13b5 100644 --- a/cli/apiconfig.go +++ b/cli/apiconfig.go @@ -129,11 +129,11 @@ func findAPI(uri string) (string, *APIConfig) { if strings.HasPrefix(uri, config.Profiles[profile].Base) { return name, config } - } else if strings.HasPrefix(uri, config.Base) { + } else if config.Base != "" && strings.HasPrefix(uri, config.Base) { return name, config } } else { - if strings.HasPrefix(uri, config.Base) { + if config.Base != "" && strings.HasPrefix(uri, config.Base) { // TODO: find the longest matching base? return name, config } diff --git a/cli/apiconfig_test.go b/cli/apiconfig_test.go index e29ed03..ae4114c 100644 --- a/cli/apiconfig_test.go +++ b/cli/apiconfig_test.go @@ -7,70 +7,6 @@ import ( "github.com/stretchr/testify/assert" ) -func TestAPIContentTypes(t *testing.T) { - captured := run("api content-types") - assert.Contains(t, captured, "application/json") - assert.Contains(t, captured, "table") - assert.Contains(t, captured, "readable") -} - -func TestAPIShow(t *testing.T) { - for tn, tc := range map[string]struct { - color bool - want string - }{ - "no color": {false, "{\n \"base\": \"https://api.example.com\"\n}\n"}, - "color": {true, "\x1b[38;5;247m{\x1b[0m\n \x1b[38;5;74m\"base\"\x1b[0m\x1b[38;5;247m:\x1b[0m \x1b[38;5;150m\"https://api.example.com\"\x1b[0m\n\x1b[38;5;247m}\x1b[0m\n"}, - } { - t.Run(tn, func(t *testing.T) { - reset(tc.color) - configs["test"] = &APIConfig{ - name: "test", - Base: "https://api.example.com", - } - captured := runNoReset("api show test") - assert.Equal(t, captured, tc.want) - }) - } -} - -func TestAPIClearCache(t *testing.T) { - reset(false) - - configs["test"] = &APIConfig{ - name: "test", - Base: "https://api.example.com", - } - Cache.Set("test:default.token", "abc123") - - runNoReset("api clear-auth-cache test") - - assert.Equal(t, "", Cache.GetString("test:default.token")) -} - -func TestAPIClearCacheProfile(t *testing.T) { - reset(false) - - configs["test"] = &APIConfig{ - name: "test", - Base: "https://api.example.com", - } - Cache.Set("test:default.token", "abc123") - Cache.Set("test:other.token", "def456") - - runNoReset("api clear-auth-cache test -p other") - - assert.Equal(t, "abc123", Cache.GetString("test:default.token")) - assert.Equal(t, "", Cache.GetString("test:other.token")) -} - -func TestAPIClearCacheMissing(t *testing.T) { - reset(false) - - captured := runNoReset("api clear-auth-cache missing-api") - assert.Contains(t, captured, "API missing-api not found") -} - func TestEditAPIsMissingEditor(t *testing.T) { os.Setenv("EDITOR", "") os.Setenv("VISUAL", "") diff --git a/cli/cli.go b/cli/cli.go index 5c4860d..190890b 100644 --- a/cli/cli.go +++ b/cli/cli.go @@ -366,6 +366,9 @@ func Run() (returnErr error) { if p := cfg.Profiles[profile]; p != nil && p.Base != "" { currentBase = p.Base } + if currentBase == "" { + continue + } api, err := Load(currentBase, Root) if err != nil { panic(err) diff --git a/cli/cli_test.go b/cli/cli_test.go index 923ee0c..5a76015 100644 --- a/cli/cli_test.go +++ b/cli/cli_test.go @@ -1,16 +1,13 @@ package cli import ( - "net/http" "os" - "path/filepath" "strings" "testing" "time" "github.com/spf13/cobra" "github.com/spf13/viper" - "github.com/stretchr/testify/assert" "gopkg.in/h2non/gock.v1" ) @@ -30,16 +27,6 @@ func reset(color bool) { Defaults() } -func run(cmd string, color ...bool) string { - if len(color) == 0 || !color[0] { - reset(false) - } else { - reset(true) - } - - return runNoReset(cmd) -} - func runNoReset(cmd string) string { capture := &strings.Builder{} Stdout = capture @@ -51,166 +38,6 @@ func runNoReset(cmd string) string { return capture.String() } -func expectJSON(t *testing.T, cmd string, expected string) { - captured := run("-o json -f body " + cmd) - assert.JSONEq(t, expected, captured) -} - -func expectExitCode(t *testing.T, expected int) { - assert.Equal(t, expected, GetExitCode()) -} - -func TestGetURI(t *testing.T) { - defer gock.Off() - - gock.New("http://example.com").Get("/foo").Reply(200).JSON(map[string]any{ - "Hello": "World", - }) - - expectJSON(t, "http://example.com/foo", `{ - "Hello": "World" - }`) - expectExitCode(t, 0) -} - -func TestPostURI(t *testing.T) { - defer gock.Off() - - gock.New("http://example.com").Post("/foo").Reply(200).JSON(map[string]any{ - "id": 1, - "value": 123, - }) - - expectJSON(t, "post http://example.com/foo value: 123", `{ - "id": 1, - "value": 123 - }`) -} - -func TestPutURI400(t *testing.T) { - defer gock.Off() - - gock.New("http://example.com").Put("/foo/1").Reply(422).JSON(map[string]any{ - "detail": "Invalid input", - }) - - expectJSON(t, "put http://example.com/foo/1 value: 123", `{ - "detail": "Invalid input" - }`) - expectExitCode(t, 4) -} - -func TestIgnoreStatusCodeExit(t *testing.T) { - defer gock.Off() - - gock.New("http://example.com").Put("/foo/1").Reply(400).JSON(map[string]any{ - "detail": "Invalid input", - }) - - expectJSON(t, "put http://example.com/foo/1 value: 123 --rsh-ignore-status-code", `{ - "detail": "Invalid input" - }`) - expectExitCode(t, 0) -} - -func TestHeaderWithComma(t *testing.T) { - defer gock.Off() - - gock.New("http://example.com").Get("/").MatchHeader("Foo", "a,b,c").Reply(204) - - out := run("http://example.com/ -H Foo:a,b,c") - assert.Contains(t, out, "204 No Content") -} - -type TestAuth struct{} - -// Parameters returns a list of OAuth2 Authorization Code inputs. -func (h *TestAuth) Parameters() []AuthParam { - return []AuthParam{} -} - -// OnRequest gets run before the request goes out on the wire. -func (h *TestAuth) OnRequest(request *http.Request, key string, params map[string]string) error { - request.Header.Set("Authorization", "abc123") - return nil -} - -func TestAuthHeader(t *testing.T) { - reset(false) - - AddAuth("test-auth", &TestAuth{}) - - configs["test-auth-header"] = &APIConfig{ - name: "test-auth-header", - Base: "https://auth-header-test.example.com", - Profiles: map[string]*APIProfile{ - "default": { - Auth: &APIAuth{ - Name: "test-auth", - }, - }, - "no-auth": {}, - }, - } - - captured := runNoReset("auth-header bad-api") - assert.Contains(t, captured, "no matched API") - - captured = runNoReset("auth-header test-auth-header") - assert.Equal(t, "abc123\n", captured) - - captured = runNoReset("auth-header test-auth-header -p bad") - assert.Contains(t, captured, "invalid profile bad") - - captured = runNoReset("auth-header test-auth-header -p no-auth") - assert.Contains(t, captured, "no auth set up") -} - -func TestLinks(t *testing.T) { - defer gock.Off() - - gock.New("http://example.com").Get("/foo").Reply(204).SetHeader("Link", "; rel=\"item\"") - - captured := run("links http://example.com/foo") - assert.JSONEq(t, `{ - "item": [ - { - "rel": "item", - "uri": "http://example.com/bar" - } - ] - }`, captured) -} - -func TestDefaultOutput(t *testing.T) { - defer gock.Off() - - gock.New("http://example.com").Get("/foo").Reply(200).JSON(map[string]any{ - "hello": "world", - }) - - captured := run("http://example.com/foo", true) - assert.Equal(t, "\x1b[38;5;204mHTTP\x1b[0m/\x1b[38;5;172m1.1\x1b[0m \x1b[38;5;172m200\x1b[0m \x1b[38;5;74mOK\x1b[0m\n\x1b[38;5;74mContent-Type\x1b[0m: application/json\n\n\x1b[38;5;172m{\x1b[0m\n \x1b[38;5;74mhello\x1b[0m\x1b[38;5;247m:\x1b[0m \x1b[38;5;150m\"world\"\x1b[0m\x1b[38;5;247m\n\x1b[0m\x1b[38;5;172m}\x1b[0m\n", captured) -} - -func TestHelp(t *testing.T) { - captured := run("--help", false) - assert.Contains(t, captured, "api") - assert.Contains(t, captured, "get") - assert.Contains(t, captured, "put") - assert.Contains(t, captured, "delete") - assert.Contains(t, captured, "edit") -} - -func TestHelpHighlight(t *testing.T) { - captured := run("--help", true) - assert.Contains(t, captured, "api") - assert.Contains(t, captured, "get") - assert.Contains(t, captured, "put") - assert.Contains(t, captured, "delete") - assert.Contains(t, captured, "edit") -} - func TestLoadCache(t *testing.T) { // Invalidate any existin cache. Cache.Set("cache-test.expires", time.Now().Add(-24*time.Hour)) @@ -252,129 +79,3 @@ func TestLoadCache(t *testing.T) { runNoReset("cache-test --help") runNoReset("cache-test --help") } - -func TestAPISync(t *testing.T) { - defer gock.Off() - - gock.New("https://sync-test.example.com/").Reply(404) - gock.New("https://sync-test.example.com/openapi.json").Reply(404) - - reset(false) - - configs["sync-test"] = &APIConfig{ - name: "sync-test", - Base: "https://sync-test.example.com", - Profiles: map[string]*APIProfile{ - "default": {}, - }, - } - - runNoReset("api sync sync-test") -} - -func TestDuplicateAPIBase(t *testing.T) { - defer func() { - os.Remove(filepath.Join(getConfigDir("test"), "apis.json")) - reset(false) - }() - reset(false) - - configs["dupe1"] = &APIConfig{ - name: "dupe1", - Base: "https://dupe.example.com", - Profiles: map[string]*APIProfile{ - "default": {}, - }, - } - configs["dupe2"] = &APIConfig{ - name: "dupe2", - Base: "https://dupe.example.com", - Profiles: map[string]*APIProfile{ - "default": {}, - }, - } - - configs["dupe1"].Save() - configs["dupe2"].Save() - - assert.Panics(t, func() { - run("--help") - }) -} - -func TestCompletion(t *testing.T) { - defer gock.Off() - - gock.New("https://api.example.com/").Reply(http.StatusNotFound) - gock.New("https://api.example.com/openapi.json").Reply(http.StatusOK) - - Init("Completion test", "1.0.0") - Defaults() - - configs["comptest"] = &APIConfig{ - name: "comptest", - Base: "https://api.example.com", - } - - Root.AddCommand(&cobra.Command{ - Use: "comptest", - }) - - AddLoader(&testLoader{ - API: API{ - Operations: []Operation{ - { - Method: http.MethodGet, - URITemplate: "https://api.example.com/users", - }, - { - Method: http.MethodGet, - URITemplate: "https://api.example.com/users/{user-id}", - }, - { - Short: "List item tags", - Method: http.MethodGet, - URITemplate: "https://api.example.com/items/{item-id}/tags", - }, - { - Short: "Get tag details", - Method: http.MethodGet, - URITemplate: "https://api.example.com/items/{item-id}/tags/{tag-id}", - }, - { - Method: http.MethodDelete, - URITemplate: "https://api.example.com/items/{item-id}/tags/{tag-id}", - }, - }, - }, - }) - - // Force a cache-reload if needed. - viper.Set("rsh-no-cache", true) - Load("https://api.example.com/", &cobra.Command{}) - viper.Set("rsh-no-cache", false) - - currentConfig = nil - - // Show APIs - possible, _ := completeGenericCmd(http.MethodGet, true)(nil, []string{}, "") - assert.Equal(t, []string{ - "comptest", - }, possible) - - currentConfig = configs["comptest"] - - // Short-name URL completion with variables filled in. - possible, _ = completeGenericCmd(http.MethodGet, false)(nil, []string{}, "comptest/items/my-item") - assert.Equal(t, []string{ - "comptest/items/my-item/tags\tList item tags", - "comptest/items/my-item/tags/{tag-id}\tGet tag details", - }, possible) - - // URL without scheme - possible, _ = completeGenericCmd(http.MethodGet, false)(nil, []string{}, "api.example.com/items/my-item") - assert.Equal(t, []string{ - "api.example.com/items/my-item/tags\tList item tags", - "api.example.com/items/my-item/tags/{tag-id}\tGet tag details", - }, possible) -} diff --git a/cli/interactive_test.go b/cli/interactive_test.go index fcf378d..dd683cc 100644 --- a/cli/interactive_test.go +++ b/cli/interactive_test.go @@ -40,6 +40,7 @@ func TestInteractive(t *testing.T) { os.Remove(filepath.Join(getConfigDir("test"), "cache.json")) reset(false) + AddAuth("http-basic", &BasicAuth{}) defer gock.Off() diff --git a/cli/request_test.go b/cli/request_test.go index 7aed307..ba9ca61 100644 --- a/cli/request_test.go +++ b/cli/request_test.go @@ -72,6 +72,7 @@ func (a *authHookFailure) OnRequest(req *http.Request, key string, params map[st func TestAuthHookFailure(t *testing.T) { configs["auth-hook-fail"] = &APIConfig{ + Base: "http://auth-hook-fail.example.com", Profiles: map[string]*APIProfile{ "default": { Auth: &APIAuth{ @@ -83,7 +84,7 @@ func TestAuthHookFailure(t *testing.T) { authHandlers["hook-fail"] = &authHookFailure{} - r, _ := http.NewRequest(http.MethodGet, "/test", nil) + r, _ := http.NewRequest(http.MethodGet, "http://auth-hook-fail.example.com/test", nil) assert.PanicsWithError(t, "some-error", func() { MakeRequest(r) }) @@ -170,7 +171,7 @@ func TestRequestRetryAfter(t *testing.T) { Put("/"). Times(1). Reply(http.StatusTooManyRequests). - SetHeader("Retry-After", time.Now().Format(http.TimeFormat)) + SetHeader("Retry-After", time.Now().UTC().Format(http.TimeFormat)) gock.New("http://example.com"). Put("/"). From 5f543ae41c78447d4e071dbd964c45e5eeb16ed0 Mon Sep 17 00:00:00 2001 From: Gary Darnell Date: Thu, 4 Jun 2026 12:53:38 +0100 Subject: [PATCH 07/65] Fix skill command syntax and update result polling --- skills/embed/setup-api-monitor.md | 21 +++++++++------ skills/embed/setup-browser-monitor.md | 38 ++++++++++++++++----------- skills/embed/setup-mcp-monitor.md | 32 +++++++++++++--------- 3 files changed, 56 insertions(+), 35 deletions(-) diff --git a/skills/embed/setup-api-monitor.md b/skills/embed/setup-api-monitor.md index 4e49946..4fb4baf 100644 --- a/skills/embed/setup-api-monitor.md +++ b/skills/embed/setup-api-monitor.md @@ -71,25 +71,29 @@ Present the options to the user: Wait for the user's choice before proceeding. -**To attach to an existing schedule:** +**To attach to an existing schedule** (both IDs are positional arguments): ```bash -apimetrics add-call-to-schedule --schedule-id --target-id +apimetrics add-call-to-schedule ``` **To create a new schedule:** ```bash -apimetrics create-schedule \ - --name "" \ - --frequency 300 \ - --targets +apimetrics create-schedule <<'EOF' +{ + "name": "", + "frequency": 300, + "targets": [""] +} +EOF ``` -`--frequency` is in seconds. Common values: `60` (1 min), `300` (5 min), `3600` (1 hour). +`frequency` is in seconds. Common values: `60` (1 min), `300` (5 min), `3600` (1 hour). **Validation gate:** Confirm the schedule lists the monitor ID in its targets before proceeding. ### 3. Run the monitor on-demand +`run-call` takes the call ID as a positional argument: ```bash apimetrics run-call ``` @@ -116,7 +120,8 @@ apimetrics list-call-results -f body.results[0] - Always verify the call ID before attaching to a schedule — attaching the wrong ID silently succeeds. - Do not poll results in a tight loop. Wait 5–10 seconds between checks; on-demand runs typically complete within 30 seconds. -- `--frequency` on schedules is in seconds, not minutes. +- `frequency` on schedules is in seconds, not minutes. +- `add-call-to-schedule` takes two positional args: schedule ID first, then target ID. ## Error recovery diff --git a/skills/embed/setup-browser-monitor.md b/skills/embed/setup-browser-monitor.md index 4ad5642..93e36d1 100644 --- a/skills/embed/setup-browser-monitor.md +++ b/skills/embed/setup-browser-monitor.md @@ -58,27 +58,33 @@ Present the options to the user: Wait for the user's choice before proceeding. -**To attach to an existing schedule:** +**To attach to an existing schedule** (both IDs are positional arguments): ```bash -apimetrics add-call-to-schedule --schedule-id --target-id +apimetrics add-call-to-schedule ``` **To create a new schedule:** ```bash -apimetrics create-schedule \ - --name "" \ - --frequency 300 \ - --targets +apimetrics create-schedule <<'EOF' +{ + "name": "", + "frequency": 300, + "targets": [""] +} +EOF ``` -`--frequency` is in seconds. Common values: `60` (1 min), `300` (5 min), `3600` (1 hour). +`frequency` is in seconds. Common values: `60` (1 min), `300` (5 min), `3600` (1 hour). **Validation gate:** Confirm the schedule lists the monitor ID in its targets before proceeding. ### 3. Run the monitor on-demand +`run-monitor` takes the monitor ID as a positional argument and requires a JSON body: ```bash -apimetrics run-monitor --monitor-id +apimetrics run-monitor <<'EOF' +{} +EOF ``` Save the `result_id` from the response — used in the next step. @@ -88,28 +94,30 @@ Save the `result_id` from the response — used in the next step. ### 4. Poll result to verify ```bash -apimetrics get-result-content --result-id --path / +apimetrics get-result ``` -Poll until the result is available (typically within 60 seconds for browser monitors). Wait 10–15 seconds between checks. +Poll until `result` is no longer `QUEUED`. Wait 10–15 seconds between checks (browser monitors typically complete within 60 seconds). + +**Validation gate:** `result` must be `PASS`. Values of `FAIL`, `WARN`, `ERROR`, or `TIMEOUT` indicate a problem — inspect the response for details. To get a screenshot of the page at the time of the run: ```bash -apimetrics get-result-screenshot --result-id +apimetrics get-result-screenshot ``` -**Validation gate:** Confirm the result shows a successful page load with no errors. - ## Hard rules - Always verify the monitor ID before attaching to a schedule — attaching the wrong ID silently succeeds. +- `run-monitor` and `get-result` take positional arguments — do not use `--monitor-id` or `--result-id` flags. +- `add-call-to-schedule` takes two positional args: schedule ID first, then target ID. - Browser monitors take longer to complete than API monitors — wait at least 60 seconds before polling results. - Do not poll results in a tight loop. Wait 10–15 seconds between checks. -- `--frequency` on schedules is in seconds, not minutes. +- `frequency` on schedules is in seconds, not minutes. ## Error recovery - **400 on create:** Confirm `name` and `url` are both provided and that the URL includes the scheme (`https://`). - **401/403:** Confirm `--api-key` or project is configured. Run `apimetrics project show` to check the active project. -- **No result after 120s:** Browser monitors may take longer in high-load periods. Check the monitor with `apimetrics read-browser-monitor --monitor-id ` and retry. +- **No result after 120s:** Browser monitors may take longer in high-load periods. Check the monitor with `apimetrics read-browser-monitor ` and retry. - **Screenshot unavailable:** Not all result types include screenshots. Fall back to `get-result-content`. diff --git a/skills/embed/setup-mcp-monitor.md b/skills/embed/setup-mcp-monitor.md index ec38b8a..fd6df47 100644 --- a/skills/embed/setup-mcp-monitor.md +++ b/skills/embed/setup-mcp-monitor.md @@ -71,27 +71,33 @@ Present the options to the user: Wait for the user's choice before proceeding. -**To attach to an existing schedule:** +**To attach to an existing schedule** (both IDs are positional arguments): ```bash -apimetrics add-call-to-schedule --schedule-id --target-id +apimetrics add-call-to-schedule ``` **To create a new schedule:** ```bash -apimetrics create-schedule \ - --name "" \ - --frequency 300 \ - --targets +apimetrics create-schedule <<'EOF' +{ + "name": "", + "frequency": 300, + "targets": [""] +} +EOF ``` -`--frequency` is in seconds. Common values: `60` (1 min), `300` (5 min), `3600` (1 hour). +`frequency` is in seconds. Common values: `60` (1 min), `300` (5 min), `3600` (1 hour). **Validation gate:** Confirm the schedule lists the monitor ID in its targets before proceeding. ### 3. Run the monitor on-demand +`run-monitor` takes the monitor ID as a positional argument and requires a JSON body: ```bash -apimetrics run-monitor --monitor-id +apimetrics run-monitor <<'EOF' +{} +EOF ``` Save the `result_id` from the response — used in the next step. @@ -101,19 +107,21 @@ Save the `result_id` from the response — used in the next step. ### 4. Poll result to verify ```bash -apimetrics get-result-content --result-id --path / +apimetrics get-result ``` -Poll until the result is available. Allow up to the `overall_timeout_ms` value plus processing time. Wait 10–15 seconds between checks. +Poll until `result` is no longer `QUEUED`. Allow up to the `overall_timeout_ms` value plus processing time. Wait 10–15 seconds between checks. -**Validation gate:** Confirm the result shows a successful session with no errors. Common failure causes: unreachable server, auth failure, or a step that did not return the expected tool response. +**Validation gate:** `result` must be `PASS`. Values of `FAIL`, `WARN`, `ERROR`, or `TIMEOUT` indicate a problem. Common failure causes: unreachable server, auth failure, or a step that did not return the expected tool response. ## Hard rules - Always verify the monitor ID before attaching to a schedule — attaching the wrong ID silently succeeds. +- `run-monitor` and `get-result` take positional arguments — do not use `--monitor-id` or `--result-id` flags. +- `add-call-to-schedule` takes two positional args: schedule ID first, then target ID. - MCP monitor runs are bounded by `overall_timeout_ms`. Sessions exceeding this limit are terminated and reported as failures. - Do not poll results in a tight loop. Wait 10–15 seconds between checks. -- `--frequency` on schedules is in seconds, not minutes. +- `frequency` on schedules is in seconds, not minutes. - The `url` must be an SSE endpoint, not a plain HTTP endpoint. ## Error recovery From a96a758d64ebcebdeb599a78605c9e815bed6cbb Mon Sep 17 00:00:00 2001 From: Ruairidh James Date: Thu, 4 Jun 2026 13:50:25 +0100 Subject: [PATCH 08/65] DS-5672: Add release workflow with GitHub publish --- .github/workflows/release.yml | 41 +++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 .github/workflows/release.yml diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..f4b120b --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,41 @@ +name: Release + +on: + push: + tags: + - v0.* + + +permissions: + contents: write + +jobs: + release: + name: Build and publish release artifacts + runs-on: ubuntu-latest + + steps: + - name: Check out repository + uses: actions/checkout@v5 + with: + fetch-depth: 0 + + - name: Set up Go + uses: actions/setup-go@v6 + with: + go-version-file: go.mod + cache: true + + - name: Test + run: go test ./... + + - name: Run GoReleaser + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + docker run --rm \ + -v "${PWD}:/workspace" \ + -w /workspace \ + -e GITHUB_TOKEN \ + ghcr.io/goreleaser/goreleaser-cross:v1.25-v2.12.7 \ + release --clean --parallelism 4 From 52b98434b2aeb06c0919db0e095b083f1711d207 Mon Sep 17 00:00:00 2001 From: Ruairidh James Date: Thu, 4 Jun 2026 16:19:58 +0100 Subject: [PATCH 09/65] DS-5672: Publish to GCS --- .github/workflows/release.yml | 8 ++++++++ .gitignore | 2 ++ .goreleaser.yml | 5 +++++ 3 files changed, 15 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f4b120b..0ad05be 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -29,6 +29,9 @@ jobs: - name: Test run: go test ./... + - name: Set up GCP credentials + run: echo '${{ secrets.GOOGLE_APPLICATION_CREDENTIALS }}' > gcp-secret.json + - name: Run GoReleaser env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -37,5 +40,10 @@ jobs: -v "${PWD}:/workspace" \ -w /workspace \ -e GITHUB_TOKEN \ + -e GOOGLE_APPLICATION_CREDENTIALS=/workspace/gcp-secret.json \ ghcr.io/goreleaser/goreleaser-cross:v1.25-v2.12.7 \ release --clean --parallelism 4 + + - name: Clean up GCP credentials + if: always() + run: rm -f gcp-secret.json diff --git a/.gitignore b/.gitignore index 4c9fca0..7ca624f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ dist TODO* launch.json +apimetrics +gcp-secret.json diff --git a/.goreleaser.yml b/.goreleaser.yml index 21b5759..0f672f9 100644 --- a/.goreleaser.yml +++ b/.goreleaser.yml @@ -59,6 +59,11 @@ builds: - CC=x86_64-w64-mingw32-gcc - CXX=x86_64-w64-mingw32-g++ +blobs: + - provider: gs + bucket: apimetrics-cli + directory: "{{ .Version }}" + archives: - name_template: "{{ .ProjectName }}-{{ .Version }}-{{ .Os }}-{{ .Arch }}" format_overrides: From e0e565d3af7ee625985c5b29146b0b0edcb393c2 Mon Sep 17 00:00:00 2001 From: ndenny Date: Thu, 4 Jun 2026 16:21:25 -0700 Subject: [PATCH 10/65] Add Apple signing to release flow --- .github/workflows/release.yml | 100 +++++++++++++++++++++++++++++++++- .gitignore | 1 + 2 files changed, 99 insertions(+), 2 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0ad05be..846c84e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -4,6 +4,16 @@ on: push: tags: - v0.* + workflow_dispatch: + inputs: + snapshot: + description: 'Run goreleaser in --snapshot mode (developer build, no publish)' + required: false + default: 'false' + mac_only: + description: 'Only run macOS signing/builds (skip Linux docker release)' + required: false + default: 'false' permissions: @@ -32,18 +42,104 @@ jobs: - name: Set up GCP credentials run: echo '${{ secrets.GOOGLE_APPLICATION_CREDENTIALS }}' > gcp-secret.json - - name: Run GoReleaser + - name: Run GoReleaser (Linux) + if: ${{ github.event.inputs.mac_only != 'true' }} env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + SNAPSHOT: ${{ github.event.inputs.snapshot }} run: | + set -e + EXTRA="" + if [ "$SNAPSHOT" = "true" ]; then + EXTRA="--snapshot" + echo "Running goreleaser in snapshot mode (no publish)" + fi docker run --rm \ -v "${PWD}:/workspace" \ -w /workspace \ -e GITHUB_TOKEN \ -e GOOGLE_APPLICATION_CREDENTIALS=/workspace/gcp-secret.json \ ghcr.io/goreleaser/goreleaser-cross:v1.25-v2.12.7 \ - release --clean --parallelism 4 + release $EXTRA --clean --parallelism 4 - name: Clean up GCP credentials if: always() run: rm -f gcp-secret.json + + release-macos: + name: Build and sign macOS artifacts + runs-on: macos-latest + needs: release + + steps: + - name: Check out repository + uses: actions/checkout@v5 + with: + fetch-depth: 0 + + - name: Set up Go + uses: actions/setup-go@v6 + with: + go-version-file: go.mod + cache: true + + - name: Test + run: go test ./... + + - name: Check for Apple signing secrets + run: | + if [ -z "${{ secrets.APPLE_CERT_P12 }}" ]; then + echo "WARNING: APPLE_CERT_P12 secret not found — skipping Apple code signing for this run." + echo "::warning::APPLE_CERT_P12 secret not found — skipping Apple code signing for this run." + else + echo "Apple signing secrets detected; will proceed with import and signing." + fi + + - name: Import Apple certificate and provisioning profile + if: ${{ secrets.APPLE_CERT_P12 != '' }} + env: + APPLE_CERT_P12: ${{ secrets.APPLE_CERT_P12 }} + APPLE_CERT_PASSWORD: ${{ secrets.APPLE_CERT_PASSWORD }} + KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }} + APPLE_PROV_PROFILE: ${{ secrets.APPLE_PROV_PROFILE }} + run: | + set -e + echo "$APPLE_CERT_P12" | base64 --decode > cert.p12 + KEYCHAIN=build.keychain + security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN" + security import cert.p12 -k ~/Library/Keychains/$KEYCHAIN -P "$APPLE_CERT_PASSWORD" -T /usr/bin/codesign + security set-key-partition-list -S apple-tool:,apple: -s -k "$KEYCHAIN_PASSWORD" ~/Library/Keychains/$KEYCHAIN + security unlock-keychain -p "$KEYCHAIN_PASSWORD" ~/Library/Keychains/$KEYCHAIN + mkdir -p ~/Library/MobileDevice/Provisioning\ Profiles + echo "$APPLE_PROV_PROFILE" | base64 --decode > profile.mobileprovision + cp profile.mobileprovision ~/Library/MobileDevice/Provisioning\ Profiles/ + + - name: Install GoReleaser + run: | + brew install goreleaser/tap/goreleaser || brew upgrade goreleaser/tap/goreleaser || true + + - name: Run GoReleaser (macOS) + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + APPLE_ID: ${{ secrets.APPLE_ID }} + APP_SPECIFIC_PASSWORD: ${{ secrets.APP_SPECIFIC_PASSWORD }} + SNAPSHOT: ${{ github.event.inputs.snapshot }} + run: | + set -e + EXTRA="" + if [ "$SNAPSHOT" = "true" ]; then + EXTRA="--snapshot" + echo "Running goreleaser in snapshot mode (no publish)" + fi + goreleaser release $EXTRA --clean --parallelism 4 + + - name: Clean up keychain and cert + if: always() + env: + KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }} + run: | + set -e + if security list-keychains | grep -q "build.keychain"; then + security delete-keychain build.keychain || true + fi + rm -f cert.p12 profile.mobileprovision || true diff --git a/.gitignore b/.gitignore index 7ca624f..ee7188a 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ TODO* launch.json apimetrics gcp-secret.json +.DS_Store \ No newline at end of file From 7a1b13c3364a2df1a7ae1f3b3bfdc66c45b270e4 Mon Sep 17 00:00:00 2001 From: ndenny Date: Thu, 4 Jun 2026 16:38:45 -0700 Subject: [PATCH 11/65] Improve macOS signing workflow: boolean inputs, keychain/path fixes, robust base64 decode, fail on tag publish without signing secret, safer goreleaser install\n\nCo-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/release.yml | 54 ++++++++++++++++++++++++++--------- 1 file changed, 41 insertions(+), 13 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 846c84e..094fd1d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -9,11 +9,13 @@ on: snapshot: description: 'Run goreleaser in --snapshot mode (developer build, no publish)' required: false - default: 'false' + type: boolean + default: false mac_only: description: 'Only run macOS signing/builds (skip Linux docker release)' required: false - default: 'false' + type: boolean + default: false permissions: @@ -42,8 +44,16 @@ jobs: - name: Set up GCP credentials run: echo '${{ secrets.GOOGLE_APPLICATION_CREDENTIALS }}' > gcp-secret.json + - name: Ensure mac signing secrets for tag releases + if: startsWith(github.ref, 'refs/tags/') + run: | + if [ -z "${{ secrets.APPLE_CERT_P12 }}" ]; then + echo "::error::APPLE_CERT_P12 secret not configured; refusing to publish unsigned macOS artifacts on tag push." + exit 1 + fi + - name: Run GoReleaser (Linux) - if: ${{ github.event.inputs.mac_only != 'true' }} + if: ${{ github.event.inputs.mac_only != true }} env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} SNAPSHOT: ${{ github.event.inputs.snapshot }} @@ -104,19 +114,35 @@ jobs: APPLE_PROV_PROFILE: ${{ secrets.APPLE_PROV_PROFILE }} run: | set -e - echo "$APPLE_CERT_P12" | base64 --decode > cert.p12 - KEYCHAIN=build.keychain - security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN" - security import cert.p12 -k ~/Library/Keychains/$KEYCHAIN -P "$APPLE_CERT_PASSWORD" -T /usr/bin/codesign - security set-key-partition-list -S apple-tool:,apple: -s -k "$KEYCHAIN_PASSWORD" ~/Library/Keychains/$KEYCHAIN - security unlock-keychain -p "$KEYCHAIN_PASSWORD" ~/Library/Keychains/$KEYCHAIN + # Decode .p12 (supports both GNU and BSD base64) + if echo "$APPLE_CERT_P12" | base64 --decode >/dev/null 2>&1; then + echo "$APPLE_CERT_P12" | base64 --decode > cert.p12 + else + echo "$APPLE_CERT_P12" | base64 -D > cert.p12 + fi + + # Use a keychain in the runner temp directory and reference it explicitly + KEYCHAIN_PATH="$RUNNER_TEMP/build.keychain-db" + security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" + security import cert.p12 -k "$KEYCHAIN_PATH" -P "$APPLE_CERT_PASSWORD" -T /usr/bin/codesign + security set-key-partition-list -S apple-tool:,apple: -s -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" + security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" + mkdir -p ~/Library/MobileDevice/Provisioning\ Profiles - echo "$APPLE_PROV_PROFILE" | base64 --decode > profile.mobileprovision + # Decode provisioning profile (BSD/GNU compatible) + if echo "$APPLE_PROV_PROFILE" | base64 --decode >/dev/null 2>&1; then + echo "$APPLE_PROV_PROFILE" | base64 --decode > profile.mobileprovision + else + echo "$APPLE_PROV_PROFILE" | base64 -D > profile.mobileprovision + fi cp profile.mobileprovision ~/Library/MobileDevice/Provisioning\ Profiles/ - name: Install GoReleaser run: | - brew install goreleaser/tap/goreleaser || brew upgrade goreleaser/tap/goreleaser || true + # Try install; if install fails, attempt upgrade. Fail the step if both fail so issues are visible. + if ! brew install goreleaser/tap/goreleaser; then + brew upgrade goreleaser/tap/goreleaser + fi - name: Run GoReleaser (macOS) env: @@ -139,7 +165,9 @@ jobs: KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }} run: | set -e - if security list-keychains | grep -q "build.keychain"; then - security delete-keychain build.keychain || true + # Delete the keychain we created (if present) + KEYCHAIN_PATH="$RUNNER_TEMP/build.keychain-db" + if [ -f "$KEYCHAIN_PATH" ] || security list-keychains | grep -q "$KEYCHAIN_PATH"; then + security delete-keychain "$KEYCHAIN_PATH" || true fi rm -f cert.p12 profile.mobileprovision || true From 1f031e12a9c1e0e8fdf5af09833becc4ef836694 Mon Sep 17 00:00:00 2001 From: ndenny Date: Thu, 4 Jun 2026 16:46:46 -0700 Subject: [PATCH 12/65] Switch mac signing job to artifact handoff instead of rerunning full goreleaser - upload darwin artifacts from Linux release job - download/sign/rechecksum darwin archives on macOS - replace mac assets on the GitHub release Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/release.yml | 84 ++++++++++++++++++++++++++++------- 1 file changed, 69 insertions(+), 15 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 094fd1d..1464e88 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -72,6 +72,17 @@ jobs: ghcr.io/goreleaser/goreleaser-cross:v1.25-v2.12.7 \ release $EXTRA --clean --parallelism 4 + - name: Upload macOS artifacts for signing + if: ${{ github.event.inputs.snapshot != true && github.event.inputs.mac_only != true }} + uses: actions/upload-artifact@v4 + with: + name: macos-release-artifacts + if-no-files-found: error + path: | + dist/*-darwin-amd64.tar.gz + dist/*-darwin-arm64.tar.gz + dist/checksums.txt + - name: Clean up GCP credentials if: always() run: rm -f gcp-secret.json @@ -137,27 +148,70 @@ jobs: fi cp profile.mobileprovision ~/Library/MobileDevice/Provisioning\ Profiles/ - - name: Install GoReleaser + - name: Download macOS artifacts + if: ${{ github.event.inputs.snapshot != true && secrets.APPLE_CERT_P12 != '' && github.event.inputs.mac_only != true }} + uses: actions/download-artifact@v4 + with: + name: macos-release-artifacts + path: dist + + - name: Sign macOS artifacts + if: ${{ github.event.inputs.snapshot != true && secrets.APPLE_CERT_P12 != '' && github.event.inputs.mac_only != true }} + env: + APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }} run: | - # Try install; if install fails, attempt upgrade. Fail the step if both fail so issues are visible. - if ! brew install goreleaser/tap/goreleaser; then - brew upgrade goreleaser/tap/goreleaser + set -e + if [ -z "$APPLE_SIGNING_IDENTITY" ]; then + echo "::error::APPLE_SIGNING_IDENTITY is required to sign macOS binaries." + exit 1 + fi + if [ ! -f dist/checksums.txt ]; then + echo "::error::dist/checksums.txt not found; expected macOS artifacts from Linux job." + exit 1 fi - - name: Run GoReleaser (macOS) + for archive in dist/*-darwin-*.tar.gz; do + [ -f "$archive" ] || continue + tmpdir="$(mktemp -d)" + tar -xzf "$archive" -C "$tmpdir" + + while IFS= read -r -d '' bin; do + codesign --force --options runtime --timestamp --sign "$APPLE_SIGNING_IDENTITY" "$bin" + done < <(find "$tmpdir" -type f -perm -111 -print0) + + tar -czf "${archive}.signed" -C "$tmpdir" . + mv "${archive}.signed" "$archive" + rm -rf "$tmpdir" + + filename="$(basename "$archive")" + sha="$(shasum -a 256 "$archive" | awk '{print $1}')" + grep -v " $filename\$" dist/checksums.txt > dist/checksums.new || true + printf "%s %s\n" "$sha" "$filename" >> dist/checksums.new + mv dist/checksums.new dist/checksums.txt + done + + - name: Upload signed macOS artifacts + if: ${{ github.event.inputs.snapshot != true && secrets.APPLE_CERT_P12 != '' && github.event.inputs.mac_only != true }} + uses: actions/upload-artifact@v4 + with: + name: macos-signed-artifacts + if-no-files-found: error + path: | + dist/*-darwin-amd64.tar.gz + dist/*-darwin-arm64.tar.gz + dist/checksums.txt + + - name: Replace macOS assets on GitHub release + if: ${{ startsWith(github.ref, 'refs/tags/') && github.event.inputs.snapshot != true && secrets.APPLE_CERT_P12 != '' && github.event.inputs.mac_only != true }} env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - APPLE_ID: ${{ secrets.APPLE_ID }} - APP_SPECIFIC_PASSWORD: ${{ secrets.APP_SPECIFIC_PASSWORD }} - SNAPSHOT: ${{ github.event.inputs.snapshot }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | set -e - EXTRA="" - if [ "$SNAPSHOT" = "true" ]; then - EXTRA="--snapshot" - echo "Running goreleaser in snapshot mode (no publish)" - fi - goreleaser release $EXTRA --clean --parallelism 4 + gh release upload "$GITHUB_REF_NAME" \ + dist/*-darwin-amd64.tar.gz \ + dist/*-darwin-arm64.tar.gz \ + dist/checksums.txt \ + --clobber - name: Clean up keychain and cert if: always() From 0511599c7cf655ba65403f12b5ca8a2032cccba4 Mon Sep 17 00:00:00 2001 From: ndenny Date: Thu, 4 Jun 2026 16:54:18 -0700 Subject: [PATCH 13/65] Address latest PR workflow review comments - remove broken mac_only path gating - fail fast on all required signing secrets for tags - validate related signing secrets before import - use printf for base64 decoding inputs - pass keychain explicitly to codesign Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/release.yml | 54 ++++++++++++++++++++++------------- 1 file changed, 34 insertions(+), 20 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1464e88..2354e3d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -11,11 +11,6 @@ on: required: false type: boolean default: false - mac_only: - description: 'Only run macOS signing/builds (skip Linux docker release)' - required: false - type: boolean - default: false permissions: @@ -46,14 +41,25 @@ jobs: - name: Ensure mac signing secrets for tag releases if: startsWith(github.ref, 'refs/tags/') + env: + APPLE_CERT_P12: ${{ secrets.APPLE_CERT_P12 }} + APPLE_CERT_PASSWORD: ${{ secrets.APPLE_CERT_PASSWORD }} + KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }} + APPLE_PROV_PROFILE: ${{ secrets.APPLE_PROV_PROFILE }} + APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }} run: | - if [ -z "${{ secrets.APPLE_CERT_P12 }}" ]; then - echo "::error::APPLE_CERT_P12 secret not configured; refusing to publish unsigned macOS artifacts on tag push." + missing=0 + for var_name in APPLE_CERT_P12 APPLE_CERT_PASSWORD KEYCHAIN_PASSWORD APPLE_PROV_PROFILE APPLE_SIGNING_IDENTITY; do + if [ -z "${!var_name}" ]; then + echo "::error::$var_name secret not configured; refusing to publish unsigned macOS artifacts on tag push." + missing=1 + fi + done + if [ "$missing" -ne 0 ]; then exit 1 fi - name: Run GoReleaser (Linux) - if: ${{ github.event.inputs.mac_only != true }} env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} SNAPSHOT: ${{ github.event.inputs.snapshot }} @@ -73,7 +79,7 @@ jobs: release $EXTRA --clean --parallelism 4 - name: Upload macOS artifacts for signing - if: ${{ github.event.inputs.snapshot != true && github.event.inputs.mac_only != true }} + if: ${{ github.event.inputs.snapshot != true }} uses: actions/upload-artifact@v4 with: name: macos-release-artifacts @@ -125,11 +131,18 @@ jobs: APPLE_PROV_PROFILE: ${{ secrets.APPLE_PROV_PROFILE }} run: | set -e + for var_name in APPLE_CERT_PASSWORD KEYCHAIN_PASSWORD APPLE_PROV_PROFILE; do + if [ -z "${!var_name}" ]; then + echo "::error::$var_name is required when APPLE_CERT_P12 is configured." + exit 1 + fi + done + # Decode .p12 (supports both GNU and BSD base64) - if echo "$APPLE_CERT_P12" | base64 --decode >/dev/null 2>&1; then - echo "$APPLE_CERT_P12" | base64 --decode > cert.p12 + if printf '%s' "$APPLE_CERT_P12" | base64 --decode >/dev/null 2>&1; then + printf '%s' "$APPLE_CERT_P12" | base64 --decode > cert.p12 else - echo "$APPLE_CERT_P12" | base64 -D > cert.p12 + printf '%s' "$APPLE_CERT_P12" | base64 -D > cert.p12 fi # Use a keychain in the runner temp directory and reference it explicitly @@ -141,24 +154,25 @@ jobs: mkdir -p ~/Library/MobileDevice/Provisioning\ Profiles # Decode provisioning profile (BSD/GNU compatible) - if echo "$APPLE_PROV_PROFILE" | base64 --decode >/dev/null 2>&1; then - echo "$APPLE_PROV_PROFILE" | base64 --decode > profile.mobileprovision + if printf '%s' "$APPLE_PROV_PROFILE" | base64 --decode >/dev/null 2>&1; then + printf '%s' "$APPLE_PROV_PROFILE" | base64 --decode > profile.mobileprovision else - echo "$APPLE_PROV_PROFILE" | base64 -D > profile.mobileprovision + printf '%s' "$APPLE_PROV_PROFILE" | base64 -D > profile.mobileprovision fi cp profile.mobileprovision ~/Library/MobileDevice/Provisioning\ Profiles/ - name: Download macOS artifacts - if: ${{ github.event.inputs.snapshot != true && secrets.APPLE_CERT_P12 != '' && github.event.inputs.mac_only != true }} + if: ${{ github.event.inputs.snapshot != true && secrets.APPLE_CERT_P12 != '' }} uses: actions/download-artifact@v4 with: name: macos-release-artifacts path: dist - name: Sign macOS artifacts - if: ${{ github.event.inputs.snapshot != true && secrets.APPLE_CERT_P12 != '' && github.event.inputs.mac_only != true }} + if: ${{ github.event.inputs.snapshot != true && secrets.APPLE_CERT_P12 != '' }} env: APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }} + KEYCHAIN_PATH: ${{ runner.temp }}/build.keychain-db run: | set -e if [ -z "$APPLE_SIGNING_IDENTITY" ]; then @@ -176,7 +190,7 @@ jobs: tar -xzf "$archive" -C "$tmpdir" while IFS= read -r -d '' bin; do - codesign --force --options runtime --timestamp --sign "$APPLE_SIGNING_IDENTITY" "$bin" + codesign --force --options runtime --timestamp --keychain "$KEYCHAIN_PATH" --sign "$APPLE_SIGNING_IDENTITY" "$bin" done < <(find "$tmpdir" -type f -perm -111 -print0) tar -czf "${archive}.signed" -C "$tmpdir" . @@ -191,7 +205,7 @@ jobs: done - name: Upload signed macOS artifacts - if: ${{ github.event.inputs.snapshot != true && secrets.APPLE_CERT_P12 != '' && github.event.inputs.mac_only != true }} + if: ${{ github.event.inputs.snapshot != true && secrets.APPLE_CERT_P12 != '' }} uses: actions/upload-artifact@v4 with: name: macos-signed-artifacts @@ -202,7 +216,7 @@ jobs: dist/checksums.txt - name: Replace macOS assets on GitHub release - if: ${{ startsWith(github.ref, 'refs/tags/') && github.event.inputs.snapshot != true && secrets.APPLE_CERT_P12 != '' && github.event.inputs.mac_only != true }} + if: ${{ startsWith(github.ref, 'refs/tags/') && github.event.inputs.snapshot != true && secrets.APPLE_CERT_P12 != '' }} env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | From eb933a2452897db7e346599e6552679391be0ffa Mon Sep 17 00:00:00 2001 From: ndenny Date: Thu, 4 Jun 2026 16:59:49 -0700 Subject: [PATCH 14/65] Fix snapshot guards and draft->finalize release flow - use boolean inputs context for snapshot conditions - create tag releases as drafts before macOS signing - finalize release after signed macOS assets are uploaded Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/release.yml | 28 ++++++++++++++++++++-------- 1 file changed, 20 insertions(+), 8 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2354e3d..34553de 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -40,7 +40,7 @@ jobs: run: echo '${{ secrets.GOOGLE_APPLICATION_CREDENTIALS }}' > gcp-secret.json - name: Ensure mac signing secrets for tag releases - if: startsWith(github.ref, 'refs/tags/') + if: ${{ startsWith(github.ref, 'refs/tags/') && inputs.snapshot != true }} env: APPLE_CERT_P12: ${{ secrets.APPLE_CERT_P12 }} APPLE_CERT_PASSWORD: ${{ secrets.APPLE_CERT_PASSWORD }} @@ -62,13 +62,17 @@ jobs: - name: Run GoReleaser (Linux) env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - SNAPSHOT: ${{ github.event.inputs.snapshot }} + SNAPSHOT: ${{ inputs.snapshot }} run: | set -e EXTRA="" + DRAFT="" if [ "$SNAPSHOT" = "true" ]; then EXTRA="--snapshot" echo "Running goreleaser in snapshot mode (no publish)" + elif [[ "$GITHUB_REF" == refs/tags/* ]]; then + DRAFT="--draft" + echo "Creating draft release until macOS artifacts are signed" fi docker run --rm \ -v "${PWD}:/workspace" \ @@ -76,10 +80,10 @@ jobs: -e GITHUB_TOKEN \ -e GOOGLE_APPLICATION_CREDENTIALS=/workspace/gcp-secret.json \ ghcr.io/goreleaser/goreleaser-cross:v1.25-v2.12.7 \ - release $EXTRA --clean --parallelism 4 + release $EXTRA $DRAFT --clean --parallelism 4 - name: Upload macOS artifacts for signing - if: ${{ github.event.inputs.snapshot != true }} + if: ${{ inputs.snapshot != true }} uses: actions/upload-artifact@v4 with: name: macos-release-artifacts @@ -162,14 +166,14 @@ jobs: cp profile.mobileprovision ~/Library/MobileDevice/Provisioning\ Profiles/ - name: Download macOS artifacts - if: ${{ github.event.inputs.snapshot != true && secrets.APPLE_CERT_P12 != '' }} + if: ${{ inputs.snapshot != true && secrets.APPLE_CERT_P12 != '' }} uses: actions/download-artifact@v4 with: name: macos-release-artifacts path: dist - name: Sign macOS artifacts - if: ${{ github.event.inputs.snapshot != true && secrets.APPLE_CERT_P12 != '' }} + if: ${{ inputs.snapshot != true && secrets.APPLE_CERT_P12 != '' }} env: APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }} KEYCHAIN_PATH: ${{ runner.temp }}/build.keychain-db @@ -205,7 +209,7 @@ jobs: done - name: Upload signed macOS artifacts - if: ${{ github.event.inputs.snapshot != true && secrets.APPLE_CERT_P12 != '' }} + if: ${{ inputs.snapshot != true && secrets.APPLE_CERT_P12 != '' }} uses: actions/upload-artifact@v4 with: name: macos-signed-artifacts @@ -216,7 +220,7 @@ jobs: dist/checksums.txt - name: Replace macOS assets on GitHub release - if: ${{ startsWith(github.ref, 'refs/tags/') && github.event.inputs.snapshot != true && secrets.APPLE_CERT_P12 != '' }} + if: ${{ startsWith(github.ref, 'refs/tags/') && inputs.snapshot != true && secrets.APPLE_CERT_P12 != '' }} env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | @@ -227,6 +231,14 @@ jobs: dist/checksums.txt \ --clobber + - name: Publish finalized GitHub release + if: ${{ startsWith(github.ref, 'refs/tags/') && inputs.snapshot != true && secrets.APPLE_CERT_P12 != '' }} + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -e + gh release edit "$GITHUB_REF_NAME" --draft=false + - name: Clean up keychain and cert if: always() env: From 1829c807f4152c306132c3d08f55ce4324c63b0e Mon Sep 17 00:00:00 2001 From: ndenny Date: Thu, 4 Jun 2026 17:08:20 -0700 Subject: [PATCH 15/65] Address PR review: notarization, dispatch guard, step outputs, remove prov profile - Add xcrun notarytool step (sign + notarize per binary via zip submission) - Add APPLE_ID/APPLE_ID_PASSWORD/APPLE_TEAM_ID to preflight secrets check - Guard workflow_dispatch against non-tag + non-snapshot to prevent broken releases - Replace secrets.X != '' step conditions with a secrets_check step output - Remove checkout/setup-go/test from macOS job (pure signing job, no build needed) - Remove APPLE_PROV_PROFILE handling (not needed for CLI tool signing) - Simplify base64 decode to base64 -D (always macOS runner) - Add security set-keychain-settings to prevent auto-lock during notarization - Add timeout-minutes to both jobs - Fix .gitignore missing trailing newline Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/release.yml | 113 +++++++++++++++++----------------- .gitignore | 2 +- 2 files changed, 56 insertions(+), 59 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 34553de..e76085d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -20,6 +20,7 @@ jobs: release: name: Build and publish release artifacts runs-on: ubuntu-latest + timeout-minutes: 60 steps: - name: Check out repository @@ -36,6 +37,14 @@ jobs: - name: Test run: go test ./... + - name: Validate dispatch inputs + if: github.event_name == 'workflow_dispatch' + run: | + if [[ "$GITHUB_REF" != refs/tags/* ]] && [ "${{ inputs.snapshot }}" != "true" ]; then + echo "::error::Manual dispatch from a non-tag ref requires snapshot=true to avoid creating a broken release." + exit 1 + fi + - name: Set up GCP credentials run: echo '${{ secrets.GOOGLE_APPLICATION_CREDENTIALS }}' > gcp-secret.json @@ -45,11 +54,13 @@ jobs: APPLE_CERT_P12: ${{ secrets.APPLE_CERT_P12 }} APPLE_CERT_PASSWORD: ${{ secrets.APPLE_CERT_PASSWORD }} KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }} - APPLE_PROV_PROFILE: ${{ secrets.APPLE_PROV_PROFILE }} APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }} + APPLE_ID: ${{ secrets.APPLE_ID }} + APPLE_ID_PASSWORD: ${{ secrets.APPLE_ID_PASSWORD }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} run: | missing=0 - for var_name in APPLE_CERT_P12 APPLE_CERT_PASSWORD KEYCHAIN_PASSWORD APPLE_PROV_PROFILE APPLE_SIGNING_IDENTITY; do + for var_name in APPLE_CERT_P12 APPLE_CERT_PASSWORD KEYCHAIN_PASSWORD APPLE_SIGNING_IDENTITY APPLE_ID APPLE_ID_PASSWORD APPLE_TEAM_ID; do if [ -z "${!var_name}" ]; then echo "::error::$var_name secret not configured; refusing to publish unsigned macOS artifacts on tag push." missing=1 @@ -72,7 +83,7 @@ jobs: echo "Running goreleaser in snapshot mode (no publish)" elif [[ "$GITHUB_REF" == refs/tags/* ]]; then DRAFT="--draft" - echo "Creating draft release until macOS artifacts are signed" + echo "Creating draft release until macOS artifacts are signed and notarized" fi docker run --rm \ -v "${PWD}:/workspace" \ @@ -98,91 +109,69 @@ jobs: run: rm -f gcp-secret.json release-macos: - name: Build and sign macOS artifacts + name: Sign and notarize macOS artifacts runs-on: macos-latest needs: release + timeout-minutes: 30 steps: - - name: Check out repository - uses: actions/checkout@v5 - with: - fetch-depth: 0 - - - name: Set up Go - uses: actions/setup-go@v6 - with: - go-version-file: go.mod - cache: true - - - name: Test - run: go test ./... - - name: Check for Apple signing secrets + id: secrets_check run: | - if [ -z "${{ secrets.APPLE_CERT_P12 }}" ]; then - echo "WARNING: APPLE_CERT_P12 secret not found — skipping Apple code signing for this run." - echo "::warning::APPLE_CERT_P12 secret not found — skipping Apple code signing for this run." + if [ -n "${{ secrets.APPLE_CERT_P12 }}" ]; then + echo "has_signing_cert=true" >> "$GITHUB_OUTPUT" else - echo "Apple signing secrets detected; will proceed with import and signing." + echo "has_signing_cert=false" >> "$GITHUB_OUTPUT" + echo "::warning::APPLE_CERT_P12 not configured — skipping Apple code signing." fi - - name: Import Apple certificate and provisioning profile - if: ${{ secrets.APPLE_CERT_P12 != '' }} + - name: Import Apple certificate + if: steps.secrets_check.outputs.has_signing_cert == 'true' env: APPLE_CERT_P12: ${{ secrets.APPLE_CERT_P12 }} APPLE_CERT_PASSWORD: ${{ secrets.APPLE_CERT_PASSWORD }} KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }} - APPLE_PROV_PROFILE: ${{ secrets.APPLE_PROV_PROFILE }} run: | set -e - for var_name in APPLE_CERT_PASSWORD KEYCHAIN_PASSWORD APPLE_PROV_PROFILE; do + for var_name in APPLE_CERT_PASSWORD KEYCHAIN_PASSWORD; do if [ -z "${!var_name}" ]; then echo "::error::$var_name is required when APPLE_CERT_P12 is configured." exit 1 fi done - # Decode .p12 (supports both GNU and BSD base64) - if printf '%s' "$APPLE_CERT_P12" | base64 --decode >/dev/null 2>&1; then - printf '%s' "$APPLE_CERT_P12" | base64 --decode > cert.p12 - else - printf '%s' "$APPLE_CERT_P12" | base64 -D > cert.p12 - fi + printf '%s' "$APPLE_CERT_P12" | base64 -D > cert.p12 - # Use a keychain in the runner temp directory and reference it explicitly KEYCHAIN_PATH="$RUNNER_TEMP/build.keychain-db" security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" + security set-keychain-settings -lut 21600 "$KEYCHAIN_PATH" security import cert.p12 -k "$KEYCHAIN_PATH" -P "$APPLE_CERT_PASSWORD" -T /usr/bin/codesign security set-key-partition-list -S apple-tool:,apple: -s -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" - mkdir -p ~/Library/MobileDevice/Provisioning\ Profiles - # Decode provisioning profile (BSD/GNU compatible) - if printf '%s' "$APPLE_PROV_PROFILE" | base64 --decode >/dev/null 2>&1; then - printf '%s' "$APPLE_PROV_PROFILE" | base64 --decode > profile.mobileprovision - else - printf '%s' "$APPLE_PROV_PROFILE" | base64 -D > profile.mobileprovision - fi - cp profile.mobileprovision ~/Library/MobileDevice/Provisioning\ Profiles/ - - name: Download macOS artifacts - if: ${{ inputs.snapshot != true && secrets.APPLE_CERT_P12 != '' }} + if: ${{ inputs.snapshot != true && steps.secrets_check.outputs.has_signing_cert == 'true' }} uses: actions/download-artifact@v4 with: name: macos-release-artifacts path: dist - - name: Sign macOS artifacts - if: ${{ inputs.snapshot != true && secrets.APPLE_CERT_P12 != '' }} + - name: Sign and notarize macOS artifacts + if: ${{ inputs.snapshot != true && steps.secrets_check.outputs.has_signing_cert == 'true' }} env: APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }} + APPLE_ID: ${{ secrets.APPLE_ID }} + APPLE_ID_PASSWORD: ${{ secrets.APPLE_ID_PASSWORD }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} KEYCHAIN_PATH: ${{ runner.temp }}/build.keychain-db run: | set -e - if [ -z "$APPLE_SIGNING_IDENTITY" ]; then - echo "::error::APPLE_SIGNING_IDENTITY is required to sign macOS binaries." - exit 1 - fi + for var_name in APPLE_SIGNING_IDENTITY APPLE_ID APPLE_ID_PASSWORD APPLE_TEAM_ID; do + if [ -z "${!var_name}" ]; then + echo "::error::$var_name is required for signing and notarization." + exit 1 + fi + done if [ ! -f dist/checksums.txt ]; then echo "::error::dist/checksums.txt not found; expected macOS artifacts from Linux job." exit 1 @@ -194,7 +183,19 @@ jobs: tar -xzf "$archive" -C "$tmpdir" while IFS= read -r -d '' bin; do - codesign --force --options runtime --timestamp --keychain "$KEYCHAIN_PATH" --sign "$APPLE_SIGNING_IDENTITY" "$bin" + codesign --force --options runtime --timestamp \ + --keychain "$KEYCHAIN_PATH" \ + --sign "$APPLE_SIGNING_IDENTITY" "$bin" + + # Notarize each binary via a zip (notarytool requires zip/dmg/pkg, not a raw binary) + zip_path="$(mktemp).zip" + zip -j "$zip_path" "$bin" + xcrun notarytool submit "$zip_path" \ + --apple-id "$APPLE_ID" \ + --password "$APPLE_ID_PASSWORD" \ + --team-id "$APPLE_TEAM_ID" \ + --wait + rm -f "$zip_path" done < <(find "$tmpdir" -type f -perm -111 -print0) tar -czf "${archive}.signed" -C "$tmpdir" . @@ -209,7 +210,7 @@ jobs: done - name: Upload signed macOS artifacts - if: ${{ inputs.snapshot != true && secrets.APPLE_CERT_P12 != '' }} + if: ${{ inputs.snapshot != true && steps.secrets_check.outputs.has_signing_cert == 'true' }} uses: actions/upload-artifact@v4 with: name: macos-signed-artifacts @@ -220,7 +221,7 @@ jobs: dist/checksums.txt - name: Replace macOS assets on GitHub release - if: ${{ startsWith(github.ref, 'refs/tags/') && inputs.snapshot != true && secrets.APPLE_CERT_P12 != '' }} + if: ${{ startsWith(github.ref, 'refs/tags/') && inputs.snapshot != true && steps.secrets_check.outputs.has_signing_cert == 'true' }} env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | @@ -232,7 +233,7 @@ jobs: --clobber - name: Publish finalized GitHub release - if: ${{ startsWith(github.ref, 'refs/tags/') && inputs.snapshot != true && secrets.APPLE_CERT_P12 != '' }} + if: ${{ startsWith(github.ref, 'refs/tags/') && inputs.snapshot != true && steps.secrets_check.outputs.has_signing_cert == 'true' }} env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | @@ -241,13 +242,9 @@ jobs: - name: Clean up keychain and cert if: always() - env: - KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }} run: | - set -e - # Delete the keychain we created (if present) KEYCHAIN_PATH="$RUNNER_TEMP/build.keychain-db" if [ -f "$KEYCHAIN_PATH" ] || security list-keychains | grep -q "$KEYCHAIN_PATH"; then security delete-keychain "$KEYCHAIN_PATH" || true fi - rm -f cert.p12 profile.mobileprovision || true + rm -f cert.p12 || true diff --git a/.gitignore b/.gitignore index ee7188a..6677c9f 100644 --- a/.gitignore +++ b/.gitignore @@ -3,4 +3,4 @@ TODO* launch.json apimetrics gcp-secret.json -.DS_Store \ No newline at end of file +.DS_Store From 7c89df32fc147e99fc05d944cbae9217e867e451 Mon Sep 17 00:00:00 2001 From: ndenny Date: Thu, 4 Jun 2026 17:11:34 -0700 Subject: [PATCH 16/65] Skip cert import during snapshot runs Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e76085d..cd6e24b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -126,7 +126,7 @@ jobs: fi - name: Import Apple certificate - if: steps.secrets_check.outputs.has_signing_cert == 'true' + if: ${{ inputs.snapshot != true && steps.secrets_check.outputs.has_signing_cert == 'true' }} env: APPLE_CERT_P12: ${{ secrets.APPLE_CERT_P12 }} APPLE_CERT_PASSWORD: ${{ secrets.APPLE_CERT_PASSWORD }} From fbd63c3dd4b37aac64e68b940b174bb3c7097b98 Mon Sep 17 00:00:00 2001 From: ndenny Date: Thu, 4 Jun 2026 17:17:10 -0700 Subject: [PATCH 17/65] Address code review: job guard, secret env var, mktemp leak - Skip release-macos job entirely when snapshot=true to avoid spinning up a macOS runner unnecessarily - Pass APPLE_CERT_P12 as an env var in secrets_check rather than interpolating the secret directly into shell - Fix mktemp resource leak in notarization loop: use mktemp -d and rm -rf the directory instead of leaking the base tempfile Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/release.yml | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index cd6e24b..97ea338 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -112,13 +112,16 @@ jobs: name: Sign and notarize macOS artifacts runs-on: macos-latest needs: release + if: ${{ inputs.snapshot != true }} timeout-minutes: 30 steps: - name: Check for Apple signing secrets id: secrets_check + env: + APPLE_CERT_P12: ${{ secrets.APPLE_CERT_P12 }} run: | - if [ -n "${{ secrets.APPLE_CERT_P12 }}" ]; then + if [ -n "$APPLE_CERT_P12" ]; then echo "has_signing_cert=true" >> "$GITHUB_OUTPUT" else echo "has_signing_cert=false" >> "$GITHUB_OUTPUT" @@ -188,14 +191,15 @@ jobs: --sign "$APPLE_SIGNING_IDENTITY" "$bin" # Notarize each binary via a zip (notarytool requires zip/dmg/pkg, not a raw binary) - zip_path="$(mktemp).zip" + zip_dir="$(mktemp -d)" + zip_path="$zip_dir/binary.zip" zip -j "$zip_path" "$bin" xcrun notarytool submit "$zip_path" \ --apple-id "$APPLE_ID" \ --password "$APPLE_ID_PASSWORD" \ --team-id "$APPLE_TEAM_ID" \ --wait - rm -f "$zip_path" + rm -rf "$zip_dir" done < <(find "$tmpdir" -type f -perm -111 -print0) tar -czf "${archive}.signed" -C "$tmpdir" . From 409c8815fd3695f949e526e941fcc5e8eac01e1c Mon Sep 17 00:00:00 2001 From: ndenny Date: Thu, 4 Jun 2026 17:23:26 -0700 Subject: [PATCH 18/65] Clean up redundant snapshot guards and input interpolation - Use SNAPSHOT env var in Validate dispatch inputs step instead of interpolating ${{ inputs.snapshot }} directly into shell - Remove inputs.snapshot != true from all step-level conditions in release-macos, since the job-level if already gates on this Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/release.yml | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 97ea338..cb7eba7 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -39,8 +39,10 @@ jobs: - name: Validate dispatch inputs if: github.event_name == 'workflow_dispatch' + env: + SNAPSHOT: ${{ inputs.snapshot }} run: | - if [[ "$GITHUB_REF" != refs/tags/* ]] && [ "${{ inputs.snapshot }}" != "true" ]; then + if [[ "$GITHUB_REF" != refs/tags/* ]] && [ "$SNAPSHOT" != "true" ]; then echo "::error::Manual dispatch from a non-tag ref requires snapshot=true to avoid creating a broken release." exit 1 fi @@ -129,7 +131,7 @@ jobs: fi - name: Import Apple certificate - if: ${{ inputs.snapshot != true && steps.secrets_check.outputs.has_signing_cert == 'true' }} + if: ${{ steps.secrets_check.outputs.has_signing_cert == 'true' }} env: APPLE_CERT_P12: ${{ secrets.APPLE_CERT_P12 }} APPLE_CERT_PASSWORD: ${{ secrets.APPLE_CERT_PASSWORD }} @@ -153,14 +155,14 @@ jobs: security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" - name: Download macOS artifacts - if: ${{ inputs.snapshot != true && steps.secrets_check.outputs.has_signing_cert == 'true' }} + if: ${{ steps.secrets_check.outputs.has_signing_cert == 'true' }} uses: actions/download-artifact@v4 with: name: macos-release-artifacts path: dist - name: Sign and notarize macOS artifacts - if: ${{ inputs.snapshot != true && steps.secrets_check.outputs.has_signing_cert == 'true' }} + if: ${{ steps.secrets_check.outputs.has_signing_cert == 'true' }} env: APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }} APPLE_ID: ${{ secrets.APPLE_ID }} @@ -214,7 +216,7 @@ jobs: done - name: Upload signed macOS artifacts - if: ${{ inputs.snapshot != true && steps.secrets_check.outputs.has_signing_cert == 'true' }} + if: ${{ steps.secrets_check.outputs.has_signing_cert == 'true' }} uses: actions/upload-artifact@v4 with: name: macos-signed-artifacts @@ -225,7 +227,7 @@ jobs: dist/checksums.txt - name: Replace macOS assets on GitHub release - if: ${{ startsWith(github.ref, 'refs/tags/') && inputs.snapshot != true && steps.secrets_check.outputs.has_signing_cert == 'true' }} + if: ${{ startsWith(github.ref, 'refs/tags/') && steps.secrets_check.outputs.has_signing_cert == 'true' }} env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | @@ -237,7 +239,7 @@ jobs: --clobber - name: Publish finalized GitHub release - if: ${{ startsWith(github.ref, 'refs/tags/') && inputs.snapshot != true && steps.secrets_check.outputs.has_signing_cert == 'true' }} + if: ${{ startsWith(github.ref, 'refs/tags/') && steps.secrets_check.outputs.has_signing_cert == 'true' }} env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | From dcb35f00f8df41ac4c5434913d1a1688af656716 Mon Sep 17 00:00:00 2001 From: ndenny Date: Thu, 4 Jun 2026 17:36:05 -0700 Subject: [PATCH 19/65] Defer GCS upload until after macOS signing (Option B) Previously goreleaser ran with --draft which created the GitHub release as a draft but still triggered the blobs publisher, uploading unsigned macOS artifacts and a mismatched checksums.txt to GCS immediately. Fix: run goreleaser with --skip publish on tag pushes so it builds all artifacts without touching GCS or GitHub. Create the GitHub draft release manually with gh after the build. Expand the artifact store upload to include all platforms. In the macOS job, after signing, upload all artifacts (Linux + Windows + signed macOS + updated checksums) to GCS before publishing the draft release. GCS is now never written until signing is complete. Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/release.yml | 56 +++++++++++++++++++++++++++-------- 1 file changed, 44 insertions(+), 12 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index cb7eba7..3b26c52 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -79,13 +79,12 @@ jobs: run: | set -e EXTRA="" - DRAFT="" if [ "$SNAPSHOT" = "true" ]; then EXTRA="--snapshot" echo "Running goreleaser in snapshot mode (no publish)" elif [[ "$GITHUB_REF" == refs/tags/* ]]; then - DRAFT="--draft" - echo "Creating draft release until macOS artifacts are signed and notarized" + EXTRA="--skip publish" + echo "Building artifacts without publishing; GitHub release and GCS upload deferred to finalization" fi docker run --rm \ -v "${PWD}:/workspace" \ @@ -93,17 +92,35 @@ jobs: -e GITHUB_TOKEN \ -e GOOGLE_APPLICATION_CREDENTIALS=/workspace/gcp-secret.json \ ghcr.io/goreleaser/goreleaser-cross:v1.25-v2.12.7 \ - release $EXTRA $DRAFT --clean --parallelism 4 + release $EXTRA --clean --parallelism 4 - - name: Upload macOS artifacts for signing + - name: Create GitHub draft release + if: ${{ startsWith(github.ref, 'refs/tags/') && inputs.snapshot != true }} + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -e + if [ -f dist/CHANGELOG.md ]; then + gh release create "$GITHUB_REF_NAME" \ + dist/*.tar.gz dist/*.zip dist/checksums.txt \ + --draft \ + --notes-file dist/CHANGELOG.md + else + gh release create "$GITHUB_REF_NAME" \ + dist/*.tar.gz dist/*.zip dist/checksums.txt \ + --draft \ + --generate-notes + fi + + - name: Upload release artifacts if: ${{ inputs.snapshot != true }} uses: actions/upload-artifact@v4 with: - name: macos-release-artifacts + name: release-artifacts if-no-files-found: error path: | - dist/*-darwin-amd64.tar.gz - dist/*-darwin-arm64.tar.gz + dist/*.tar.gz + dist/*.zip dist/checksums.txt - name: Clean up GCP credentials @@ -154,11 +171,11 @@ jobs: security set-key-partition-list -S apple-tool:,apple: -s -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" - - name: Download macOS artifacts + - name: Download release artifacts if: ${{ steps.secrets_check.outputs.has_signing_cert == 'true' }} uses: actions/download-artifact@v4 with: - name: macos-release-artifacts + name: release-artifacts path: dist - name: Sign and notarize macOS artifacts @@ -226,6 +243,21 @@ jobs: dist/*-darwin-arm64.tar.gz dist/checksums.txt + - name: Set up GCP credentials for GCS upload + if: ${{ startsWith(github.ref, 'refs/tags/') && steps.secrets_check.outputs.has_signing_cert == 'true' }} + env: + GCP_CREDENTIALS_JSON: ${{ secrets.GOOGLE_APPLICATION_CREDENTIALS }} + run: | + printf '%s' "$GCP_CREDENTIALS_JSON" > "$RUNNER_TEMP/gcp-creds.json" + echo "GOOGLE_APPLICATION_CREDENTIALS=$RUNNER_TEMP/gcp-creds.json" >> "$GITHUB_ENV" + + - name: Upload all artifacts to GCS + if: ${{ startsWith(github.ref, 'refs/tags/') && steps.secrets_check.outputs.has_signing_cert == 'true' }} + run: | + set -e + VERSION="${GITHUB_REF_NAME#v}" + gsutil -m cp dist/*.tar.gz dist/*.zip dist/checksums.txt "gs://apimetrics-cli/$VERSION/" + - name: Replace macOS assets on GitHub release if: ${{ startsWith(github.ref, 'refs/tags/') && steps.secrets_check.outputs.has_signing_cert == 'true' }} env: @@ -246,11 +278,11 @@ jobs: set -e gh release edit "$GITHUB_REF_NAME" --draft=false - - name: Clean up keychain and cert + - name: Clean up keychain, cert, and GCP credentials if: always() run: | KEYCHAIN_PATH="$RUNNER_TEMP/build.keychain-db" if [ -f "$KEYCHAIN_PATH" ] || security list-keychains | grep -q "$KEYCHAIN_PATH"; then security delete-keychain "$KEYCHAIN_PATH" || true fi - rm -f cert.p12 || true + rm -f cert.p12 "$RUNNER_TEMP/gcp-creds.json" || true From 281d9829e88f33ae61f62616037e38252f947376 Mon Sep 17 00:00:00 2001 From: ndenny Date: Thu, 4 Jun 2026 17:44:18 -0700 Subject: [PATCH 20/65] Add pre-flight check for GCP credentials on tag releases MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Without this, a missing or malformed GOOGLE_APPLICATION_CREDENTIALS secret would only surface late in the macOS job — after the draft release is created and artifacts are signed — leaving a partial release state requiring manual cleanup. The new step validates the secret is present and is valid JSON before goreleaser runs, matching the existing pattern for Apple signing secrets. Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/release.yml | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3b26c52..eebf160 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -72,6 +72,20 @@ jobs: exit 1 fi + - name: Ensure GCP credentials for tag releases + if: ${{ startsWith(github.ref, 'refs/tags/') && inputs.snapshot != true }} + env: + GCP_CREDENTIALS_JSON: ${{ secrets.GOOGLE_APPLICATION_CREDENTIALS }} + run: | + if [ -z "$GCP_CREDENTIALS_JSON" ]; then + echo "::error::GOOGLE_APPLICATION_CREDENTIALS secret not configured; refusing to publish artifacts on tag push." + exit 1 + fi + if ! printf '%s' "$GCP_CREDENTIALS_JSON" | python3 -c "import sys, json; json.load(sys.stdin)" 2>/dev/null; then + echo "::error::GOOGLE_APPLICATION_CREDENTIALS is not valid JSON; refusing to publish artifacts on tag push." + exit 1 + fi + - name: Run GoReleaser (Linux) env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} From 89affda08543d4c1345654c9713fe91aef6e3421 Mon Sep 17 00:00:00 2001 From: ndenny Date: Thu, 4 Jun 2026 18:12:02 -0700 Subject: [PATCH 21/65] DS-5672: Harden release workflow and add Homebrew tap - Switch GCP auth to Workload Identity Federation (no long-lived key stored as a secret); mounts WIF credentials into the goreleaser-cross Docker container via ACTIONS_ID_TOKEN_REQUEST_URL/TOKEN env vars - Remove duplicate go test step from workflow (goreleaser before.hooks already runs it) - Drop --parallelism 4 to 2 to match ubuntu-latest's 2 vCPUs - Add comment on v0.* tag pattern explaining it's intentional - Add brews section to .goreleaser.yml for Homebrew tap publish Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/release.yml | 63 +++++++++++++++++------------------ .goreleaser.yml | 13 ++++++++ 2 files changed, 44 insertions(+), 32 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index eebf160..ef91d40 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -3,6 +3,8 @@ name: Release on: push: tags: + # v0.* is intentional: prevents accidental production releases during + # initial development. Update to v* when ready to ship v1.0. - v0.* workflow_dispatch: inputs: @@ -15,6 +17,7 @@ on: permissions: contents: write + id-token: write # required for Workload Identity Federation jobs: release: @@ -34,9 +37,6 @@ jobs: go-version-file: go.mod cache: true - - name: Test - run: go test ./... - - name: Validate dispatch inputs if: github.event_name == 'workflow_dispatch' env: @@ -47,9 +47,6 @@ jobs: exit 1 fi - - name: Set up GCP credentials - run: echo '${{ secrets.GOOGLE_APPLICATION_CREDENTIALS }}' > gcp-secret.json - - name: Ensure mac signing secrets for tag releases if: ${{ startsWith(github.ref, 'refs/tags/') && inputs.snapshot != true }} env: @@ -72,19 +69,13 @@ jobs: exit 1 fi - - name: Ensure GCP credentials for tag releases + - name: Authenticate to Google Cloud if: ${{ startsWith(github.ref, 'refs/tags/') && inputs.snapshot != true }} - env: - GCP_CREDENTIALS_JSON: ${{ secrets.GOOGLE_APPLICATION_CREDENTIALS }} - run: | - if [ -z "$GCP_CREDENTIALS_JSON" ]; then - echo "::error::GOOGLE_APPLICATION_CREDENTIALS secret not configured; refusing to publish artifacts on tag push." - exit 1 - fi - if ! printf '%s' "$GCP_CREDENTIALS_JSON" | python3 -c "import sys, json; json.load(sys.stdin)" 2>/dev/null; then - echo "::error::GOOGLE_APPLICATION_CREDENTIALS is not valid JSON; refusing to publish artifacts on tag push." - exit 1 - fi + id: auth + uses: google-github-actions/auth@v2 + with: + workload_identity_provider: ${{ secrets.GCP_WORKLOAD_IDENTITY_PROVIDER }} + service_account: ${{ secrets.GCP_SERVICE_ACCOUNT }} - name: Run GoReleaser (Linux) env: @@ -100,13 +91,26 @@ jobs: EXTRA="--skip publish" echo "Building artifacts without publishing; GitHub release and GCS upload deferred to finalization" fi + + # Mount WIF credentials into the container when present (tag releases only). + CREDS_OPTS=() + if [ -n "$GOOGLE_APPLICATION_CREDENTIALS" ]; then + CREDS_DIR="$(dirname "$GOOGLE_APPLICATION_CREDENTIALS")" + CREDS_OPTS=( + -v "${CREDS_DIR}:${CREDS_DIR}:ro" + -e GOOGLE_APPLICATION_CREDENTIALS + -e ACTIONS_ID_TOKEN_REQUEST_URL + -e ACTIONS_ID_TOKEN_REQUEST_TOKEN + ) + fi + docker run --rm \ -v "${PWD}:/workspace" \ -w /workspace \ -e GITHUB_TOKEN \ - -e GOOGLE_APPLICATION_CREDENTIALS=/workspace/gcp-secret.json \ + "${CREDS_OPTS[@]}" \ ghcr.io/goreleaser/goreleaser-cross:v1.25-v2.12.7 \ - release $EXTRA --clean --parallelism 4 + release $EXTRA --clean --parallelism 2 - name: Create GitHub draft release if: ${{ startsWith(github.ref, 'refs/tags/') && inputs.snapshot != true }} @@ -137,10 +141,6 @@ jobs: dist/*.zip dist/checksums.txt - - name: Clean up GCP credentials - if: always() - run: rm -f gcp-secret.json - release-macos: name: Sign and notarize macOS artifacts runs-on: macos-latest @@ -257,13 +257,12 @@ jobs: dist/*-darwin-arm64.tar.gz dist/checksums.txt - - name: Set up GCP credentials for GCS upload + - name: Authenticate to Google Cloud if: ${{ startsWith(github.ref, 'refs/tags/') && steps.secrets_check.outputs.has_signing_cert == 'true' }} - env: - GCP_CREDENTIALS_JSON: ${{ secrets.GOOGLE_APPLICATION_CREDENTIALS }} - run: | - printf '%s' "$GCP_CREDENTIALS_JSON" > "$RUNNER_TEMP/gcp-creds.json" - echo "GOOGLE_APPLICATION_CREDENTIALS=$RUNNER_TEMP/gcp-creds.json" >> "$GITHUB_ENV" + uses: google-github-actions/auth@v2 + with: + workload_identity_provider: ${{ secrets.GCP_WORKLOAD_IDENTITY_PROVIDER }} + service_account: ${{ secrets.GCP_SERVICE_ACCOUNT }} - name: Upload all artifacts to GCS if: ${{ startsWith(github.ref, 'refs/tags/') && steps.secrets_check.outputs.has_signing_cert == 'true' }} @@ -292,11 +291,11 @@ jobs: set -e gh release edit "$GITHUB_REF_NAME" --draft=false - - name: Clean up keychain, cert, and GCP credentials + - name: Clean up keychain and cert if: always() run: | KEYCHAIN_PATH="$RUNNER_TEMP/build.keychain-db" if [ -f "$KEYCHAIN_PATH" ] || security list-keychains | grep -q "$KEYCHAIN_PATH"; then security delete-keychain "$KEYCHAIN_PATH" || true fi - rm -f cert.p12 "$RUNNER_TEMP/gcp-creds.json" || true + rm -f cert.p12 || true diff --git a/.goreleaser.yml b/.goreleaser.yml index 0f672f9..bf32567 100644 --- a/.goreleaser.yml +++ b/.goreleaser.yml @@ -64,6 +64,19 @@ blobs: bucket: apimetrics-cli directory: "{{ .Version }}" +brews: + - name: apimetrics + repository: + owner: APImetrics + name: homebrew-tap + directory: Formula + homepage: "https://apimetrics.io" + description: "APImetrics CLI" + license: "MIT" + install: bin.install "apimetrics" + test: | + system "#{bin}/apimetrics", "--version" + archives: - name_template: "{{ .ProjectName }}-{{ .Version }}-{{ .Os }}-{{ .Arch }}" format_overrides: From 4b2300ec800dd6e4e53d98e8d24fd44d3d40ec7e Mon Sep 17 00:00:00 2001 From: ndenny Date: Thu, 4 Jun 2026 18:23:15 -0700 Subject: [PATCH 22/65] =?UTF-8?q?DS-5672:=20Fix=20Homebrew=20publish=20?= =?UTF-8?q?=E2=80=94=20token=20and=20execution=20path?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two issues prevented the Homebrew tap from ever being updated: 1. GoReleaser ran with --skip publish on every tag push, so the brews publisher never executed. Fix: add a goreleaser publish step in the macOS job after the GitHub release is public, skipping only the publishers handled manually (blobs, release, announce). 2. GITHUB_TOKEN cannot push to APImetrics/homebrew-tap (different repo). Fix: use HOMEBREW_TAP_GITHUB_TOKEN (a PAT/App token with write access to the tap) via brews.repository.token in .goreleaser.yml. Also: upload artifacts.json + metadata.json from the Linux build so the macOS job has the goreleaser dist metadata needed to run goreleaser publish. Update artifacts.json checksums in the signing loop so goreleaser publish uses the signed (not original) macOS archive hashes. Add repo checkout to release-macos so goreleaser-action can find .goreleaser.yml. Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/release.yml | 27 +++++++++++++++++++++++++++ .goreleaser.yml | 1 + 2 files changed, 28 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ef91d40..701e6e3 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -140,6 +140,8 @@ jobs: dist/*.tar.gz dist/*.zip dist/checksums.txt + dist/artifacts.json + dist/metadata.json release-macos: name: Sign and notarize macOS artifacts @@ -149,6 +151,9 @@ jobs: timeout-minutes: 30 steps: + - name: Check out repository + uses: actions/checkout@v5 + - name: Check for Apple signing secrets id: secrets_check env: @@ -244,6 +249,17 @@ jobs: grep -v " $filename\$" dist/checksums.txt > dist/checksums.new || true printf "%s %s\n" "$sha" "$filename" >> dist/checksums.new mv dist/checksums.new dist/checksums.txt + + # Keep artifacts.json in sync so goreleaser publish uses signed checksums. + python3 -c " +import json, sys +data = json.load(open('dist/artifacts.json')) +for a in data: + if a.get('name') == sys.argv[1]: + a.setdefault('extra', {})['Checksum'] = 'sha256:' + sys.argv[2] +with open('dist/artifacts.json', 'w') as f: + json.dump(data, f, indent=2) +" "$filename" "$sha" done - name: Upload signed macOS artifacts @@ -291,6 +307,17 @@ jobs: set -e gh release edit "$GITHUB_REF_NAME" --draft=false + - name: Publish Homebrew formula + if: ${{ startsWith(github.ref, 'refs/tags/') && steps.secrets_check.outputs.has_signing_cert == 'true' }} + uses: goreleaser/goreleaser-action@v6 + with: + distribution: goreleaser + version: '~> v2' + args: publish --skip blobs,release,announce,validate + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + HOMEBREW_TAP_GITHUB_TOKEN: ${{ secrets.HOMEBREW_TAP_GITHUB_TOKEN }} + - name: Clean up keychain and cert if: always() run: | diff --git a/.goreleaser.yml b/.goreleaser.yml index bf32567..6c5e153 100644 --- a/.goreleaser.yml +++ b/.goreleaser.yml @@ -69,6 +69,7 @@ brews: repository: owner: APImetrics name: homebrew-tap + token: "{{ .Env.HOMEBREW_TAP_GITHUB_TOKEN }}" directory: Formula homepage: "https://apimetrics.io" description: "APImetrics CLI" From 3a8c63d6b56befd074c0c74b85ed1871c5c47f7e Mon Sep 17 00:00:00 2001 From: ndenny Date: Thu, 4 Jun 2026 19:43:58 -0700 Subject: [PATCH 23/65] DS-5672: Replace HOMEBREW_TAP_GITHUB_TOKEN PAT with GitHub App token Uses actions/create-github-app-token@v1 to generate a short-lived installation token at release time instead of storing a long-lived PAT. The App is installed only on APImetrics/homebrew-tap with Contents:write, so the blast radius is minimal and no personal account is involved. Required secrets: GH_APP_ID, GH_APP_PRIVATE_KEY (replace HOMEBREW_TAP_GITHUB_TOKEN). Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/release.yml | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 701e6e3..66327d1 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -307,6 +307,15 @@ with open('dist/artifacts.json', 'w') as f: set -e gh release edit "$GITHUB_REF_NAME" --draft=false + - name: Generate Homebrew tap token + id: tap-token + if: ${{ startsWith(github.ref, 'refs/tags/') && steps.secrets_check.outputs.has_signing_cert == 'true' }} + uses: actions/create-github-app-token@v1 + with: + app-id: ${{ secrets.GH_APP_ID }} + private-key: ${{ secrets.GH_APP_PRIVATE_KEY }} + repositories: homebrew-tap + - name: Publish Homebrew formula if: ${{ startsWith(github.ref, 'refs/tags/') && steps.secrets_check.outputs.has_signing_cert == 'true' }} uses: goreleaser/goreleaser-action@v6 @@ -316,7 +325,7 @@ with open('dist/artifacts.json', 'w') as f: args: publish --skip blobs,release,announce,validate env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - HOMEBREW_TAP_GITHUB_TOKEN: ${{ secrets.HOMEBREW_TAP_GITHUB_TOKEN }} + HOMEBREW_TAP_GITHUB_TOKEN: ${{ steps.tap-token.outputs.token }} - name: Clean up keychain and cert if: always() From 1c2c631e9b5ff8694104003b81997459e49be33a Mon Sep 17 00:00:00 2001 From: ndenny Date: Thu, 4 Jun 2026 19:46:13 -0700 Subject: [PATCH 24/65] DS-5672: Add RELEASING.md with infrastructure setup guide Documents all required one-time configuration: GCP Workload Identity Federation setup (with gcloud commands), GitHub App creation for Homebrew tap access, Apple signing certificate setup, and a complete secrets reference table. Also covers how to cut a release and run snapshot builds. Co-Authored-By: Claude Sonnet 4.6 --- RELEASING.md | 190 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 190 insertions(+) create mode 100644 RELEASING.md diff --git a/RELEASING.md b/RELEASING.md new file mode 100644 index 0000000..51717d9 --- /dev/null +++ b/RELEASING.md @@ -0,0 +1,190 @@ +# Release Process + +This document covers the one-time infrastructure setup required to run the release workflow, and how to cut a release once everything is configured. + +## How releases work + +Pushing a `v0.x.y` tag triggers the release workflow (`.github/workflows/release.yml`), which: + +1. **Linux job** — cross-compiles all platform binaries inside `goreleaser-cross` (Docker), builds archives, and creates a GitHub draft release. +2. **macOS job** — signs and notarizes the Darwin binaries using an Apple Developer certificate, re-archives them, uploads all artifacts to GCS, replaces the macOS assets on the draft release, publishes the release, then updates the Homebrew tap formula. + +Snapshot builds (no publish) can be triggered via `workflow_dispatch` with `snapshot: true`. + +--- + +## One-time infrastructure setup + +### 1. Google Cloud — Workload Identity Federation + +The workflow authenticates to GCP without a stored service account key using Workload Identity Federation (WIF). Set this up once per GCP project. + +```bash +PROJECT_ID=your-gcp-project +POOL_NAME=github-actions +PROVIDER_NAME=github +SA_NAME=apimetrics-cli-release +BUCKET=apimetrics-cli +REPO=APImetrics/APImetrics-cli + +# Create the workload identity pool +gcloud iam workload-identity-pools create "$POOL_NAME" \ + --project="$PROJECT_ID" \ + --location=global \ + --display-name="GitHub Actions" + +# Create the OIDC provider +gcloud iam workload-identity-pools providers create-oidc "$PROVIDER_NAME" \ + --project="$PROJECT_ID" \ + --location=global \ + --workload-identity-pool="$POOL_NAME" \ + --display-name="GitHub" \ + --attribute-mapping="google.subject=assertion.sub,attribute.repository=assertion.repository" \ + --issuer-uri="https://token.actions.githubusercontent.com" + +# Create the service account +gcloud iam service-accounts create "$SA_NAME" \ + --project="$PROJECT_ID" \ + --display-name="APImetrics CLI release" + +# Grant it write access to the GCS bucket +gsutil iam ch "serviceAccount:${SA_NAME}@${PROJECT_ID}.iam.gserviceaccount.com:roles/storage.objectAdmin" \ + "gs://$BUCKET" + +# Allow the GitHub repo to impersonate the service account +POOL_ID=$(gcloud iam workload-identity-pools describe "$POOL_NAME" \ + --project="$PROJECT_ID" --location=global --format="value(name)") + +gcloud iam service-accounts add-iam-policy-binding \ + "${SA_NAME}@${PROJECT_ID}.iam.gserviceaccount.com" \ + --project="$PROJECT_ID" \ + --role=roles/iam.workloadIdentityUser \ + --member="principalSet://iam.googleapis.com/${POOL_ID}/attribute.repository/${REPO}" +``` + +Then retrieve the values to store as secrets: + +```bash +# Workload identity provider (store as GCP_WORKLOAD_IDENTITY_PROVIDER) +gcloud iam workload-identity-pools providers describe "$PROVIDER_NAME" \ + --project="$PROJECT_ID" \ + --location=global \ + --workload-identity-pool="$POOL_NAME" \ + --format="value(name)" + +# Service account email (store as GCP_SERVICE_ACCOUNT) +echo "${SA_NAME}@${PROJECT_ID}.iam.gserviceaccount.com" +``` + +**Secrets to add to `APImetrics/APImetrics-cli`:** + +| Secret | Value | +|--------|-------| +| `GCP_WORKLOAD_IDENTITY_PROVIDER` | Full provider resource name (`projects/.../providers/github`) | +| `GCP_SERVICE_ACCOUNT` | Service account email (`apimetrics-cli-release@….iam.gserviceaccount.com`) | + +--- + +### 2. GitHub App — Homebrew tap access + +The workflow uses a GitHub App to push formula updates to `APImetrics/homebrew-tap`. This avoids a long-lived personal access token. + +1. Go to **GitHub → APImetrics org settings → Developer settings → GitHub Apps → New GitHub App**. +2. Name it something like `apimetrics-release-bot`. +3. Under **Permissions → Repository permissions**, set **Contents** to `Read & write`. No other permissions needed. +4. Uncheck **Webhooks** (not needed). +5. Set **Where can this GitHub App be installed?** to `Only on this account`. +6. Create the app and note the **App ID**. +7. Under **Private keys**, generate and download a private key (`.pem` file). +8. Go to **Install App** and install it on `APImetrics/homebrew-tap` only. + +**Secrets to add to `APImetrics/APImetrics-cli`:** + +| Secret | Value | +|--------|-------| +| `GH_APP_ID` | Numeric App ID shown on the app settings page | +| `GH_APP_PRIVATE_KEY` | Full contents of the downloaded `.pem` file | + +--- + +### 3. Apple code signing and notarization + +macOS binaries must be signed and notarized with an Apple Developer ID certificate so they run without Gatekeeper warnings. + +**Prerequisites:** +- An [Apple Developer Program](https://developer.apple.com/programs/) membership. +- A **Developer ID Application** certificate. Export it as a `.p12` file from Keychain Access (include the private key, set a strong export password). +- An [app-specific password](https://support.apple.com/en-us/102654) for the Apple ID used to notarize. + +**Encode the certificate:** + +```bash +base64 -i certificate.p12 | pbcopy # copies base64 to clipboard +``` + +**Secrets to add to `APImetrics/APImetrics-cli`:** + +| Secret | Value | +|--------|-------| +| `APPLE_CERT_P12` | Base64-encoded `.p12` certificate (see above) | +| `APPLE_CERT_PASSWORD` | Password set when exporting the `.p12` | +| `KEYCHAIN_PASSWORD` | Any strong random string (used for the temporary CI keychain) | +| `APPLE_SIGNING_IDENTITY` | Certificate common name, e.g. `Developer ID Application: APImetrics Inc (XXXXXXXXXX)` | +| `APPLE_ID` | Apple ID email used for notarization | +| `APPLE_ID_PASSWORD` | App-specific password for that Apple ID | +| `APPLE_TEAM_ID` | 10-character Apple Developer Team ID | + +--- + +### 4. Homebrew tap repository + +The Homebrew formula is pushed to `APImetrics/homebrew-tap`. Ensure: + +- The repository exists. +- A `Formula/` directory exists in the repository root (create it with a `.gitkeep` if needed — GoReleaser will create `Formula/apimetrics.rb` on first release). +- The GitHub App from step 2 is installed on this repository. + +--- + +## Complete secrets reference + +All secrets required on `APImetrics/APImetrics-cli`: + +| Secret | Purpose | +|--------|---------| +| `GCP_WORKLOAD_IDENTITY_PROVIDER` | WIF provider for GCS uploads | +| `GCP_SERVICE_ACCOUNT` | GCP service account email for GCS uploads | +| `GH_APP_ID` | GitHub App ID for Homebrew tap access | +| `GH_APP_PRIVATE_KEY` | GitHub App private key for Homebrew tap access | +| `APPLE_CERT_P12` | Base64 Apple Developer ID certificate | +| `APPLE_CERT_PASSWORD` | Password for the p12 certificate | +| `KEYCHAIN_PASSWORD` | Temporary CI keychain password | +| `APPLE_SIGNING_IDENTITY` | Apple signing identity string | +| `APPLE_ID` | Apple ID for notarization | +| `APPLE_ID_PASSWORD` | App-specific password for notarization | +| `APPLE_TEAM_ID` | Apple Developer Team ID | + +--- + +## Cutting a release + +Once all secrets are configured: + +```bash +git tag v0.x.y +git push origin v0.x.y +``` + +Monitor progress in the [Actions tab](https://github.com/APImetrics/APImetrics-cli/actions). The full pipeline (Linux build + macOS sign/notarize) takes approximately 20–30 minutes. + +### Snapshot builds (no publish) + +To build all artifacts without publishing anywhere: + +1. Go to **Actions → Release → Run workflow**. +2. Check **Run goreleaser in --snapshot mode**. +3. Run from any branch. + +### Tag pattern note + +The workflow only triggers on `v0.*` tags. This is intentional during initial development to prevent accidental production releases. Update the tag pattern in `.github/workflows/release.yml` to `v*` when ready to ship v1.0. From 82c6db6b15f9bbce555298c17855e2a20fde141a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 5 Jun 2026 03:38:24 +0000 Subject: [PATCH 25/65] DS-5672: Fix goreleaser command in Publish Homebrew formula step --- .github/workflows/release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 66327d1..476798c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -322,7 +322,7 @@ with open('dist/artifacts.json', 'w') as f: with: distribution: goreleaser version: '~> v2' - args: publish --skip blobs,release,announce,validate + args: release --skip build,archive,sign,checksum,sbom,announce,validate env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} HOMEBREW_TAP_GITHUB_TOKEN: ${{ steps.tap-token.outputs.token }} From 8cd9b3152fa3cfe575ff97ee8433034a64e3158d Mon Sep 17 00:00:00 2001 From: ndenny Date: Fri, 5 Jun 2026 08:03:05 -0700 Subject: [PATCH 26/65] DS-5672: Fix goreleaser skip flags, add setup-gcloud, clarify PEM secret - Fix invalid --skip values in Publish Homebrew formula step: remove 'build' and 'checksum' (not valid in v2.12.7), replace with correct values. In v2, '--skip archive' encompasses the build step; '--skip publish' covers both the GitHub release and blob storage publishers. Final skip list: before,archive,sign,sbom,publish,announce,validate,notarize - Add google-github-actions/setup-gcloud@v2 step after WIF auth in release-macos to explicitly install gsutil rather than relying on runner image pre-installation - RELEASING.md: note that GH_APP_PRIVATE_KEY must include PEM header and footer lines Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/release.yml | 6 +++++- RELEASING.md | 2 +- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 476798c..700ab7a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -280,6 +280,10 @@ with open('dist/artifacts.json', 'w') as f: workload_identity_provider: ${{ secrets.GCP_WORKLOAD_IDENTITY_PROVIDER }} service_account: ${{ secrets.GCP_SERVICE_ACCOUNT }} + - name: Set up Cloud SDK + if: ${{ startsWith(github.ref, 'refs/tags/') && steps.secrets_check.outputs.has_signing_cert == 'true' }} + uses: google-github-actions/setup-gcloud@v2 + - name: Upload all artifacts to GCS if: ${{ startsWith(github.ref, 'refs/tags/') && steps.secrets_check.outputs.has_signing_cert == 'true' }} run: | @@ -322,7 +326,7 @@ with open('dist/artifacts.json', 'w') as f: with: distribution: goreleaser version: '~> v2' - args: release --skip build,archive,sign,checksum,sbom,announce,validate + args: release --skip before,archive,sign,sbom,publish,announce,validate,notarize env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} HOMEBREW_TAP_GITHUB_TOKEN: ${{ steps.tap-token.outputs.token }} diff --git a/RELEASING.md b/RELEASING.md index 51717d9..0d9d25f 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -103,7 +103,7 @@ The workflow uses a GitHub App to push formula updates to `APImetrics/homebrew-t | Secret | Value | |--------|-------| | `GH_APP_ID` | Numeric App ID shown on the app settings page | -| `GH_APP_PRIVATE_KEY` | Full contents of the downloaded `.pem` file | +| `GH_APP_PRIVATE_KEY` | Full contents of the downloaded `.pem` file, including the `-----BEGIN RSA PRIVATE KEY-----` and `-----END RSA PRIVATE KEY-----` header/footer lines | --- From a10d44a8311d7f7638b7e9b9ee0267ae0179e4a8 Mon Sep 17 00:00:00 2001 From: ndenny Date: Fri, 5 Jun 2026 09:51:18 -0700 Subject: [PATCH 27/65] DS-5672: Fix YAML syntax error in artifacts.json patch step python3 -c "..." with a multi-line string placed Python code at zero indentation inside the run: | literal block, which caused the YAML parser to treat those lines as outside the block and fail on line 255. Fix: switch to a heredoc (python3 << 'PYEOF') so Python content stays at the run block's indentation level (10 spaces), which YAML strips before passing to the shell, leaving Python with correct zero-indent top-level code. Variables passed via env vars (PATCH_NAME/PATCH_SHA) since heredocs don't support sys.argv. Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/release.yml | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 700ab7a..9d0453a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -251,15 +251,17 @@ jobs: mv dist/checksums.new dist/checksums.txt # Keep artifacts.json in sync so goreleaser publish uses signed checksums. - python3 -c " -import json, sys -data = json.load(open('dist/artifacts.json')) -for a in data: - if a.get('name') == sys.argv[1]: - a.setdefault('extra', {})['Checksum'] = 'sha256:' + sys.argv[2] -with open('dist/artifacts.json', 'w') as f: - json.dump(data, f, indent=2) -" "$filename" "$sha" + PATCH_NAME="$filename" PATCH_SHA="$sha" python3 << 'PYEOF' + import json, os + data = json.load(open('dist/artifacts.json')) + name = os.environ['PATCH_NAME'] + checksum = os.environ['PATCH_SHA'] + for a in data: + if a.get('name') == name: + a.setdefault('extra', {})['Checksum'] = 'sha256:' + checksum + with open('dist/artifacts.json', 'w') as f: + json.dump(data, f, indent=2) + PYEOF done - name: Upload signed macOS artifacts From 6926703391b82bc236e0da73adfaea4294c96470 Mon Sep 17 00:00:00 2001 From: ndenny Date: Fri, 5 Jun 2026 10:20:46 -0700 Subject: [PATCH 28/65] DS-5672: Remove unused WIF auth from Linux job; make release creation idempotent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove the Authenticate to Google Cloud step from the Linux job — goreleaser always runs with --skip publish there so GCS uploads never happen from that job; the WIF credential was never used. - Remove the CREDS_OPTS Docker mount logic that accompanied it. - Make Create GitHub draft release idempotent: if the release already exists (workflow rerun after a partial failure), re-upload artifacts with --clobber instead of failing on a duplicate create. Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/release.yml | 28 ++++++---------------------- 1 file changed, 6 insertions(+), 22 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9d0453a..f74dbf9 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -69,14 +69,6 @@ jobs: exit 1 fi - - name: Authenticate to Google Cloud - if: ${{ startsWith(github.ref, 'refs/tags/') && inputs.snapshot != true }} - id: auth - uses: google-github-actions/auth@v2 - with: - workload_identity_provider: ${{ secrets.GCP_WORKLOAD_IDENTITY_PROVIDER }} - service_account: ${{ secrets.GCP_SERVICE_ACCOUNT }} - - name: Run GoReleaser (Linux) env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -92,23 +84,10 @@ jobs: echo "Building artifacts without publishing; GitHub release and GCS upload deferred to finalization" fi - # Mount WIF credentials into the container when present (tag releases only). - CREDS_OPTS=() - if [ -n "$GOOGLE_APPLICATION_CREDENTIALS" ]; then - CREDS_DIR="$(dirname "$GOOGLE_APPLICATION_CREDENTIALS")" - CREDS_OPTS=( - -v "${CREDS_DIR}:${CREDS_DIR}:ro" - -e GOOGLE_APPLICATION_CREDENTIALS - -e ACTIONS_ID_TOKEN_REQUEST_URL - -e ACTIONS_ID_TOKEN_REQUEST_TOKEN - ) - fi - docker run --rm \ -v "${PWD}:/workspace" \ -w /workspace \ -e GITHUB_TOKEN \ - "${CREDS_OPTS[@]}" \ ghcr.io/goreleaser/goreleaser-cross:v1.25-v2.12.7 \ release $EXTRA --clean --parallelism 2 @@ -118,7 +97,12 @@ jobs: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | set -e - if [ -f dist/CHANGELOG.md ]; then + if gh release view "$GITHUB_REF_NAME" &>/dev/null; then + echo "Draft release $GITHUB_REF_NAME already exists (rerun); re-uploading artifacts." + gh release upload "$GITHUB_REF_NAME" \ + dist/*.tar.gz dist/*.zip dist/checksums.txt \ + --clobber + elif [ -f dist/CHANGELOG.md ]; then gh release create "$GITHUB_REF_NAME" \ dist/*.tar.gz dist/*.zip dist/checksums.txt \ --draft \ From 13c0ae245ffc39b7276313a487db6d52afda0eb1 Mon Sep 17 00:00:00 2001 From: ndenny Date: Fri, 5 Jun 2026 10:49:59 -0700 Subject: [PATCH 29/65] DS-5672: Fix Homebrew publish; remove dead goreleaser blobs/brews config - goreleaser v2.12.7 has no --skip build target; the goreleaser-action step on the macOS runner would attempt to cross-compile Linux/Windows targets using compilers unavailable outside the goreleaser-cross Docker image, causing a guaranteed failure. Replace the goreleaser Homebrew step with a direct shell script that reads checksums from dist/checksums.txt and git-pushes the generated formula to the tap repo using the App token. - --skip publish does not cover the homebrew pipe (they are separate skip targets). Add homebrew to the Linux job's --skip list so goreleaser never attempts Homebrew publishing without the required HOMEBREW_TAP_GITHUB_TOKEN. - Remove blobs and brews sections from .goreleaser.yml: blobs was dead (goreleaser's publish step is always skipped; GCS uploads happen via gsutil) and brews is superseded by the direct script. Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/release.yml | 66 +++++++++++++++++++++++++++++++---- .goreleaser.yml | 19 ---------- 2 files changed, 59 insertions(+), 26 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f74dbf9..095f652 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -80,7 +80,7 @@ jobs: EXTRA="--snapshot" echo "Running goreleaser in snapshot mode (no publish)" elif [[ "$GITHUB_REF" == refs/tags/* ]]; then - EXTRA="--skip publish" + EXTRA="--skip publish,homebrew" echo "Building artifacts without publishing; GitHub release and GCS upload deferred to finalization" fi @@ -308,14 +308,66 @@ jobs: - name: Publish Homebrew formula if: ${{ startsWith(github.ref, 'refs/tags/') && steps.secrets_check.outputs.has_signing_cert == 'true' }} - uses: goreleaser/goreleaser-action@v6 - with: - distribution: goreleaser - version: '~> v2' - args: release --skip before,archive,sign,sbom,publish,announce,validate,notarize env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} HOMEBREW_TAP_GITHUB_TOKEN: ${{ steps.tap-token.outputs.token }} + run: | + set -e + VERSION="${GITHUB_REF_NAME#v}" + BASE_URL="https://github.com/APImetrics/APImetrics-cli/releases/download/${GITHUB_REF_NAME}" + AMD64_FILE="apimetrics-${VERSION}-darwin-amd64.tar.gz" + ARM64_FILE="apimetrics-${VERSION}-darwin-arm64.tar.gz" + + AMD64_SHA=$(awk -v f="${AMD64_FILE}" '$2 == f {print $1}' dist/checksums.txt) + ARM64_SHA=$(awk -v f="${ARM64_FILE}" '$2 == f {print $1}' dist/checksums.txt) + + if [ -z "$AMD64_SHA" ] || [ -z "$ARM64_SHA" ]; then + echo "::error::Could not find darwin checksums in dist/checksums.txt" + exit 1 + fi + + git clone "https://x-access-token:${HOMEBREW_TAP_GITHUB_TOKEN}@github.com/APImetrics/homebrew-tap.git" tap-repo + + cat > tap-repo/Formula/apimetrics.rb << FORMULA_END + # typed: false + # frozen_string_literal: true + + class Apimetrics < Formula + desc "APImetrics CLI" + homepage "https://apimetrics.io" + version "${VERSION}" + license "MIT" + + on_macos do + on_intel do + url "${BASE_URL}/${AMD64_FILE}" + sha256 "${AMD64_SHA}" + end + on_arm do + url "${BASE_URL}/${ARM64_FILE}" + sha256 "${ARM64_SHA}" + end + end + + def install + bin.install "apimetrics" + end + + test do + system "#{bin}/apimetrics", "--version" + end + end + FORMULA_END + + cd tap-repo + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git add Formula/apimetrics.rb + if git diff --cached --quiet; then + echo "Homebrew formula is already up to date." + else + git commit -m "apimetrics ${VERSION}" + git push + fi - name: Clean up keychain and cert if: always() diff --git a/.goreleaser.yml b/.goreleaser.yml index 6c5e153..21b5759 100644 --- a/.goreleaser.yml +++ b/.goreleaser.yml @@ -59,25 +59,6 @@ builds: - CC=x86_64-w64-mingw32-gcc - CXX=x86_64-w64-mingw32-g++ -blobs: - - provider: gs - bucket: apimetrics-cli - directory: "{{ .Version }}" - -brews: - - name: apimetrics - repository: - owner: APImetrics - name: homebrew-tap - token: "{{ .Env.HOMEBREW_TAP_GITHUB_TOKEN }}" - directory: Formula - homepage: "https://apimetrics.io" - description: "APImetrics CLI" - license: "MIT" - install: bin.install "apimetrics" - test: | - system "#{bin}/apimetrics", "--version" - archives: - name_template: "{{ .ProjectName }}-{{ .Version }}-{{ .Os }}-{{ .Arch }}" format_overrides: From 5abf68a8d781a21269fceb8944883ca7a53ac914 Mon Sep 17 00:00:00 2001 From: ndenny Date: Fri, 5 Jun 2026 10:55:24 -0700 Subject: [PATCH 30/65] DS-5672: Fix stale comments and add defensive homebrew skip for snapshot - Update artifacts.json patch comment: goreleaser publish is never called; the patch is for archival accuracy, not publish pipeline use. - Add --skip homebrew to the snapshot path on the Linux job; brews config is currently absent from .goreleaser.yml but this prevents a silent failure if it is ever re-added without updating the workflow. - Fix stale GoReleaser reference in RELEASING.md Homebrew tap section. Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/release.yml | 4 ++-- RELEASING.md | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 095f652..871044c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -77,7 +77,7 @@ jobs: set -e EXTRA="" if [ "$SNAPSHOT" = "true" ]; then - EXTRA="--snapshot" + EXTRA="--snapshot --skip homebrew" echo "Running goreleaser in snapshot mode (no publish)" elif [[ "$GITHUB_REF" == refs/tags/* ]]; then EXTRA="--skip publish,homebrew" @@ -234,7 +234,7 @@ jobs: printf "%s %s\n" "$sha" "$filename" >> dist/checksums.new mv dist/checksums.new dist/checksums.txt - # Keep artifacts.json in sync so goreleaser publish uses signed checksums. + # Keep artifacts.json in sync with the signed checksums for archival accuracy. PATCH_NAME="$filename" PATCH_SHA="$sha" python3 << 'PYEOF' import json, os data = json.load(open('dist/artifacts.json')) diff --git a/RELEASING.md b/RELEASING.md index 0d9d25f..3dbf398 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -141,7 +141,7 @@ base64 -i certificate.p12 | pbcopy # copies base64 to clipboard The Homebrew formula is pushed to `APImetrics/homebrew-tap`. Ensure: - The repository exists. -- A `Formula/` directory exists in the repository root (create it with a `.gitkeep` if needed — GoReleaser will create `Formula/apimetrics.rb` on first release). +- A `Formula/` directory exists in the repository root (create it with a `.gitkeep` if needed — the release workflow will create `Formula/apimetrics.rb` on first release). - The GitHub App from step 2 is installed on this repository. --- From 191d8def06dfaafdc1f2e1c6e369c88c3857d2a6 Mon Sep 17 00:00:00 2001 From: ndenny Date: Fri, 5 Jun 2026 11:04:53 -0700 Subject: [PATCH 31/65] DS-5672: Address code review issues in macOS signing step - Add `security list-keychains -s` after keychain creation so the CI keychain is in the user search list, defensive against any codesign call that doesn't receive an explicit --keychain flag - Capture original tar member list before extract and use it when repacking, preserving GoReleaser's exact archive paths instead of introducing a ./ prefix via the `.` source shorthand - Add `codesign --verify --deep --strict` immediately after signing to surface certificate/identity failures before notarization is attempted Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/release.yml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 871044c..5d47868 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -173,6 +173,7 @@ jobs: security import cert.p12 -k "$KEYCHAIN_PATH" -P "$APPLE_CERT_PASSWORD" -T /usr/bin/codesign security set-key-partition-list -S apple-tool:,apple: -s -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" + security list-keychains -d user -s "$KEYCHAIN_PATH" $(security list-keychains -d user | tr -d '"' | xargs) - name: Download release artifacts if: ${{ steps.secrets_check.outputs.has_signing_cert == 'true' }} @@ -205,12 +206,14 @@ jobs: for archive in dist/*-darwin-*.tar.gz; do [ -f "$archive" ] || continue tmpdir="$(mktemp -d)" + orig_members=$(tar -tzf "$archive") tar -xzf "$archive" -C "$tmpdir" while IFS= read -r -d '' bin; do codesign --force --options runtime --timestamp \ --keychain "$KEYCHAIN_PATH" \ --sign "$APPLE_SIGNING_IDENTITY" "$bin" + codesign --verify --deep --strict "$bin" # Notarize each binary via a zip (notarytool requires zip/dmg/pkg, not a raw binary) zip_dir="$(mktemp -d)" @@ -224,7 +227,8 @@ jobs: rm -rf "$zip_dir" done < <(find "$tmpdir" -type f -perm -111 -print0) - tar -czf "${archive}.signed" -C "$tmpdir" . + # shellcheck disable=SC2086 + tar -czf "${archive}.signed" -C "$tmpdir" $orig_members mv "${archive}.signed" "$archive" rm -rf "$tmpdir" From 4a1137adc9c4e68c57a080b5c94b520f4a7fc14f Mon Sep 17 00:00:00 2001 From: ndenny Date: Fri, 5 Jun 2026 11:09:59 -0700 Subject: [PATCH 32/65] DS-5672: Address code review issues in macOS signing step Simplify keychain cleanup: security list-keychains wraps paths in double quotes so grep on the raw path never matches; the guard was dead logic. Drop it and suppress the error from delete-keychain directly with 2>/dev/null so the step is clean whether or not the keychain was created. Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/release.yml | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5d47868..a7d7653 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -376,8 +376,5 @@ jobs: - name: Clean up keychain and cert if: always() run: | - KEYCHAIN_PATH="$RUNNER_TEMP/build.keychain-db" - if [ -f "$KEYCHAIN_PATH" ] || security list-keychains | grep -q "$KEYCHAIN_PATH"; then - security delete-keychain "$KEYCHAIN_PATH" || true - fi - rm -f cert.p12 || true + security delete-keychain "$RUNNER_TEMP/build.keychain-db" 2>/dev/null || true + rm -f cert.p12 From be985446b29f5605b441e5b4f7dd9eeae673dc88 Mon Sep 17 00:00:00 2001 From: ndenny Date: Fri, 5 Jun 2026 11:17:32 -0700 Subject: [PATCH 33/65] DS-5672: Exclude darwin archives from Linux job GitHub release upload MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Darwin artifacts are never published to the GitHub release until after they are signed by the macOS job. Previously the Linux job uploaded all archives (including unsigned darwin) and the macOS job later replaced them — a rerun against an already-published tag could leave unsigned darwin assets on the live release if the macOS job failed mid-run. Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/release.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a7d7653..e634066 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -100,16 +100,16 @@ jobs: if gh release view "$GITHUB_REF_NAME" &>/dev/null; then echo "Draft release $GITHUB_REF_NAME already exists (rerun); re-uploading artifacts." gh release upload "$GITHUB_REF_NAME" \ - dist/*.tar.gz dist/*.zip dist/checksums.txt \ + dist/*-linux-*.tar.gz dist/*-windows-*.zip dist/checksums.txt \ --clobber elif [ -f dist/CHANGELOG.md ]; then gh release create "$GITHUB_REF_NAME" \ - dist/*.tar.gz dist/*.zip dist/checksums.txt \ + dist/*-linux-*.tar.gz dist/*-windows-*.zip dist/checksums.txt \ --draft \ --notes-file dist/CHANGELOG.md else gh release create "$GITHUB_REF_NAME" \ - dist/*.tar.gz dist/*.zip dist/checksums.txt \ + dist/*-linux-*.tar.gz dist/*-windows-*.zip dist/checksums.txt \ --draft \ --generate-notes fi From 95ab544ad5d90d8a78b0cc6459013e726f74dc07 Mon Sep 17 00:00:00 2001 From: ndenny Date: Fri, 5 Jun 2026 14:33:59 -0700 Subject: [PATCH 34/65] Update macOS signing workflow to use production workload identity secrets --- .github/workflows/release.yml | 8 ++++---- RELEASING.md | 12 ++++++------ 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e634066..5626717 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -61,12 +61,12 @@ jobs: missing=0 for var_name in APPLE_CERT_P12 APPLE_CERT_PASSWORD KEYCHAIN_PASSWORD APPLE_SIGNING_IDENTITY APPLE_ID APPLE_ID_PASSWORD APPLE_TEAM_ID; do if [ -z "${!var_name}" ]; then - echo "::error::$var_name secret not configured; refusing to publish unsigned macOS artifacts on tag push." + echo "::warning::$var_name secret not configured; macOS signing/finalization steps will be skipped." missing=1 fi done if [ "$missing" -ne 0 ]; then - exit 1 + echo "Proceeding with Linux/Windows build only." fi - name: Run GoReleaser (Linux) @@ -267,8 +267,8 @@ jobs: if: ${{ startsWith(github.ref, 'refs/tags/') && steps.secrets_check.outputs.has_signing_cert == 'true' }} uses: google-github-actions/auth@v2 with: - workload_identity_provider: ${{ secrets.GCP_WORKLOAD_IDENTITY_PROVIDER }} - service_account: ${{ secrets.GCP_SERVICE_ACCOUNT }} + workload_identity_provider: ${{ secrets.PRODUCTION_WORKLOAD_IDENTITY_PROVIDER }} + service_account: ${{ secrets.PRODUCTION_WORKLOAD_SERVICE_ACCOUNT }} - name: Set up Cloud SDK if: ${{ startsWith(github.ref, 'refs/tags/') && steps.secrets_check.outputs.has_signing_cert == 'true' }} diff --git a/RELEASING.md b/RELEASING.md index 3dbf398..72ffc81 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -65,14 +65,14 @@ gcloud iam service-accounts add-iam-policy-binding \ Then retrieve the values to store as secrets: ```bash -# Workload identity provider (store as GCP_WORKLOAD_IDENTITY_PROVIDER) +# Workload identity provider (store as PRODUCTION_WORKLOAD_IDENTITY_PROVIDER) gcloud iam workload-identity-pools providers describe "$PROVIDER_NAME" \ --project="$PROJECT_ID" \ --location=global \ --workload-identity-pool="$POOL_NAME" \ --format="value(name)" -# Service account email (store as GCP_SERVICE_ACCOUNT) +# Service account email (store as PRODUCTION_WORKLOAD_SERVICE_ACCOUNT) echo "${SA_NAME}@${PROJECT_ID}.iam.gserviceaccount.com" ``` @@ -80,8 +80,8 @@ echo "${SA_NAME}@${PROJECT_ID}.iam.gserviceaccount.com" | Secret | Value | |--------|-------| -| `GCP_WORKLOAD_IDENTITY_PROVIDER` | Full provider resource name (`projects/.../providers/github`) | -| `GCP_SERVICE_ACCOUNT` | Service account email (`apimetrics-cli-release@….iam.gserviceaccount.com`) | +| `PRODUCTION_WORKLOAD_IDENTITY_PROVIDER` | Full provider resource name (`projects/.../providers/github`) | +| `PRODUCTION_WORKLOAD_SERVICE_ACCOUNT` | Service account email (`apimetrics-cli-release@….iam.gserviceaccount.com`) | --- @@ -152,8 +152,8 @@ All secrets required on `APImetrics/APImetrics-cli`: | Secret | Purpose | |--------|---------| -| `GCP_WORKLOAD_IDENTITY_PROVIDER` | WIF provider for GCS uploads | -| `GCP_SERVICE_ACCOUNT` | GCP service account email for GCS uploads | +| `PRODUCTION_WORKLOAD_IDENTITY_PROVIDER` | WIF provider for GCS uploads | +| `PRODUCTION_WORKLOAD_SERVICE_ACCOUNT` | GCP service account email for GCS uploads | | `GH_APP_ID` | GitHub App ID for Homebrew tap access | | `GH_APP_PRIVATE_KEY` | GitHub App private key for Homebrew tap access | | `APPLE_CERT_P12` | Base64 Apple Developer ID certificate | From 07e26cfe32a5fed9af8207dad213496108dde450 Mon Sep 17 00:00:00 2001 From: ndenny Date: Fri, 5 Jun 2026 15:03:10 -0700 Subject: [PATCH 35/65] Add WinGet publishing to release workflow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After the macOS signing job publishes the GitHub release, a new release-winget job runs on Ubuntu and submits a PR to microsoft/winget-pkgs using wingetcreate (installed as a .NET global tool). The job is gated on WINGET_GITHUB_TOKEN being configured; a missing token emits a warning and skips rather than failing the build. RELEASING.md gains a new section (§5 WinGet) covering the one-time manual initial submission via `wingetcreate new`, the PAT requirements, and the WINGET_GITHUB_TOKEN secret. The secret is also added to the complete secrets reference table. Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/release.yml | 38 ++++++++++++++++++++++++++++++++++ RELEASING.md | 39 +++++++++++++++++++++++++++++++++++ 2 files changed, 77 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5626717..ae39ae3 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -378,3 +378,41 @@ jobs: run: | security delete-keychain "$RUNNER_TEMP/build.keychain-db" 2>/dev/null || true rm -f cert.p12 + + release-winget: + name: Submit WinGet package update + runs-on: ubuntu-latest + needs: release-macos + if: ${{ startsWith(github.ref, 'refs/tags/') && inputs.snapshot != true }} + timeout-minutes: 15 + + steps: + - name: Check WinGet token + id: winget_check + env: + WINGET_GITHUB_TOKEN: ${{ secrets.WINGET_GITHUB_TOKEN }} + run: | + if [ -n "$WINGET_GITHUB_TOKEN" ]; then + echo "has_token=true" >> "$GITHUB_OUTPUT" + else + echo "has_token=false" >> "$GITHUB_OUTPUT" + echo "::warning::WINGET_GITHUB_TOKEN not configured — skipping WinGet submission." + fi + + - name: Submit WinGet package update + if: ${{ steps.winget_check.outputs.has_token == 'true' }} + env: + WINGET_GITHUB_TOKEN: ${{ secrets.WINGET_GITHUB_TOKEN }} + run: | + set -e + VERSION="${GITHUB_REF_NAME#v}" + INSTALLER_URL="https://github.com/APImetrics/APImetrics-cli/releases/download/${GITHUB_REF_NAME}/apimetrics-${VERSION}-windows-amd64.zip" + + dotnet tool install --global Microsoft.WinGetCreate --version 1.12.8.0 + export PATH="$PATH:$HOME/.dotnet/tools" + + wingetcreate update APImetrics.APImetricsCLI \ + --version "$VERSION" \ + --urls "$INSTALLER_URL" \ + --submit \ + --token "$WINGET_GITHUB_TOKEN" diff --git a/RELEASING.md b/RELEASING.md index 72ffc81..c773c03 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -146,6 +146,44 @@ The Homebrew formula is pushed to `APImetrics/homebrew-tap`. Ensure: --- +### 5. WinGet package + +The workflow uses `wingetcreate` to submit a PR to [`microsoft/winget-pkgs`](https://github.com/microsoft/winget-pkgs) after each release, keeping the Windows Package Manager entry up to date. + +#### Initial package submission (first release only) + +The package must exist in `microsoft/winget-pkgs` before automated updates can run. Create the initial manifest with `wingetcreate`: + +```bash +# Install wingetcreate (.NET global tool; requires dotnet SDK) +dotnet tool install --global Microsoft.WinGetCreate --version 1.12.8.0 +export PATH="$PATH:$HOME/.dotnet/tools" + +VERSION=0.1.0 # set to the first published version +INSTALLER_URL="https://github.com/APImetrics/APImetrics-cli/releases/download/v${VERSION}/apimetrics-${VERSION}-windows-amd64.zip" + +wingetcreate new "$INSTALLER_URL" \ + --id APImetrics.APImetricsCLI \ + --version "$VERSION" \ + --name "APImetrics CLI" \ + --publisher "APImetrics" \ + --token +``` + +Review the generated manifest files, then submit the PR manually. Once merged, subsequent releases are automated. + +#### GitHub PAT for automated updates + +The `wingetcreate update --submit` command creates a PR on `microsoft/winget-pkgs` using a GitHub account you control. Create a classic PAT (or fine-grained PAT) with **`public_repo`** scope on any GitHub account. The PR will appear under that account's name. + +**Secret to add to `APImetrics/APImetrics-cli`:** + +| Secret | Value | +|--------|-------| +| `WINGET_GITHUB_TOKEN` | PAT with `public_repo` scope, used to open PRs on `microsoft/winget-pkgs` | + +--- + ## Complete secrets reference All secrets required on `APImetrics/APImetrics-cli`: @@ -163,6 +201,7 @@ All secrets required on `APImetrics/APImetrics-cli`: | `APPLE_ID` | Apple ID for notarization | | `APPLE_ID_PASSWORD` | App-specific password for notarization | | `APPLE_TEAM_ID` | Apple Developer Team ID | +| `WINGET_GITHUB_TOKEN` | PAT for submitting WinGet PRs to `microsoft/winget-pkgs` | --- From f7cd6426e622939613997856b0176b836be21b4e Mon Sep 17 00:00:00 2001 From: ndenny Date: Fri, 5 Jun 2026 15:22:22 -0700 Subject: [PATCH 36/65] DS-5672: Fix winget step idempotency and add first-run comment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Switch dotnet tool install → update so the step is idempotent if the tool is already present on the runner - Add inline comment directing to RELEASING.md § 5 for the one-time manual package submission required before automated updates can run Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/release.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ae39ae3..e7bd3df 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -405,10 +405,12 @@ jobs: WINGET_GITHUB_TOKEN: ${{ secrets.WINGET_GITHUB_TOKEN }} run: | set -e + # Requires APImetrics.APImetricsCLI to already exist in microsoft/winget-pkgs. + # See RELEASING.md § 5 for the one-time manual submission required before this runs. VERSION="${GITHUB_REF_NAME#v}" INSTALLER_URL="https://github.com/APImetrics/APImetrics-cli/releases/download/${GITHUB_REF_NAME}/apimetrics-${VERSION}-windows-amd64.zip" - dotnet tool install --global Microsoft.WinGetCreate --version 1.12.8.0 + dotnet tool update --global Microsoft.WinGetCreate --version 1.12.8.0 export PATH="$PATH:$HOME/.dotnet/tools" wingetcreate update APImetrics.APImetricsCLI \ From 4ee222af45e49879ea107f1c3039c883efa80016 Mon Sep 17 00:00:00 2001 From: Nick Denny Date: Fri, 5 Jun 2026 17:16:52 -0700 Subject: [PATCH 37/65] Update docs per Code Review Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- RELEASING.md | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/RELEASING.md b/RELEASING.md index c773c03..f0c9587 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -174,13 +174,16 @@ Review the generated manifest files, then submit the PR manually. Once merged, s #### GitHub PAT for automated updates -The `wingetcreate update --submit` command creates a PR on `microsoft/winget-pkgs` using a GitHub account you control. Create a classic PAT (or fine-grained PAT) with **`public_repo`** scope on any GitHub account. The PR will appear under that account's name. +The `wingetcreate update --submit` command creates a PR on `microsoft/winget-pkgs` using a GitHub account you control. + +- **Classic PAT:** `public_repo` scope is sufficient. +- **Fine-grained PAT:** grant access to your fork of `winget-pkgs` and enable **Contents: read/write** and **Pull requests: read/write**. **Secret to add to `APImetrics/APImetrics-cli`:** | Secret | Value | |--------|-------| -| `WINGET_GITHUB_TOKEN` | PAT with `public_repo` scope, used to open PRs on `microsoft/winget-pkgs` | +| `WINGET_GITHUB_TOKEN` | Token used by `wingetcreate` to push to your fork and open PRs on `microsoft/winget-pkgs` | --- From af9f909bcb1fb3dfdc997524780a9119458760d3 Mon Sep 17 00:00:00 2001 From: ndenny Date: Fri, 5 Jun 2026 17:20:29 -0700 Subject: [PATCH 38/65] DS-5672: Guard winget job against draft releases; clarify PAT docs - Add a 'Check release is published' step before submitting to WinGet; if the GitHub release is still a draft (e.g. Apple signing not configured), emit a warning and skip rather than failing on an inaccessible installer URL - Fix RELEASING.md to recommend only classic PATs with public_repo scope; fine-grained PATs require a pre-existing fork which creates a circular first-run dependency Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/release.yml | 16 +++++++++++++++- RELEASING.md | 4 ++-- 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e7bd3df..a2be658 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -399,8 +399,22 @@ jobs: echo "::warning::WINGET_GITHUB_TOKEN not configured — skipping WinGet submission." fi - - name: Submit WinGet package update + - name: Check release is published + id: release_check if: ${{ steps.winget_check.outputs.has_token == 'true' }} + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + is_draft=$(gh release view "$GITHUB_REF_NAME" --json isDraft --jq '.isDraft') + if [ "$is_draft" = "true" ]; then + echo "is_published=false" >> "$GITHUB_OUTPUT" + echo "::warning::GitHub release $GITHUB_REF_NAME is still a draft (Apple signing secrets may not be configured) — skipping WinGet submission." + else + echo "is_published=true" >> "$GITHUB_OUTPUT" + fi + + - name: Submit WinGet package update + if: ${{ steps.winget_check.outputs.has_token == 'true' && steps.release_check.outputs.is_published == 'true' }} env: WINGET_GITHUB_TOKEN: ${{ secrets.WINGET_GITHUB_TOKEN }} run: | diff --git a/RELEASING.md b/RELEASING.md index f0c9587..445208d 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -176,8 +176,8 @@ Review the generated manifest files, then submit the PR manually. Once merged, s The `wingetcreate update --submit` command creates a PR on `microsoft/winget-pkgs` using a GitHub account you control. -- **Classic PAT:** `public_repo` scope is sufficient. -- **Fine-grained PAT:** grant access to your fork of `winget-pkgs` and enable **Contents: read/write** and **Pull requests: read/write**. +- **Classic PAT (recommended):** `public_repo` scope is sufficient. +- **Fine-grained PAT:** grant access to your fork of `winget-pkgs` and enable **Contents: read/write** and **Pull requests: read/write**. Note that `wingetcreate` creates the fork on first run — the fork must exist before you can pre-select it when generating the token. **Secret to add to `APImetrics/APImetrics-cli`:** From 62e9a74f7643e04eeac379396a605ff69de56f24 Mon Sep 17 00:00:00 2001 From: ndenny Date: Fri, 5 Jun 2026 17:28:19 -0700 Subject: [PATCH 39/65] DS-5672: Use GCS URLs for Homebrew and WinGet artifacts GitHub release assets on private repos require authentication and cannot be used as public download URLs. Switch both the Homebrew formula and the WinGet installer URL to the public GCS bucket (storage.googleapis.com/ apimetrics-cli/{version}/), which is already populated by the macOS job. Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/release.yml | 4 ++-- RELEASING.md | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a2be658..e070c95 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -317,7 +317,7 @@ jobs: run: | set -e VERSION="${GITHUB_REF_NAME#v}" - BASE_URL="https://github.com/APImetrics/APImetrics-cli/releases/download/${GITHUB_REF_NAME}" + BASE_URL="https://storage.googleapis.com/apimetrics-cli/${VERSION}" AMD64_FILE="apimetrics-${VERSION}-darwin-amd64.tar.gz" ARM64_FILE="apimetrics-${VERSION}-darwin-arm64.tar.gz" @@ -422,7 +422,7 @@ jobs: # Requires APImetrics.APImetricsCLI to already exist in microsoft/winget-pkgs. # See RELEASING.md § 5 for the one-time manual submission required before this runs. VERSION="${GITHUB_REF_NAME#v}" - INSTALLER_URL="https://github.com/APImetrics/APImetrics-cli/releases/download/${GITHUB_REF_NAME}/apimetrics-${VERSION}-windows-amd64.zip" + INSTALLER_URL="https://storage.googleapis.com/apimetrics-cli/${VERSION}/apimetrics-${VERSION}-windows-amd64.zip" dotnet tool update --global Microsoft.WinGetCreate --version 1.12.8.0 export PATH="$PATH:$HOME/.dotnet/tools" diff --git a/RELEASING.md b/RELEASING.md index 445208d..674916c 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -160,7 +160,7 @@ dotnet tool install --global Microsoft.WinGetCreate --version 1.12.8.0 export PATH="$PATH:$HOME/.dotnet/tools" VERSION=0.1.0 # set to the first published version -INSTALLER_URL="https://github.com/APImetrics/APImetrics-cli/releases/download/v${VERSION}/apimetrics-${VERSION}-windows-amd64.zip" +INSTALLER_URL="https://storage.googleapis.com/apimetrics-cli/${VERSION}/apimetrics-${VERSION}-windows-amd64.zip" wingetcreate new "$INSTALLER_URL" \ --id APImetrics.APImetricsCLI \ From f1c0149221e138f1472b1b661e792e3bd15cacf8 Mon Sep 17 00:00:00 2001 From: ndenny Date: Fri, 5 Jun 2026 17:38:57 -0700 Subject: [PATCH 40/65] DS-5672: Upload Linux/Windows artifacts to GCS regardless of Apple signing Previously the entire GCS upload block was gated on has_signing_cert, so linux/windows artifacts never reached the public bucket when Apple secrets were not configured. Split into two steps: - Upload Linux/Windows artifacts to GCS: always runs on tag releases - Upload signed macOS artifacts to GCS: still gated on has_signing_cert Also ungate artifact download, WIF auth, and gcloud setup so they run unconditionally on tag releases (they are required infrastructure, not optional like Apple signing). Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/release.yml | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e070c95..e44bbe8 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -176,7 +176,6 @@ jobs: security list-keychains -d user -s "$KEYCHAIN_PATH" $(security list-keychains -d user | tr -d '"' | xargs) - name: Download release artifacts - if: ${{ steps.secrets_check.outputs.has_signing_cert == 'true' }} uses: actions/download-artifact@v4 with: name: release-artifacts @@ -264,22 +263,29 @@ jobs: dist/checksums.txt - name: Authenticate to Google Cloud - if: ${{ startsWith(github.ref, 'refs/tags/') && steps.secrets_check.outputs.has_signing_cert == 'true' }} + if: ${{ startsWith(github.ref, 'refs/tags/') }} uses: google-github-actions/auth@v2 with: workload_identity_provider: ${{ secrets.PRODUCTION_WORKLOAD_IDENTITY_PROVIDER }} service_account: ${{ secrets.PRODUCTION_WORKLOAD_SERVICE_ACCOUNT }} - name: Set up Cloud SDK - if: ${{ startsWith(github.ref, 'refs/tags/') && steps.secrets_check.outputs.has_signing_cert == 'true' }} + if: ${{ startsWith(github.ref, 'refs/tags/') }} uses: google-github-actions/setup-gcloud@v2 - - name: Upload all artifacts to GCS + - name: Upload Linux and Windows artifacts to GCS + if: ${{ startsWith(github.ref, 'refs/tags/') }} + run: | + set -e + VERSION="${GITHUB_REF_NAME#v}" + gsutil -m cp dist/*-linux-*.tar.gz dist/*-windows-*.zip dist/checksums.txt "gs://apimetrics-cli/$VERSION/" + + - name: Upload signed macOS artifacts to GCS if: ${{ startsWith(github.ref, 'refs/tags/') && steps.secrets_check.outputs.has_signing_cert == 'true' }} run: | set -e VERSION="${GITHUB_REF_NAME#v}" - gsutil -m cp dist/*.tar.gz dist/*.zip dist/checksums.txt "gs://apimetrics-cli/$VERSION/" + gsutil -m cp dist/*-darwin-*.tar.gz dist/checksums.txt "gs://apimetrics-cli/$VERSION/" - name: Replace macOS assets on GitHub release if: ${{ startsWith(github.ref, 'refs/tags/') && steps.secrets_check.outputs.has_signing_cert == 'true' }} From 14cdbce7c5c44149026402e58784a15f14c72ea8 Mon Sep 17 00:00:00 2001 From: ndenny Date: Fri, 5 Jun 2026 17:55:41 -0700 Subject: [PATCH 41/65] DS-5672: Fix gh release view missing --repo in winget job The release-winget job has no checkout step so gh cannot infer the repo from a local .git directory. Pass --repo explicitly via GITHUB_REPOSITORY. Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e44bbe8..099066b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -411,7 +411,7 @@ jobs: env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | - is_draft=$(gh release view "$GITHUB_REF_NAME" --json isDraft --jq '.isDraft') + is_draft=$(gh release view "$GITHUB_REF_NAME" --repo "$GITHUB_REPOSITORY" --json isDraft --jq '.isDraft') if [ "$is_draft" = "true" ]; then echo "is_published=false" >> "$GITHUB_OUTPUT" echo "::warning::GitHub release $GITHUB_REF_NAME is still a draft (Apple signing secrets may not be configured) — skipping WinGet submission." From b8e3a7045954ead48807627ae85d631066ee8020 Mon Sep 17 00:00:00 2001 From: ndenny Date: Fri, 5 Jun 2026 18:22:37 -0700 Subject: [PATCH 42/65] =?UTF-8?q?DS-5672:=20Fix=20winget=20job=20=E2=80=94?= =?UTF-8?q?=20switch=20to=20Windows=20runner,=20download=20exe=20directly?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Microsoft.WinGetCreate is not published to NuGet; the release ships only wingetcreate.exe and an MSIX bundle. The dotnet tool install/update approach was always going to fail. Switch release-winget to windows-latest and download wingetcreate.exe directly from the GitHub release. Bash check steps keep shell: bash (Git Bash available on Windows runners); submit step uses pwsh. Also fix the RELEASING.md initial setup example to use PowerShell and the direct exe download rather than the nonexistent NuGet package. Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/release.yml | 27 ++++++++++++++------------- RELEASING.md | 25 ++++++++++++------------- 2 files changed, 26 insertions(+), 26 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 099066b..ca0eb88 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -387,7 +387,7 @@ jobs: release-winget: name: Submit WinGet package update - runs-on: ubuntu-latest + runs-on: windows-latest needs: release-macos if: ${{ startsWith(github.ref, 'refs/tags/') && inputs.snapshot != true }} timeout-minutes: 15 @@ -395,6 +395,7 @@ jobs: steps: - name: Check WinGet token id: winget_check + shell: bash env: WINGET_GITHUB_TOKEN: ${{ secrets.WINGET_GITHUB_TOKEN }} run: | @@ -408,6 +409,7 @@ jobs: - name: Check release is published id: release_check if: ${{ steps.winget_check.outputs.has_token == 'true' }} + shell: bash env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | @@ -421,20 +423,19 @@ jobs: - name: Submit WinGet package update if: ${{ steps.winget_check.outputs.has_token == 'true' && steps.release_check.outputs.is_published == 'true' }} + shell: pwsh env: WINGET_GITHUB_TOKEN: ${{ secrets.WINGET_GITHUB_TOKEN }} run: | - set -e + $ErrorActionPreference = 'Stop' # Requires APImetrics.APImetricsCLI to already exist in microsoft/winget-pkgs. # See RELEASING.md § 5 for the one-time manual submission required before this runs. - VERSION="${GITHUB_REF_NAME#v}" - INSTALLER_URL="https://storage.googleapis.com/apimetrics-cli/${VERSION}/apimetrics-${VERSION}-windows-amd64.zip" - - dotnet tool update --global Microsoft.WinGetCreate --version 1.12.8.0 - export PATH="$PATH:$HOME/.dotnet/tools" - - wingetcreate update APImetrics.APImetricsCLI \ - --version "$VERSION" \ - --urls "$INSTALLER_URL" \ - --submit \ - --token "$WINGET_GITHUB_TOKEN" + $version = $env:GITHUB_REF_NAME -replace '^v', '' + $installerUrl = "https://storage.googleapis.com/apimetrics-cli/$version/apimetrics-$version-windows-amd64.zip" + + Invoke-WebRequest -Uri "https://github.com/microsoft/winget-create/releases/download/v1.12.8.0/wingetcreate.exe" -OutFile wingetcreate.exe + .\wingetcreate.exe update APImetrics.APImetricsCLI ` + --version $version ` + --urls $installerUrl ` + --submit ` + --token $env:WINGET_GITHUB_TOKEN diff --git a/RELEASING.md b/RELEASING.md index 674916c..3e7650e 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -154,19 +154,18 @@ The workflow uses `wingetcreate` to submit a PR to [`microsoft/winget-pkgs`](htt The package must exist in `microsoft/winget-pkgs` before automated updates can run. Create the initial manifest with `wingetcreate`: -```bash -# Install wingetcreate (.NET global tool; requires dotnet SDK) -dotnet tool install --global Microsoft.WinGetCreate --version 1.12.8.0 -export PATH="$PATH:$HOME/.dotnet/tools" - -VERSION=0.1.0 # set to the first published version -INSTALLER_URL="https://storage.googleapis.com/apimetrics-cli/${VERSION}/apimetrics-${VERSION}-windows-amd64.zip" - -wingetcreate new "$INSTALLER_URL" \ - --id APImetrics.APImetricsCLI \ - --version "$VERSION" \ - --name "APImetrics CLI" \ - --publisher "APImetrics" \ +```powershell +# Download wingetcreate.exe (Windows only — run this on a Windows machine) +Invoke-WebRequest -Uri "https://github.com/microsoft/winget-create/releases/download/v1.12.8.0/wingetcreate.exe" -OutFile wingetcreate.exe + +$version = "0.1.0" # set to the first published version +$installerUrl = "https://storage.googleapis.com/apimetrics-cli/$version/apimetrics-$version-windows-amd64.zip" + +.\wingetcreate.exe new $installerUrl ` + --id APImetrics.APImetricsCLI ` + --version $version ` + --name "APImetrics CLI" ` + --publisher "APImetrics" ` --token ``` From 1f56d536f7b29ea022fc1b40abce9797b129342b Mon Sep 17 00:00:00 2001 From: ndenny Date: Fri, 5 Jun 2026 18:25:34 -0700 Subject: [PATCH 43/65] DS-5672: Fix set -e in release check; defer checksums.txt to signed upload - Add set -e to 'Check release is published' step so a transient gh API failure exits rather than silently treating the release as published - Remove checksums.txt from the ungated linux/windows GCS upload; the goreleaser-generated file includes darwin entries for artifacts not yet uploaded, leaving GCS in an inconsistent state. Upload checksums.txt only after macOS signing when all artifacts are present. Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/release.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ca0eb88..57e113a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -278,7 +278,7 @@ jobs: run: | set -e VERSION="${GITHUB_REF_NAME#v}" - gsutil -m cp dist/*-linux-*.tar.gz dist/*-windows-*.zip dist/checksums.txt "gs://apimetrics-cli/$VERSION/" + gsutil -m cp dist/*-linux-*.tar.gz dist/*-windows-*.zip "gs://apimetrics-cli/$VERSION/" - name: Upload signed macOS artifacts to GCS if: ${{ startsWith(github.ref, 'refs/tags/') && steps.secrets_check.outputs.has_signing_cert == 'true' }} @@ -413,6 +413,7 @@ jobs: env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | + set -e is_draft=$(gh release view "$GITHUB_REF_NAME" --repo "$GITHUB_REPOSITORY" --json isDraft --jq '.isDraft') if [ "$is_draft" = "true" ]; then echo "is_published=false" >> "$GITHUB_OUTPUT" From d0b4850fbaa41c150edde60f3c298c0a3b9556d3 Mon Sep 17 00:00:00 2001 From: ndenny Date: Tue, 9 Jun 2026 18:57:36 -0700 Subject: [PATCH 44/65] Update winget package publisher name --- .github/workflows/release.yml | 4 ++-- RELEASING.md | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 57e113a..9d4a0d2 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -429,13 +429,13 @@ jobs: WINGET_GITHUB_TOKEN: ${{ secrets.WINGET_GITHUB_TOKEN }} run: | $ErrorActionPreference = 'Stop' - # Requires APImetrics.APImetricsCLI to already exist in microsoft/winget-pkgs. + # Requires APIContext.APImetricsCLI to already exist in microsoft/winget-pkgs. # See RELEASING.md § 5 for the one-time manual submission required before this runs. $version = $env:GITHUB_REF_NAME -replace '^v', '' $installerUrl = "https://storage.googleapis.com/apimetrics-cli/$version/apimetrics-$version-windows-amd64.zip" Invoke-WebRequest -Uri "https://github.com/microsoft/winget-create/releases/download/v1.12.8.0/wingetcreate.exe" -OutFile wingetcreate.exe - .\wingetcreate.exe update APImetrics.APImetricsCLI ` + .\wingetcreate.exe update APIContext.APImetricsCLI ` --version $version ` --urls $installerUrl ` --submit ` diff --git a/RELEASING.md b/RELEASING.md index 3e7650e..c98e693 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -162,10 +162,10 @@ $version = "0.1.0" # set to the first published version $installerUrl = "https://storage.googleapis.com/apimetrics-cli/$version/apimetrics-$version-windows-amd64.zip" .\wingetcreate.exe new $installerUrl ` - --id APImetrics.APImetricsCLI ` + --id APIContext.APImetricsCLI ` --version $version ` --name "APImetrics CLI" ` - --publisher "APImetrics" ` + --publisher "APIContext" ` --token ``` From 3f17ac725b346ff1e5e1d41f5c5bd9d1b0c32a0a Mon Sep 17 00:00:00 2001 From: Ruairidh James Date: Wed, 10 Jun 2026 14:45:51 +0100 Subject: [PATCH 45/65] DS-5707: Replace README.md with relevant usage instructions --- README.md | 238 +++++++++++++++++++++++++++++++++++++++++------------- 1 file changed, 184 insertions(+), 54 deletions(-) diff --git a/README.md b/README.md index d8995b9..5fcd4e8 100644 --- a/README.md +++ b/README.md @@ -1,54 +1,184 @@ -![Restish Logo](https://user-images.githubusercontent.com/106826/82109918-ec5b2300-96ee-11ea-9af0-8515329d5965.png) - -[![Works With Restish](https://img.shields.io/badge/Works%20With-Restish-ff5f87)](https://rest.sh/) [![User Guide](https://img.shields.io/badge/Docs-Guide-5fafd7)](https://rest.sh/#/guide) [![CI](https://github.com/rest-sh/restish/actions/workflows/ci.yaml/badge.svg?branch=main)](https://github.com/rest-sh/restish/actions/workflows/ci.yaml) [![codecov](https://codecov.io/gh/rest-sh/restish/branch/main/graph/badge.svg)](https://codecov.io/gh/rest-sh/restish) [![Docs](https://img.shields.io/badge/godoc-reference-5fafd7)](https://pkg.go.dev/github.com/rest-sh/restish?tab=subdirectories) [![Go Report Card](https://goreportcard.com/badge/github.com/rest-sh/restish)](https://goreportcard.com/report/github.com/rest-sh/restish) - -[Restish](https://rest.sh/) is a CLI for interacting with [REST](https://apisyouwonthate.com/blog/rest-and-hypermedia-in-2019)-ish HTTP APIs with some nice features built-in — like always having the latest API resources, fields, and operations available when they go live on the API without needing to install or update anything. -Check out [how Restish compares to cURL & HTTPie](https://rest.sh/#/comparison). - -See the [user guide](https://rest.sh/#/guide) for how to install Restish and get started. - -Features include: - -- HTTP/2 ([RFC 7540](https://tools.ietf.org/html/rfc7540)) with TLS by _default_ with fallback to HTTP/1.1 -- Generic head/get/post/put/patch/delete verbs like `curl` or [HTTPie](https://httpie.org/) -- Generated commands for CLI operations, e.g. `restish my-api list-users` - - Automatically discovers API descriptions - - [RFC 8631](https://tools.ietf.org/html/rfc8631) `service-desc` link relation - - [RFC 5988](https://tools.ietf.org/html/rfc5988#section-6.2.2) `describedby` link relation - - Supported formats - - OpenAPI [3.0](https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.3.md) / [3.1](https://spec.openapis.org/oas/v3.1.0.html) and [JSON Schema](https://json-schema.org/) - - Automatic configuration of API auth if advertised by the API - - Shell command completion for Bash, Fish, Zsh, Powershell -- Automatic pagination of resource collections via [RFC 5988](https://tools.ietf.org/html/rfc5988) `prev` and `next` hypermedia links -- API endpoint-based auth built-in with support for profiles: - - HTTP Basic - - API key via header or query param - - OAuth2 client credentials flow (machine-to-machine, [RFC 6749](https://tools.ietf.org/html/rfc6749)) - - OAuth2 authorization code (with PKCE [RFC 7636](https://tools.ietf.org/html/rfc7636)) flow - - On the fly authorization through external tools for custom API signature mechanisms -- Content negotiation, decoding & unmarshalling built-in: - - JSON ([RFC 8259](https://tools.ietf.org/html/rfc8259), ) - - YAML () - - CBOR ([RFC 7049](https://tools.ietf.org/html/rfc7049), ) - - MessagePack () - - Amazon Ion () - - Gzip ([RFC 1952](https://tools.ietf.org/html/rfc1952)), Deflate ([RFC 1951](https://datatracker.ietf.org/doc/html/rfc1951)), and Brotli ([RFC 7932](https://tools.ietf.org/html/rfc7932)) content encoding -- Automatic retries with support for [`Retry-After`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Retry-After) and `X-Retry-In` headers when APIs are rate-limited. -- Standardized [hypermedia](https://smartbear.com/learn/api-design/what-is-hypermedia/) parsing into queryable/followable response links: - - HTTP Link relation headers ([RFC 5988](https://tools.ietf.org/html/rfc5988#section-6.2.2)) - - [HAL](http://stateless.co/hal_specification.html) - - [Siren](https://github.com/kevinswiber/siren) - - [Terrifically Simple JSON](https://github.com/mpnally/Terrifically-Simple-JSON) - - [JSON:API](https://jsonapi.org/) -- Local caching that respects [RFC 7234](https://tools.ietf.org/html/rfc7234) `Cache-Control` and `Expires` headers -- CLI [shorthand](https://github.com/danielgtaylor/openapi-cli-generator/tree/master/shorthand#cli-shorthand-syntax) for structured data input (e.g. for JSON) -- [Shorthand query](https://github.com/danielgtaylor/shorthand#querying) response filtering & projection -- Colorized prettified readable output -- Fast native zero-dependency binary - -Articles: - -- [A CLI for REST APIs](https://dev.to/danielgtaylor/a-cli-for-rest-apis-part-1-104b) -- [Mapping OpenAPI to the CLI](https://dev.to/danielgtaylor/mapping-openapi-to-the-cli-37pb) - -This project started life as a fork of [OpenAPI CLI Generator](https://github.com/danielgtaylor/openapi-cli-generator). +# APImetrics CLI + +A command-line interface for managing API monitors, schedules, SLOs, and more on the [APImetrics](https://apimetrics.io) platform. + +## Getting Started + +### 1. Log in + +```bash +apimetrics login +``` + +This opens your browser for OAuth2 authentication. Your credentials are cached locally and refreshed automatically. + +### 2. Select a project + +All commands require an active project. Run the following to choose one: + +```bash +apimetrics project select +``` + +To confirm which project is currently active: + +```bash +apimetrics project show +``` + +### 3. Run your first command + +```bash +# List all API calls in the current project +apimetrics list-calls + +# List all schedules +apimetrics list-schedules +``` + +To see all supported commands, run +```bash +apimetrics --help +``` + +## Passing Input + +All create and update commands read a JSON body from **stdin** using a heredoc. There is no `--body`, `--data`, or `-d` flag. + +```bash +apimetrics create-call <<'EOF' +{ + "meta": { + "name": "My API Check" + }, + "request": { + "method": "GET", + "url": "https://api.example.com/health" + } +} +EOF +``` + +You can also use **CLI Shorthand** as a concise alternative to JSON: + +```bash +apimetrics create-call meta.name: "My API Check", request.method: GET, request.url: https://api.example.com/health +``` + +Or pipe from a file: + +```bash +apimetrics create-call < my-call.json +``` + +## Output Formats + +Use `-o` / `--rsh-output-format` to control output: + +| Format | Description | +|---|---| +| `auto` (default) | Pretty in terminal, JSON when piped | +| `json` | Standard JSON | +| `yaml` | YAML | +| `table` | Tabular layout (best for lists) | +| `readable` | Colorized human-readable format | +| `gron` | Grep-friendly flattened format | + +```bash +# Table output +apimetrics list-calls -o table + +# Raw JSON for scripting +apimetrics list-calls -o json + +# Filter results with a query expression +apimetrics list-calls -f body[0].meta.name +``` + +When output is redirected to a pipe or file, color is disabled and only the body is printed as JSON automatically. + +## Global Flags + +These flags work with every command: + +| Flag | Short | Description | +|---|---|---| +| `--rsh-output-format` | `-o` | Output format (auto/json/yaml/table/readable/gron) | +| `--rsh-filter` | `-f` | Filter/project response using a query expression | +| `--rsh-raw` | `-r` | Raw output (strips quotes from strings) | +| `--rsh-verbose` | `-v` | Verbose logging | +| `--rsh-header` | `-H` | Add a request header (repeatable) | +| `--rsh-query` | `-q` | Add a query parameter (repeatable) | +| `--rsh-profile` | `-p` | Use a named auth profile | +| `--rsh-server` | `-s` | Override the API server base URL | +| `--rsh-no-cache` | | Disable HTTP caching | +| `--rsh-no-paginate` | | Disable automatic pagination | +| `--rsh-retry` | | Retry count (default 2) | +| `--rsh-timeout` | `-t` | HTTP request timeout | +| `--rsh-insecure` | | Disable TLS verification | + +## Shell Completion + +Enable tab completion for your shell: + +```bash +# Bash +apimetrics completion bash >> ~/.bash_profile + +# Zsh +apimetrics completion zsh >> ~/.zshrc + +# Fish +apimetrics completion fish > ~/.config/fish/completions/apimetrics.fish + +# PowerShell +apimetrics completion powershell >> $PROFILE +``` + +Once enabled, press `tab` to explore available commands and their arguments. + +## AI Agent Integration + +The CLI includes built-in skills for AI coding agents (e.g. Claude Code). These skills teach agents how to create and configure monitors using the CLI. + +**Install skills into Claude Code:** + +```bash +apimetrics skills install --claude-code +``` + +**Print all skills to stdout** (for any agent or model that can read context): + +```bash +apimetrics onboard +``` + +Available skills: +- `setup-api-monitor` — Create an API (HTTP) monitor, attach a schedule, and verify it +- `setup-browser-monitor` — Create a browser monitor, attach a schedule, and verify it +- `setup-mcp-monitor` — Create an MCP protocol monitor with session steps + +## Configuration + +Configuration and cached tokens are stored in platform-specific locations: + +| OS | Config | Cache | +|---|---|---| +| macOS | `~/Library/Application Support/apimetrics/` | `~/Library/Caches/apimetrics/` | +| Linux | `~/.config/apimetrics/` | `~/.cache/apimetrics/` | +| Windows | `%AppData%\apimetrics\` | `%LocalAppData%\apimetrics\` | + +Override these locations with environment variables: + +```bash +export APIMETRICS_CONFIG_DIR=/path/to/config +export APIMETRICS_CACHE_DIR=/path/to/cache +``` + +## Logout + +```bash +apimetrics logout +``` + +This removes cached tokens. Run `apimetrics login` again to re-authenticate. From 375956c14eb737147d3858dc015000f684477b1b Mon Sep 17 00:00:00 2001 From: ndenny Date: Thu, 11 Jun 2026 16:46:02 -0700 Subject: [PATCH 46/65] Add welcome message --- oauth/authcode.go | 30 +++++++++++++++++++++++++----- 1 file changed, 25 insertions(+), 5 deletions(-) diff --git a/oauth/authcode.go b/oauth/authcode.go index bde8881..4259a3a 100644 --- a/oauth/authcode.go +++ b/oauth/authcode.go @@ -16,8 +16,8 @@ import ( "context" - "github.com/mattn/go-isatty" "apicontext.com/apimetrics/cli" + "github.com/mattn/go-isatty" "golang.org/x/oauth2" ) @@ -285,10 +285,30 @@ func (ac *AuthorizationCodeTokenSource) Token() (*oauth2.Token, error) { } }() - // Open auth URL in browser, print for manual use in case open fails. - fmt.Fprintln(os.Stderr, "Open your browser to log in using the URL:") - fmt.Fprintln(os.Stderr, authorizeURL.String()) - open(authorizeURL.String()) + // Print welcome banner, show login URL, and ask before opening browser. + fmt.Fprintln(os.Stderr, ` + _ ___ ___ _ _ + /_\ | _ \_ _|_ __ ___| |_ _ _(_)__ ___ + / _ \| _/| || ' \/ -_| _| '_| / _(_-< + /_/ \_\_| |___|_|_|_\___|\__|_| |_\__/__/ +`) + fmt.Fprintln(os.Stderr, "Welcome to APImetrics CLI!") + fmt.Fprintln(os.Stderr, "") + fmt.Fprintln(os.Stderr, "To log in, open the following URL in your browser:") + fmt.Fprintln(os.Stderr, "") + fmt.Fprintln(os.Stderr, " "+authorizeURL.String()) + fmt.Fprintln(os.Stderr, "") + if isatty.IsTerminal(os.Stderr.Fd()) || isatty.IsCygwinTerminal(os.Stderr.Fd()) { + fmt.Fprint(os.Stderr, "Open your browser now? [Y/n]: ") + reader := bufio.NewReader(os.Stdin) + answer, _ := reader.ReadString('\n') + answer = strings.TrimSpace(strings.ToLower(answer)) + if answer == "" || answer == "y" || answer == "yes" { + open(authorizeURL.String()) + } + } else { + open(authorizeURL.String()) + } // Provide a way to manually enter the code, e.g. for remote SSH sessions. // Only read from stdin if it is a live terminal, if a file or command has From 91e21088e9abcf03e7a093573299c88923b2d455 Mon Sep 17 00:00:00 2001 From: ndenny Date: Thu, 11 Jun 2026 18:08:38 -0700 Subject: [PATCH 47/65] Add configuration files for production and QC environments; update main.go to reference them --- .github/workflows/develop.yml | 189 ++++++++++++++++++++++++++++++++++ config_prod.go | 9 ++ config_qc.go | 9 ++ main.go | 14 +-- 4 files changed, 210 insertions(+), 11 deletions(-) create mode 100644 .github/workflows/develop.yml create mode 100644 config_prod.go create mode 100644 config_qc.go diff --git a/.github/workflows/develop.yml b/.github/workflows/develop.yml new file mode 100644 index 0000000..2184489 --- /dev/null +++ b/.github/workflows/develop.yml @@ -0,0 +1,189 @@ +name: Develop Build + +on: + push: + branches: + - develop + +permissions: + contents: read + id-token: write # required for Workload Identity Federation (signing only) + +jobs: + build: + name: Build QC artifacts + runs-on: ubuntu-latest + timeout-minutes: 60 + + steps: + - name: Check out repository + uses: actions/checkout@v5 + with: + fetch-depth: 0 + + - name: Set up Go + uses: actions/setup-go@v6 + with: + go-version-file: go.mod + cache: true + + - name: Run GoReleaser (all platforms, unsigned) + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + docker run --rm \ + -v "${PWD}:/workspace" \ + -w /workspace \ + -e GITHUB_TOKEN \ + ghcr.io/goreleaser/goreleaser-cross:v1.25-v2.12.7 \ + release --snapshot --config dist/config-qc.yaml --clean --parallelism 2 + + - name: Upload build artifacts + uses: actions/upload-artifact@v4 + with: + name: qc-artifacts-${{ github.run_number }} + if-no-files-found: error + path: | + dist/*.tar.gz + dist/*.zip + dist/checksums.txt + dist/artifacts.json + dist/metadata.json + + sign-macos: + name: Sign and notarize macOS artifacts + runs-on: macos-latest + needs: build + timeout-minutes: 30 + + steps: + - name: Check out repository + uses: actions/checkout@v5 + + - name: Check for Apple signing secrets + id: secrets_check + env: + APPLE_CERT_P12: ${{ secrets.APPLE_CERT_P12 }} + run: | + if [ -n "$APPLE_CERT_P12" ]; then + echo "has_signing_cert=true" >> "$GITHUB_OUTPUT" + else + echo "has_signing_cert=false" >> "$GITHUB_OUTPUT" + echo "::warning::APPLE_CERT_P12 not configured — skipping Apple code signing." + fi + + - name: Import Apple certificate + if: ${{ steps.secrets_check.outputs.has_signing_cert == 'true' }} + env: + APPLE_CERT_P12: ${{ secrets.APPLE_CERT_P12 }} + APPLE_CERT_PASSWORD: ${{ secrets.APPLE_CERT_PASSWORD }} + KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }} + run: | + set -e + for var_name in APPLE_CERT_PASSWORD KEYCHAIN_PASSWORD; do + if [ -z "${!var_name}" ]; then + echo "::error::$var_name is required when APPLE_CERT_P12 is configured." + exit 1 + fi + done + + printf '%s' "$APPLE_CERT_P12" | base64 -D > cert.p12 + + KEYCHAIN_PATH="$RUNNER_TEMP/build.keychain-db" + security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" + security set-keychain-settings -lut 21600 "$KEYCHAIN_PATH" + security import cert.p12 -k "$KEYCHAIN_PATH" -P "$APPLE_CERT_PASSWORD" -T /usr/bin/codesign + security set-key-partition-list -S apple-tool:,apple: -s -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" + security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" + security list-keychains -d user -s "$KEYCHAIN_PATH" $(security list-keychains -d user | tr -d '"' | xargs) + + - name: Download build artifacts + uses: actions/download-artifact@v4 + with: + name: qc-artifacts-${{ github.run_number }} + path: dist + + - name: Sign and notarize macOS artifacts + if: ${{ steps.secrets_check.outputs.has_signing_cert == 'true' }} + env: + APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }} + APPLE_ID: ${{ secrets.APPLE_ID }} + APPLE_ID_PASSWORD: ${{ secrets.APPLE_ID_PASSWORD }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + KEYCHAIN_PATH: ${{ runner.temp }}/build.keychain-db + run: | + set -e + for var_name in APPLE_SIGNING_IDENTITY APPLE_ID APPLE_ID_PASSWORD APPLE_TEAM_ID; do + if [ -z "${!var_name}" ]; then + echo "::error::$var_name is required for signing and notarization." + exit 1 + fi + done + if [ ! -f dist/checksums.txt ]; then + echo "::error::dist/checksums.txt not found." + exit 1 + fi + + for archive in dist/*-darwin-*.tar.gz; do + [ -f "$archive" ] || continue + tmpdir="$(mktemp -d)" + orig_members=$(tar -tzf "$archive") + tar -xzf "$archive" -C "$tmpdir" + + while IFS= read -r -d '' bin; do + codesign --force --options runtime --timestamp \ + --keychain "$KEYCHAIN_PATH" \ + --sign "$APPLE_SIGNING_IDENTITY" "$bin" + codesign --verify --deep --strict "$bin" + + zip_dir="$(mktemp -d)" + zip_path="$zip_dir/binary.zip" + zip -j "$zip_path" "$bin" + xcrun notarytool submit "$zip_path" \ + --apple-id "$APPLE_ID" \ + --password "$APPLE_ID_PASSWORD" \ + --team-id "$APPLE_TEAM_ID" \ + --wait + rm -rf "$zip_dir" + done < <(find "$tmpdir" -type f -perm -111 -print0) + + # shellcheck disable=SC2086 + tar -czf "${archive}.signed" -C "$tmpdir" $orig_members + mv "${archive}.signed" "$archive" + rm -rf "$tmpdir" + + filename="$(basename "$archive")" + sha="$(shasum -a 256 "$archive" | awk '{print $1}')" + grep -v " $filename\$" dist/checksums.txt > dist/checksums.new || true + printf "%s %s\n" "$sha" "$filename" >> dist/checksums.new + mv dist/checksums.new dist/checksums.txt + + PATCH_NAME="$filename" PATCH_SHA="$sha" python3 << 'PYEOF' + import json, os + data = json.load(open('dist/artifacts.json')) + name = os.environ['PATCH_NAME'] + checksum = os.environ['PATCH_SHA'] + for a in data: + if a.get('name') == name: + a.setdefault('extra', {})['Checksum'] = 'sha256:' + checksum + with open('dist/artifacts.json', 'w') as f: + json.dump(data, f, indent=2) + PYEOF + done + + - name: Upload signed macOS artifacts + if: ${{ steps.secrets_check.outputs.has_signing_cert == 'true' }} + uses: actions/upload-artifact@v4 + with: + name: qc-artifacts-macos-signed-${{ github.run_number }} + if-no-files-found: error + path: | + dist/*-darwin-amd64.tar.gz + dist/*-darwin-arm64.tar.gz + dist/checksums.txt + + - name: Clean up keychain and cert + if: always() + run: | + security delete-keychain "$RUNNER_TEMP/build.keychain-db" 2>/dev/null || true + rm -f cert.p12 diff --git a/config_prod.go b/config_prod.go new file mode 100644 index 0000000..ef370a8 --- /dev/null +++ b/config_prod.go @@ -0,0 +1,9 @@ +//go:build prod + +package main + +var baseURL = "https://client.apimetrics.io" +var authURL = "https://auth.apimetrics.io/authorize" +var tokenURL = "https://auth.apimetrics.io/oauth/token" +var authAudience = "https://client.apimetrics.io" +var clientID = "dPbV4VPvioF4nZ3oGQMn7n1vE2pFNAAI" diff --git a/config_qc.go b/config_qc.go new file mode 100644 index 0000000..fea0917 --- /dev/null +++ b/config_qc.go @@ -0,0 +1,9 @@ +//go:build !prod + +package main + +var baseURL = "https://qc-client.apimetrics.io" +var authURL = "https://qc-auth.apimetrics.io/authorize" +var tokenURL = "https://qc-auth.apimetrics.io/oauth/token" +var authAudience = "https://client.apimetrics.io" +var clientID = "bj0yh0AjBMzfeOpffmCj5UP8FbmYDwcM" diff --git a/main.go b/main.go index db8bc01..caed2aa 100644 --- a/main.go +++ b/main.go @@ -11,17 +11,9 @@ import ( ) var version string = "dev" -var commit string -var date string - -// Build-time API configuration — override with -ldflags at build time: -// -// go build -ldflags "-X main.baseURL=https://client.apimetrics.io ..." -var baseURL = "https://qc-client.apimetrics.io" -var authURL = "https://qc-auth.apimetrics.io/authorize" -var tokenURL = "https://qc-auth.apimetrics.io/oauth/token" -var authAudience = "https://client.apimetrics.io" -var clientID = "bj0yh0AjBMzfeOpffmCj5UP8FbmYDwcM" + +// baseURL, authURL, tokenURL, authAudience, clientID are defined in +// config_qc.go (default) or config_prod.go (build tag: prod). func main() { if version == "dev" { From afe05a460c6dc9e3ffaafb1285dfb35fd70d89a2 Mon Sep 17 00:00:00 2001 From: ndenny Date: Fri, 12 Jun 2026 13:31:34 -0700 Subject: [PATCH 48/65] Formatting --- bench_test.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bench_test.go b/bench_test.go index a4ed1a0..2bf729a 100644 --- a/bench_test.go +++ b/bench_test.go @@ -6,10 +6,10 @@ import ( "net/http" "testing" - "github.com/amzn/ion-go/ion" - "github.com/fxamacker/cbor/v2" "apicontext.com/apimetrics/cli" "apicontext.com/apimetrics/openapi" + "github.com/amzn/ion-go/ion" + "github.com/fxamacker/cbor/v2" "github.com/shamaton/msgpack/v2" "github.com/spf13/cobra" ) From b6ef104b5084a33253dc1d15194e512f378a45af Mon Sep 17 00:00:00 2001 From: ndenny Date: Fri, 12 Jun 2026 13:32:34 -0700 Subject: [PATCH 49/65] Formatting --- bench_test.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bench_test.go b/bench_test.go index a4ed1a0..2bf729a 100644 --- a/bench_test.go +++ b/bench_test.go @@ -6,10 +6,10 @@ import ( "net/http" "testing" - "github.com/amzn/ion-go/ion" - "github.com/fxamacker/cbor/v2" "apicontext.com/apimetrics/cli" "apicontext.com/apimetrics/openapi" + "github.com/amzn/ion-go/ion" + "github.com/fxamacker/cbor/v2" "github.com/shamaton/msgpack/v2" "github.com/spf13/cobra" ) From 73094efb1f58633136063f88f7030e66ff706bed Mon Sep 17 00:00:00 2001 From: ndenny Date: Fri, 12 Jun 2026 14:21:04 -0700 Subject: [PATCH 50/65] Switch to QC CLI Auth0 client --- config_qc.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config_qc.go b/config_qc.go index fea0917..9347638 100644 --- a/config_qc.go +++ b/config_qc.go @@ -6,4 +6,4 @@ var baseURL = "https://qc-client.apimetrics.io" var authURL = "https://qc-auth.apimetrics.io/authorize" var tokenURL = "https://qc-auth.apimetrics.io/oauth/token" var authAudience = "https://client.apimetrics.io" -var clientID = "bj0yh0AjBMzfeOpffmCj5UP8FbmYDwcM" +var clientID = "4fhqu4lEH5ExaRh00X1B9WJSkjTnUmuK" From ad6be2135100a0bd48f7537855a93ba126a98a26 Mon Sep 17 00:00:00 2001 From: ndenny Date: Fri, 12 Jun 2026 14:32:48 -0700 Subject: [PATCH 51/65] Add dev build for testing locally --- config_dev.go | 9 +++++++++ config_qc.go | 2 +- 2 files changed, 10 insertions(+), 1 deletion(-) create mode 100644 config_dev.go diff --git a/config_dev.go b/config_dev.go new file mode 100644 index 0000000..a6f856d --- /dev/null +++ b/config_dev.go @@ -0,0 +1,9 @@ +//go:build dev && !prod + +package main + +var baseURL = "http://localhost:8080" +var authURL = "https://local-apimetrics.auth0.com/authorize" +var tokenURL = "https://local-apimetrics.auth0.com/oauth/token" +var authAudience = "https://apimetrics-qc.appspot.com" +var clientID = "bpplXMDCn187JipRLl6Y9KrsTVZCJTbS" diff --git a/config_qc.go b/config_qc.go index 9347638..2691890 100644 --- a/config_qc.go +++ b/config_qc.go @@ -1,4 +1,4 @@ -//go:build !prod +//go:build !prod && !dev package main From d1df2491417375428d86168f4669603ed500da83 Mon Sep 17 00:00:00 2001 From: ndenny Date: Fri, 12 Jun 2026 14:45:27 -0700 Subject: [PATCH 52/65] Add styling to auth pages --- oauth/authcode.go | 200 ++++++++++++++++++++++++++++------------------ 1 file changed, 123 insertions(+), 77 deletions(-) diff --git a/oauth/authcode.go b/oauth/authcode.go index bde8881..94ad855 100644 --- a/oauth/authcode.go +++ b/oauth/authcode.go @@ -22,105 +22,151 @@ import ( ) var htmlSuccess = ` - + + + + + + Login Successful — APImetrics + + + + -
-
-

Login Successful!

- Please return to the terminal. You may now close this window. -

+
+ +
+

Login Successful!

+

Please return to the terminal. You may now close this window.

` var htmlError = ` - + + + + + + Login Failed — APImetrics + + + + -
-
-

Error: $ERROR

- $DETAILS -

+
+ +
+

Login Failed

+

$ERROR
$DETAILS

From 61f141dd7c2f8ac2bea21caf824538f223b28e48 Mon Sep 17 00:00:00 2001 From: ndenny Date: Fri, 12 Jun 2026 14:56:05 -0700 Subject: [PATCH 53/65] Restrict prod releases to main or main-* branches --- .github/workflows/release.yml | 15 ++++++- .goreleaser.yml | 80 ----------------------------------- 2 files changed, 14 insertions(+), 81 deletions(-) delete mode 100644 .goreleaser.yml diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9d4a0d2..4d72347 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -37,6 +37,19 @@ jobs: go-version-file: go.mod cache: true + - name: Verify release branch + run: | + if [[ "$GITHUB_REF" == refs/tags/* ]]; then + # Allow main or a main- branch (e.g. main-hotfix) for emergency releases. + if ! git branch -r --contains "$GITHUB_SHA" | grep -qE '^\s*origin/main($|-)'; then + echo "::error::Tag $GITHUB_REF_NAME must be on the main branch (or a main- branch)." + exit 1 + fi + elif [[ "$GITHUB_REF" != "refs/heads/main" && "$GITHUB_REF" != refs/heads/main-* && "${{ inputs.snapshot }}" != "true" ]]; then + echo "::error::Release workflow must be dispatched from the main branch (or a main- branch, or use snapshot=true for ad-hoc builds)." + exit 1 + fi + - name: Validate dispatch inputs if: github.event_name == 'workflow_dispatch' env: @@ -89,7 +102,7 @@ jobs: -w /workspace \ -e GITHUB_TOKEN \ ghcr.io/goreleaser/goreleaser-cross:v1.25-v2.12.7 \ - release $EXTRA --clean --parallelism 2 + release $EXTRA --config dist/config.yaml --clean --parallelism 2 - name: Create GitHub draft release if: ${{ startsWith(github.ref, 'refs/tags/') && inputs.snapshot != true }} diff --git a/.goreleaser.yml b/.goreleaser.yml deleted file mode 100644 index 21b5759..0000000 --- a/.goreleaser.yml +++ /dev/null @@ -1,80 +0,0 @@ -version: 2 - -project_name: apimetrics - -before: - hooks: - - go mod download - # TODO: Figure out how to test with CGO on all platforms. - - go test -v ./... - -builds: - - id: apimetrics-darwin-amd64 - goos: - - darwin - goarch: - - amd64 - env: - - CGO_ENABLED=1 - - CC=o64-clang - - CXX=o64-clang++ - - - id: apimetrics-darwin-arm64 - goos: - - darwin - goarch: - - arm64 - env: - - CGO_ENABLED=1 - - CC=oa64-clang - - CXX=oa64-clang++ - - - id: apimetrics-linux-amd64 - goos: - - linux - goarch: - - amd64 - env: - - CGO_ENABLED=1 - - CC=x86_64-linux-gnu-gcc - - CXX=x86_64-linux-gnu-g++ - - - id: apimetrics-linux-arm64 - goos: - - linux - goarch: - - arm64 - env: - - CGO_ENABLED=1 - - CC=aarch64-linux-gnu-gcc - - CXX=aarch64-linux-gnu-g++ - - - id: apimetrics-windows-amd64 - goos: - - windows - goarch: - - amd64 - env: - - CGO_ENABLED=1 - - CC=x86_64-w64-mingw32-gcc - - CXX=x86_64-w64-mingw32-g++ - -archives: - - name_template: "{{ .ProjectName }}-{{ .Version }}-{{ .Os }}-{{ .Arch }}" - format_overrides: - - goos: windows - formats: - - zip - -checksum: - name_template: "checksums.txt" - -snapshot: - version_template: "{{ .Tag }}-next" - -changelog: - sort: asc - filters: - exclude: - - "^docs:" - - "^test:" From 393849aa1e22dd552167e4709e24ac9c75b9b667 Mon Sep 17 00:00:00 2001 From: ndenny Date: Fri, 12 Jun 2026 15:00:49 -0700 Subject: [PATCH 54/65] Fix go vet: redundant newline in Fprintln banner The welcome banner string already ends with a newline, so Fprintln appended a redundant one. Switch to Fprint to satisfy go vet. Co-Authored-By: Claude Opus 4.8 (1M context) --- oauth/authcode.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/oauth/authcode.go b/oauth/authcode.go index 4259a3a..55f1533 100644 --- a/oauth/authcode.go +++ b/oauth/authcode.go @@ -286,7 +286,7 @@ func (ac *AuthorizationCodeTokenSource) Token() (*oauth2.Token, error) { }() // Print welcome banner, show login URL, and ask before opening browser. - fmt.Fprintln(os.Stderr, ` + fmt.Fprint(os.Stderr, ` _ ___ ___ _ _ /_\ | _ \_ _|_ __ ___| |_ _ _(_)__ ___ / _ \| _/| || ' \/ -_| _| '_| / _(_-< From f06c1aa40a787daafdcd8c3b0531f2db5034c002 Mon Sep 17 00:00:00 2001 From: ndenny Date: Fri, 12 Jun 2026 15:17:29 -0700 Subject: [PATCH 55/65] Move goreleaser configs out of gitignored dist/ MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This branch replaced the auto-discovered root .goreleaser.yml with two env-specific configs but placed them under dist/, which is gitignored, so they never reached CI — the release job failed with "open dist/config.yaml: no such file or directory". (goreleaser's --clean also wipes dist/ at startup, so committing them there would not work either.) Relocate both to a tracked .goreleaser/ directory and point the release and develop workflows at the new paths. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/develop.yml | 2 +- .github/workflows/release.yml | 2 +- .goreleaser/config-qc.yaml | 128 +++++++++++++++ .goreleaser/config.yaml | 287 ++++++++++++++++++++++++++++++++++ 4 files changed, 417 insertions(+), 2 deletions(-) create mode 100644 .goreleaser/config-qc.yaml create mode 100644 .goreleaser/config.yaml diff --git a/.github/workflows/develop.yml b/.github/workflows/develop.yml index 2184489..525e128 100644 --- a/.github/workflows/develop.yml +++ b/.github/workflows/develop.yml @@ -36,7 +36,7 @@ jobs: -w /workspace \ -e GITHUB_TOKEN \ ghcr.io/goreleaser/goreleaser-cross:v1.25-v2.12.7 \ - release --snapshot --config dist/config-qc.yaml --clean --parallelism 2 + release --snapshot --config .goreleaser/config-qc.yaml --clean --parallelism 2 - name: Upload build artifacts uses: actions/upload-artifact@v4 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4d72347..31bc59d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -102,7 +102,7 @@ jobs: -w /workspace \ -e GITHUB_TOKEN \ ghcr.io/goreleaser/goreleaser-cross:v1.25-v2.12.7 \ - release $EXTRA --config dist/config.yaml --clean --parallelism 2 + release $EXTRA --config .goreleaser/config.yaml --clean --parallelism 2 - name: Create GitHub draft release if: ${{ startsWith(github.ref, 'refs/tags/') && inputs.snapshot != true }} diff --git a/.goreleaser/config-qc.yaml b/.goreleaser/config-qc.yaml new file mode 100644 index 0000000..85a699f --- /dev/null +++ b/.goreleaser/config-qc.yaml @@ -0,0 +1,128 @@ +version: 2 +project_name: apimetrics-qc +builds: + - id: apimetrics-qc-darwin-amd64 + goos: + - darwin + goarch: + - amd64 + targets: + - darwin_amd64_v1 + dir: . + main: . + binary: apimetrics-qc + builder: go + tool: go + command: build + ldflags: + - -s -w -X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}} -X main.builtBy=goreleaser + env: + - CGO_ENABLED=1 + - CC=o64-clang + - CXX=o64-clang++ + - id: apimetrics-qc-darwin-arm64 + goos: + - darwin + goarch: + - arm64 + targets: + - darwin_arm64_v8.0 + dir: . + main: . + binary: apimetrics-qc + builder: go + tool: go + command: build + ldflags: + - -s -w -X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}} -X main.builtBy=goreleaser + env: + - CGO_ENABLED=1 + - CC=oa64-clang + - CXX=oa64-clang++ + - id: apimetrics-qc-linux-amd64 + goos: + - linux + goarch: + - amd64 + targets: + - linux_amd64_v1 + dir: . + main: . + binary: apimetrics-qc + builder: go + tool: go + command: build + ldflags: + - -s -w -X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}} -X main.builtBy=goreleaser + env: + - CGO_ENABLED=1 + - CC=x86_64-linux-gnu-gcc + - CXX=x86_64-linux-gnu-g++ + - id: apimetrics-qc-linux-arm64 + goos: + - linux + goarch: + - arm64 + targets: + - linux_arm64_v8.0 + dir: . + main: . + binary: apimetrics-qc + builder: go + tool: go + command: build + ldflags: + - -s -w -X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}} -X main.builtBy=goreleaser + env: + - CGO_ENABLED=1 + - CC=aarch64-linux-gnu-gcc + - CXX=aarch64-linux-gnu-g++ + - id: apimetrics-qc-windows-amd64 + goos: + - windows + goarch: + - amd64 + targets: + - windows_amd64_v1 + dir: . + main: . + binary: apimetrics-qc + builder: go + tool: go + command: build + ldflags: + - -s -w -X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}} -X main.builtBy=goreleaser + env: + - CGO_ENABLED=1 + - CC=x86_64-w64-mingw32-gcc + - CXX=x86_64-w64-mingw32-g++ +archives: + - id: default + builds_info: + mode: 493 + name_template: '{{ .ProjectName }}-{{ .Version }}-{{ .Os }}-{{ .Arch }}' + formats: + - tar.gz + format_overrides: + - goos: windows + formats: + - zip + files: + - src: license* + - src: LICENSE* + - src: readme* + - src: README* +snapshot: + version_template: '{{ .ShortCommit }}' +checksum: + name_template: checksums.txt + algorithm: sha256 +dist: dist +gomod: + gobinary: go +before: + hooks: + - go mod download + - go test -v ./... +git: + tag_sort: -version:refname diff --git a/.goreleaser/config.yaml b/.goreleaser/config.yaml new file mode 100644 index 0000000..55927d7 --- /dev/null +++ b/.goreleaser/config.yaml @@ -0,0 +1,287 @@ +version: 2 +project_name: apimetrics +release: + github: + owner: APImetrics + name: APImetrics-cli + name_template: '{{.Tag}}' +builds: + - id: apimetrics-darwin-amd64 + goos: + - darwin + goarch: + - amd64 + goamd64: + - v1 + go386: + - sse2 + goarm: + - "6" + goarm64: + - v8.0 + gomips: + - hardfloat + goppc64: + - power8 + goriscv64: + - rva20u64 + targets: + - darwin_amd64_v1 + dir: . + main: . + binary: apimetrics + builder: go + tool: go + command: build + flags: + - -tags=prod + ldflags: + - -s -w -X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}} -X main.builtBy=goreleaser + env: + - CGO_ENABLED=1 + - CC=o64-clang + - CXX=o64-clang++ + - id: apimetrics-darwin-arm64 + goos: + - darwin + goarch: + - arm64 + goamd64: + - v1 + go386: + - sse2 + goarm: + - "6" + goarm64: + - v8.0 + gomips: + - hardfloat + goppc64: + - power8 + goriscv64: + - rva20u64 + targets: + - darwin_arm64_v8.0 + dir: . + main: . + binary: apimetrics + builder: go + tool: go + command: build + flags: + - -tags=prod + ldflags: + - -s -w -X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}} -X main.builtBy=goreleaser + env: + - CGO_ENABLED=1 + - CC=oa64-clang + - CXX=oa64-clang++ + - id: apimetrics-linux-amd64 + goos: + - linux + goarch: + - amd64 + goamd64: + - v1 + go386: + - sse2 + goarm: + - "6" + goarm64: + - v8.0 + gomips: + - hardfloat + goppc64: + - power8 + goriscv64: + - rva20u64 + targets: + - linux_amd64_v1 + dir: . + main: . + binary: apimetrics + builder: go + tool: go + command: build + flags: + - -tags=prod + ldflags: + - -s -w -X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}} -X main.builtBy=goreleaser + env: + - CGO_ENABLED=1 + - CC=x86_64-linux-gnu-gcc + - CXX=x86_64-linux-gnu-g++ + - id: apimetrics-linux-arm64 + goos: + - linux + goarch: + - arm64 + goamd64: + - v1 + go386: + - sse2 + goarm: + - "6" + goarm64: + - v8.0 + gomips: + - hardfloat + goppc64: + - power8 + goriscv64: + - rva20u64 + targets: + - linux_arm64_v8.0 + dir: . + main: . + binary: apimetrics + builder: go + tool: go + command: build + flags: + - -tags=prod + ldflags: + - -s -w -X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}} -X main.builtBy=goreleaser + env: + - CGO_ENABLED=1 + - CC=aarch64-linux-gnu-gcc + - CXX=aarch64-linux-gnu-g++ + - id: apimetrics-windows-amd64 + goos: + - windows + goarch: + - amd64 + goamd64: + - v1 + go386: + - sse2 + goarm: + - "6" + goarm64: + - v8.0 + gomips: + - hardfloat + goppc64: + - power8 + goriscv64: + - rva20u64 + targets: + - windows_amd64_v1 + dir: . + main: . + binary: apimetrics + builder: go + tool: go + command: build + flags: + - -tags=prod + ldflags: + - -s -w -X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}} -X main.builtBy=goreleaser + env: + - CGO_ENABLED=1 + - CC=x86_64-w64-mingw32-gcc + - CXX=x86_64-w64-mingw32-g++ +archives: + - id: default + builds_info: + mode: 493 + name_template: '{{ .ProjectName }}-{{ .Version }}-{{ .Os }}-{{ .Arch }}' + formats: + - tar.gz + format_overrides: + - goos: windows + formats: + - zip + files: + - src: license* + - src: LICENSE* + - src: readme* + - src: README* + - src: changelog* + - src: CHANGELOG* +snapshot: + version_template: '{{ .Tag }}-next' +checksum: + name_template: checksums.txt + algorithm: sha256 +docker_digest: + name_template: digests.txt +blobs: + - bucket: apimetrics-cli + provider: gs + directory: '{{ .Version }}' + content_disposition: attachment;filename={{.Filename}} +changelog: + filters: + exclude: + - '^docs:' + - '^test:' + sort: asc + format: '{{ .SHA }} {{ .Message }}' +dist: dist +env_files: + github_token: ~/.config/goreleaser/github_token + gitlab_token: ~/.config/goreleaser/gitlab_token + gitea_token: ~/.config/goreleaser/gitea_token +before: + hooks: + - go mod download + - go test -v ./... +source: + name_template: '{{ .ProjectName }}-{{ .Version }}' + format: tar.gz +gomod: + gobinary: go +announce: + twitter: + message_template: '{{ .ProjectName }} {{ .Tag }} is out! Check it out at {{ .ReleaseURL }}' + mastodon: + message_template: '{{ .ProjectName }} {{ .Tag }} is out! Check it out at {{ .ReleaseURL }}' + server: "" + reddit: + title_template: '{{ .ProjectName }} {{ .Tag }} is out!' + url_template: '{{ .ReleaseURL }}' + slack: + message_template: '{{ .ProjectName }} {{ .Tag }} is out! Check it out at {{ .ReleaseURL }}' + username: GoReleaser + discord: + message_template: '{{ .ProjectName }} {{ .Tag }} is out! Check it out at {{ .ReleaseURL }}' + author: GoReleaser + color: "3888754" + icon_url: https://goreleaser.com/static/avatar.png + teams: + title_template: '{{ .ProjectName }} {{ .Tag }} is out!' + message_template: '{{ .ProjectName }} {{ .Tag }} is out! Check it out at {{ .ReleaseURL }}' + color: '#2D313E' + icon_url: https://goreleaser.com/static/avatar.png + smtp: + subject_template: '{{ .ProjectName }} {{ .Tag }} is out!' + body_template: 'You can view details from: {{ .ReleaseURL }}' + mattermost: + message_template: '{{ .ProjectName }} {{ .Tag }} is out! Check it out at {{ .ReleaseURL }}' + title_template: '{{ .ProjectName }} {{ .Tag }} is out!' + username: GoReleaser + linkedin: + message_template: '{{ .ProjectName }} {{ .Tag }} is out! Check it out at {{ .ReleaseURL }}' + telegram: + message_template: '{{ mdv2escape .ProjectName }} {{ mdv2escape .Tag }} is out{{ mdv2escape "!" }} Check it out at {{ mdv2escape .ReleaseURL }}' + parse_mode: MarkdownV2 + webhook: + message_template: '{ "message": "{{ .ProjectName }} {{ .Tag }} is out! Check it out at {{ .ReleaseURL }}"}' + content_type: application/json; charset=utf-8 + expected_status_codes: + - 200 + - 201 + - 202 + - 204 + opencollective: + title_template: '{{ .Tag }}' + message_template: '{{ .ProjectName }} {{ .Tag }} is out!
Check it out at {{ .ReleaseURL }}' + bluesky: + message_template: '{{ .ProjectName }} {{ .Tag }} is out! Check it out at {{ .ReleaseURL }}' +git: + tag_sort: -version:refname +github_urls: + download: https://github.com +gitlab_urls: + download: https://gitlab.com From 33b322d3886db8dc2ab9608b2ab3cf2a371ff174 Mon Sep 17 00:00:00 2001 From: ndenny Date: Fri, 12 Jun 2026 15:34:07 -0700 Subject: [PATCH 56/65] Gate browser prompt on stdin being a TTY The [Y/n] prompt read from stdin but only checked that stderr was a terminal. If a request body was piped into stdin while stderr was a TTY, the prompt would consume its first line. Check stdin instead, matching the manual-code path that protects piped input. Co-Authored-By: Claude Opus 4.8 (1M context) --- oauth/authcode.go | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/oauth/authcode.go b/oauth/authcode.go index 55f1533..6ee1127 100644 --- a/oauth/authcode.go +++ b/oauth/authcode.go @@ -298,7 +298,10 @@ func (ac *AuthorizationCodeTokenSource) Token() (*oauth2.Token, error) { fmt.Fprintln(os.Stderr, "") fmt.Fprintln(os.Stderr, " "+authorizeURL.String()) fmt.Fprintln(os.Stderr, "") - if isatty.IsTerminal(os.Stderr.Fd()) || isatty.IsCygwinTerminal(os.Stderr.Fd()) { + // Only prompt interactively if stdin is a live terminal. If a file or + // command has been piped in it is likely the request body to use after + // auth, so we must not consume it here (see the manual-code path below). + if isatty.IsTerminal(os.Stdin.Fd()) || isatty.IsCygwinTerminal(os.Stdin.Fd()) { fmt.Fprint(os.Stderr, "Open your browser now? [Y/n]: ") reader := bufio.NewReader(os.Stdin) answer, _ := reader.ReadString('\n') From 3a000bc05f6b4f4c03d5c8a9599a2bf985ff9861 Mon Sep 17 00:00:00 2001 From: ndenny Date: Fri, 12 Jun 2026 15:44:06 -0700 Subject: [PATCH 57/65] Escape OAuth error params and harden release branch check - ServeHTTP now HTML-escapes the error/error_description query params before interpolating them into the error page, preventing reflected XSS via the localhost redirect URL. - The release tag guard fetches origin/main* explicitly before git branch --contains, since tag pushes don't populate remote-tracking refs and the check would otherwise fail on legitimate main tags. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/release.yml | 3 +++ oauth/authcode.go | 5 ++++- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 31bc59d..c387c61 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -40,6 +40,9 @@ jobs: - name: Verify release branch run: | if [[ "$GITHUB_REF" == refs/tags/* ]]; then + # Tag pushes don't populate origin/* remote-tracking refs, so fetch + # the main branches explicitly before checking containment. + git fetch --quiet origin '+refs/heads/main:refs/remotes/origin/main' '+refs/heads/main-*:refs/remotes/origin/main-*' || true # Allow main or a main- branch (e.g. main-hotfix) for emergency releases. if ! git branch -r --contains "$GITHUB_SHA" | grep -qE '^\s*origin/main($|-)'; then echo "::error::Tag $GITHUB_REF_NAME must be on the main branch (or a main- branch)." diff --git a/oauth/authcode.go b/oauth/authcode.go index 94ad855..5ff2eef 100644 --- a/oauth/authcode.go +++ b/oauth/authcode.go @@ -6,6 +6,7 @@ import ( "crypto/sha256" "encoding/base64" "fmt" + "html" "net/http" "net/url" "os" @@ -233,7 +234,9 @@ func (h authHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { if err := r.URL.Query().Get("error"); err != "" { details := r.URL.Query().Get("error_description") - rendered := strings.Replace(strings.Replace(htmlError, "$ERROR", err, 1), "$DETAILS", details, 1) + // Escape the query params before interpolating into HTML to avoid + // reflected XSS via the localhost redirect URL. + rendered := strings.Replace(strings.Replace(htmlError, "$ERROR", html.EscapeString(err), 1), "$DETAILS", html.EscapeString(details), 1) w.Write([]byte(rendered)) h.c <- "" return From cfbd5d95d575b64bcdc4a4742de1c1f50f55710b Mon Sep 17 00:00:00 2001 From: ndenny Date: Fri, 12 Jun 2026 15:49:20 -0700 Subject: [PATCH 58/65] Build QC artifacts on snapshot dispatch and attach them Snapshot (--snapshot) dispatches now build with config-qc.yaml so they produce QC binaries, and the resulting dist artifacts are uploaded to the workflow run as qc-snapshot-artifacts for download. Previously snapshot runs used the prod config and uploaded nothing. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/release.yml | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c387c61..8989e8c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -92,9 +92,12 @@ jobs: run: | set -e EXTRA="" + CONFIG=".goreleaser/config.yaml" if [ "$SNAPSHOT" = "true" ]; then + # Snapshot builds are QC builds: QC endpoints, apimetrics-qc binary. EXTRA="--snapshot --skip homebrew" - echo "Running goreleaser in snapshot mode (no publish)" + CONFIG=".goreleaser/config-qc.yaml" + echo "Running goreleaser in snapshot mode (QC build, no publish)" elif [[ "$GITHUB_REF" == refs/tags/* ]]; then EXTRA="--skip publish,homebrew" echo "Building artifacts without publishing; GitHub release and GCS upload deferred to finalization" @@ -105,7 +108,7 @@ jobs: -w /workspace \ -e GITHUB_TOKEN \ ghcr.io/goreleaser/goreleaser-cross:v1.25-v2.12.7 \ - release $EXTRA --config .goreleaser/config.yaml --clean --parallelism 2 + release $EXTRA --config "$CONFIG" --clean --parallelism 2 - name: Create GitHub draft release if: ${{ startsWith(github.ref, 'refs/tags/') && inputs.snapshot != true }} @@ -131,10 +134,9 @@ jobs: fi - name: Upload release artifacts - if: ${{ inputs.snapshot != true }} uses: actions/upload-artifact@v4 with: - name: release-artifacts + name: ${{ inputs.snapshot == true && 'qc-snapshot-artifacts' || 'release-artifacts' }} if-no-files-found: error path: | dist/*.tar.gz From d0e78f6ebeff96863252991c4ae6dad261f0ccbe Mon Sep 17 00:00:00 2001 From: ndenny Date: Fri, 12 Jun 2026 16:17:46 -0700 Subject: [PATCH 59/65] Upload QC snapshot assets individually Snapshot runs previously bundled every artifact under a single qc-snapshot-artifacts entry, which GitHub serves as one combined zip. Upload each platform archive (and checksums.txt) as its own artifact so the Actions run lists them individually. Non-snapshot release runs keep the combined release-artifacts bundle the macOS job depends on. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/release.yml | 53 ++++++++++++++++++++++++++++++++++- 1 file changed, 52 insertions(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8989e8c..cd63a60 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -134,9 +134,10 @@ jobs: fi - name: Upload release artifacts + if: ${{ inputs.snapshot != true }} uses: actions/upload-artifact@v4 with: - name: ${{ inputs.snapshot == true && 'qc-snapshot-artifacts' || 'release-artifacts' }} + name: release-artifacts if-no-files-found: error path: | dist/*.tar.gz @@ -145,6 +146,56 @@ jobs: dist/artifacts.json dist/metadata.json + # Snapshot (QC) builds: upload each asset as its own artifact so the + # Actions run lists them individually rather than as one combined zip. + - name: Upload QC snapshot — macOS (Intel) + if: ${{ inputs.snapshot == true }} + uses: actions/upload-artifact@v4 + with: + name: apimetrics-qc-darwin-amd64 + if-no-files-found: error + path: dist/*-darwin-amd64.tar.gz + + - name: Upload QC snapshot — macOS (Apple Silicon) + if: ${{ inputs.snapshot == true }} + uses: actions/upload-artifact@v4 + with: + name: apimetrics-qc-darwin-arm64 + if-no-files-found: error + path: dist/*-darwin-arm64.tar.gz + + - name: Upload QC snapshot — Linux (amd64) + if: ${{ inputs.snapshot == true }} + uses: actions/upload-artifact@v4 + with: + name: apimetrics-qc-linux-amd64 + if-no-files-found: error + path: dist/*-linux-amd64.tar.gz + + - name: Upload QC snapshot — Linux (arm64) + if: ${{ inputs.snapshot == true }} + uses: actions/upload-artifact@v4 + with: + name: apimetrics-qc-linux-arm64 + if-no-files-found: error + path: dist/*-linux-arm64.tar.gz + + - name: Upload QC snapshot — Windows (amd64) + if: ${{ inputs.snapshot == true }} + uses: actions/upload-artifact@v4 + with: + name: apimetrics-qc-windows-amd64 + if-no-files-found: error + path: dist/*-windows-amd64.zip + + - name: Upload QC snapshot — checksums + if: ${{ inputs.snapshot == true }} + uses: actions/upload-artifact@v4 + with: + name: checksums.txt + if-no-files-found: error + path: dist/checksums.txt + release-macos: name: Sign and notarize macOS artifacts runs-on: macos-latest From 5d2cb788cdbd1e1d47d7ce76fe3d74afe112ae59 Mon Sep 17 00:00:00 2001 From: ndenny Date: Fri, 12 Jun 2026 16:23:23 -0700 Subject: [PATCH 60/65] Sign macOS builds in snapshot mode and upload them as assets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously the release-macos job was gated off entirely for snapshot (QC) builds, so snapshot darwin archives were never signed and only the unsigned placeholders from the build job were available. Now the macOS job runs for snapshot dispatches too: it pulls the individually-uploaded QC artifacts, signs/notarizes the darwin archives, and re-uploads the signed archives (and updated checksums.txt) as individual assets, overwriting the unsigned placeholders. The tag-release path is unchanged — it still downloads the combined release-artifacts bundle and produces macos-signed-artifacts. The artifacts.json checksum patch is skipped when that file is absent (snapshot builds). Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/release.yml | 47 ++++++++++++++++++++++++++++++++--- 1 file changed, 44 insertions(+), 3 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index cd63a60..208409f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -200,7 +200,6 @@ jobs: name: Sign and notarize macOS artifacts runs-on: macos-latest needs: release - if: ${{ inputs.snapshot != true }} timeout-minutes: 30 steps: @@ -245,11 +244,21 @@ jobs: security list-keychains -d user -s "$KEYCHAIN_PATH" $(security list-keychains -d user | tr -d '"' | xargs) - name: Download release artifacts + if: ${{ inputs.snapshot != true }} uses: actions/download-artifact@v4 with: name: release-artifacts path: dist + # Snapshot builds upload each asset as its own artifact; pull them all + # back (flattened) so the signing step finds the darwin archives. + - name: Download QC snapshot artifacts + if: ${{ inputs.snapshot == true }} + uses: actions/download-artifact@v4 + with: + merge-multiple: true + path: dist + - name: Sign and notarize macOS artifacts if: ${{ steps.secrets_check.outputs.has_signing_cert == 'true' }} env: @@ -306,7 +315,9 @@ jobs: printf "%s %s\n" "$sha" "$filename" >> dist/checksums.new mv dist/checksums.new dist/checksums.txt - # Keep artifacts.json in sync with the signed checksums for archival accuracy. + # Keep artifacts.json in sync with the signed checksums for archival + # accuracy. Only present for full releases, not snapshot builds. + if [ -f dist/artifacts.json ]; then PATCH_NAME="$filename" PATCH_SHA="$sha" python3 << 'PYEOF' import json, os data = json.load(open('dist/artifacts.json')) @@ -318,10 +329,11 @@ jobs: with open('dist/artifacts.json', 'w') as f: json.dump(data, f, indent=2) PYEOF + fi done - name: Upload signed macOS artifacts - if: ${{ steps.secrets_check.outputs.has_signing_cert == 'true' }} + if: ${{ steps.secrets_check.outputs.has_signing_cert == 'true' && inputs.snapshot != true }} uses: actions/upload-artifact@v4 with: name: macos-signed-artifacts @@ -331,6 +343,35 @@ jobs: dist/*-darwin-arm64.tar.gz dist/checksums.txt + # Snapshot builds: replace the unsigned darwin placeholders uploaded by + # the build job with the signed archives, listed as individual assets. + - name: Upload signed QC snapshot — macOS (Intel) + if: ${{ inputs.snapshot == true && steps.secrets_check.outputs.has_signing_cert == 'true' }} + uses: actions/upload-artifact@v4 + with: + name: apimetrics-qc-darwin-amd64 + overwrite: true + if-no-files-found: error + path: dist/*-darwin-amd64.tar.gz + + - name: Upload signed QC snapshot — macOS (Apple Silicon) + if: ${{ inputs.snapshot == true && steps.secrets_check.outputs.has_signing_cert == 'true' }} + uses: actions/upload-artifact@v4 + with: + name: apimetrics-qc-darwin-arm64 + overwrite: true + if-no-files-found: error + path: dist/*-darwin-arm64.tar.gz + + - name: Upload signed QC snapshot — checksums + if: ${{ inputs.snapshot == true && steps.secrets_check.outputs.has_signing_cert == 'true' }} + uses: actions/upload-artifact@v4 + with: + name: checksums.txt + overwrite: true + if-no-files-found: error + path: dist/checksums.txt + - name: Authenticate to Google Cloud if: ${{ startsWith(github.ref, 'refs/tags/') }} uses: google-github-actions/auth@v2 From 6a158aca2a974e7006aba1536e2a8cf113cbe879 Mon Sep 17 00:00:00 2001 From: ndenny Date: Fri, 12 Jun 2026 16:30:19 -0700 Subject: [PATCH 61/65] Make develop builds sign-only (no notarization) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Merges to develop run develop.yml on every push, so notarizing the macOS builds each time wastes time and Apple notary quota — those are internal QC artifacts, not externally distributed. Drop the notarytool submit step and the now-unused APPLE_ID/APPLE_ID_PASSWORD/APPLE_TEAM_ID env vars, keeping codesign + verify. Notarization is retained for snapshot dispatches and tagged releases in release.yml. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/develop.yml | 27 ++++++--------------------- 1 file changed, 6 insertions(+), 21 deletions(-) diff --git a/.github/workflows/develop.yml b/.github/workflows/develop.yml index 525e128..a9e2a54 100644 --- a/.github/workflows/develop.yml +++ b/.github/workflows/develop.yml @@ -51,7 +51,7 @@ jobs: dist/metadata.json sign-macos: - name: Sign and notarize macOS artifacts + name: Sign macOS artifacts runs-on: macos-latest needs: build timeout-minutes: 30 @@ -103,22 +103,17 @@ jobs: name: qc-artifacts-${{ github.run_number }} path: dist - - name: Sign and notarize macOS artifacts + - name: Sign macOS artifacts if: ${{ steps.secrets_check.outputs.has_signing_cert == 'true' }} env: APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }} - APPLE_ID: ${{ secrets.APPLE_ID }} - APPLE_ID_PASSWORD: ${{ secrets.APPLE_ID_PASSWORD }} - APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} KEYCHAIN_PATH: ${{ runner.temp }}/build.keychain-db run: | set -e - for var_name in APPLE_SIGNING_IDENTITY APPLE_ID APPLE_ID_PASSWORD APPLE_TEAM_ID; do - if [ -z "${!var_name}" ]; then - echo "::error::$var_name is required for signing and notarization." - exit 1 - fi - done + if [ -z "$APPLE_SIGNING_IDENTITY" ]; then + echo "::error::APPLE_SIGNING_IDENTITY is required for signing." + exit 1 + fi if [ ! -f dist/checksums.txt ]; then echo "::error::dist/checksums.txt not found." exit 1 @@ -135,16 +130,6 @@ jobs: --keychain "$KEYCHAIN_PATH" \ --sign "$APPLE_SIGNING_IDENTITY" "$bin" codesign --verify --deep --strict "$bin" - - zip_dir="$(mktemp -d)" - zip_path="$zip_dir/binary.zip" - zip -j "$zip_path" "$bin" - xcrun notarytool submit "$zip_path" \ - --apple-id "$APPLE_ID" \ - --password "$APPLE_ID_PASSWORD" \ - --team-id "$APPLE_TEAM_ID" \ - --wait - rm -rf "$zip_dir" done < <(find "$tmpdir" -type f -perm -111 -print0) # shellcheck disable=SC2086 From 2c560674376ce31f750414c5b23c594a5ad42b9e Mon Sep 17 00:00:00 2001 From: ndenny Date: Mon, 6 Jul 2026 15:49:11 -0700 Subject: [PATCH 62/65] Use github release assets instead of GCS --- .github/workflows/develop.yml | 1 - .github/workflows/release.yml | 32 ++---------- .goreleaser/config.yaml | 5 -- RELEASING.md | 92 +++++------------------------------ 4 files changed, 16 insertions(+), 114 deletions(-) diff --git a/.github/workflows/develop.yml b/.github/workflows/develop.yml index a9e2a54..bdf880a 100644 --- a/.github/workflows/develop.yml +++ b/.github/workflows/develop.yml @@ -7,7 +7,6 @@ on: permissions: contents: read - id-token: write # required for Workload Identity Federation (signing only) jobs: build: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 208409f..9517a87 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -16,8 +16,7 @@ on: permissions: - contents: write - id-token: write # required for Workload Identity Federation + contents: write # required to create releases and upload assets jobs: release: @@ -372,31 +371,6 @@ jobs: if-no-files-found: error path: dist/checksums.txt - - name: Authenticate to Google Cloud - if: ${{ startsWith(github.ref, 'refs/tags/') }} - uses: google-github-actions/auth@v2 - with: - workload_identity_provider: ${{ secrets.PRODUCTION_WORKLOAD_IDENTITY_PROVIDER }} - service_account: ${{ secrets.PRODUCTION_WORKLOAD_SERVICE_ACCOUNT }} - - - name: Set up Cloud SDK - if: ${{ startsWith(github.ref, 'refs/tags/') }} - uses: google-github-actions/setup-gcloud@v2 - - - name: Upload Linux and Windows artifacts to GCS - if: ${{ startsWith(github.ref, 'refs/tags/') }} - run: | - set -e - VERSION="${GITHUB_REF_NAME#v}" - gsutil -m cp dist/*-linux-*.tar.gz dist/*-windows-*.zip "gs://apimetrics-cli/$VERSION/" - - - name: Upload signed macOS artifacts to GCS - if: ${{ startsWith(github.ref, 'refs/tags/') && steps.secrets_check.outputs.has_signing_cert == 'true' }} - run: | - set -e - VERSION="${GITHUB_REF_NAME#v}" - gsutil -m cp dist/*-darwin-*.tar.gz dist/checksums.txt "gs://apimetrics-cli/$VERSION/" - - name: Replace macOS assets on GitHub release if: ${{ startsWith(github.ref, 'refs/tags/') && steps.secrets_check.outputs.has_signing_cert == 'true' }} env: @@ -433,7 +407,7 @@ jobs: run: | set -e VERSION="${GITHUB_REF_NAME#v}" - BASE_URL="https://storage.googleapis.com/apimetrics-cli/${VERSION}" + BASE_URL="https://github.com/${GITHUB_REPOSITORY}/releases/download/${GITHUB_REF_NAME}" AMD64_FILE="apimetrics-${VERSION}-darwin-amd64.tar.gz" ARM64_FILE="apimetrics-${VERSION}-darwin-arm64.tar.gz" @@ -542,7 +516,7 @@ jobs: # Requires APIContext.APImetricsCLI to already exist in microsoft/winget-pkgs. # See RELEASING.md § 5 for the one-time manual submission required before this runs. $version = $env:GITHUB_REF_NAME -replace '^v', '' - $installerUrl = "https://storage.googleapis.com/apimetrics-cli/$version/apimetrics-$version-windows-amd64.zip" + $installerUrl = "https://github.com/$env:GITHUB_REPOSITORY/releases/download/$env:GITHUB_REF_NAME/apimetrics-$version-windows-amd64.zip" Invoke-WebRequest -Uri "https://github.com/microsoft/winget-create/releases/download/v1.12.8.0/wingetcreate.exe" -OutFile wingetcreate.exe .\wingetcreate.exe update APIContext.APImetricsCLI ` diff --git a/.goreleaser/config.yaml b/.goreleaser/config.yaml index 55927d7..a418153 100644 --- a/.goreleaser/config.yaml +++ b/.goreleaser/config.yaml @@ -206,11 +206,6 @@ checksum: algorithm: sha256 docker_digest: name_template: digests.txt -blobs: - - bucket: apimetrics-cli - provider: gs - directory: '{{ .Version }}' - content_disposition: attachment;filename={{.Filename}} changelog: filters: exclude: diff --git a/RELEASING.md b/RELEASING.md index c98e693..72ba4c8 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -6,8 +6,10 @@ This document covers the one-time infrastructure setup required to run the relea Pushing a `v0.x.y` tag triggers the release workflow (`.github/workflows/release.yml`), which: -1. **Linux job** — cross-compiles all platform binaries inside `goreleaser-cross` (Docker), builds archives, and creates a GitHub draft release. -2. **macOS job** — signs and notarizes the Darwin binaries using an Apple Developer certificate, re-archives them, uploads all artifacts to GCS, replaces the macOS assets on the draft release, publishes the release, then updates the Homebrew tap formula. +1. **Linux job** — cross-compiles all platform binaries inside `goreleaser-cross` (Docker), builds archives, and creates a GitHub draft release with the Linux and Windows assets. +2. **macOS job** — signs and notarizes the Darwin binaries using an Apple Developer certificate, re-archives them, replaces the macOS assets on the draft release, publishes the release, then updates the Homebrew tap formula. + +All binaries are distributed as **GitHub release assets** — Homebrew and WinGet download directly from `https://github.com/APImetrics/APImetrics-cli/releases/download//`. Snapshot builds (no publish) can be triggered via `workflow_dispatch` with `snapshot: true`. @@ -15,77 +17,11 @@ Snapshot builds (no publish) can be triggered via `workflow_dispatch` with `snap ## One-time infrastructure setup -### 1. Google Cloud — Workload Identity Federation - -The workflow authenticates to GCP without a stored service account key using Workload Identity Federation (WIF). Set this up once per GCP project. - -```bash -PROJECT_ID=your-gcp-project -POOL_NAME=github-actions -PROVIDER_NAME=github -SA_NAME=apimetrics-cli-release -BUCKET=apimetrics-cli -REPO=APImetrics/APImetrics-cli - -# Create the workload identity pool -gcloud iam workload-identity-pools create "$POOL_NAME" \ - --project="$PROJECT_ID" \ - --location=global \ - --display-name="GitHub Actions" - -# Create the OIDC provider -gcloud iam workload-identity-pools providers create-oidc "$PROVIDER_NAME" \ - --project="$PROJECT_ID" \ - --location=global \ - --workload-identity-pool="$POOL_NAME" \ - --display-name="GitHub" \ - --attribute-mapping="google.subject=assertion.sub,attribute.repository=assertion.repository" \ - --issuer-uri="https://token.actions.githubusercontent.com" - -# Create the service account -gcloud iam service-accounts create "$SA_NAME" \ - --project="$PROJECT_ID" \ - --display-name="APImetrics CLI release" - -# Grant it write access to the GCS bucket -gsutil iam ch "serviceAccount:${SA_NAME}@${PROJECT_ID}.iam.gserviceaccount.com:roles/storage.objectAdmin" \ - "gs://$BUCKET" - -# Allow the GitHub repo to impersonate the service account -POOL_ID=$(gcloud iam workload-identity-pools describe "$POOL_NAME" \ - --project="$PROJECT_ID" --location=global --format="value(name)") - -gcloud iam service-accounts add-iam-policy-binding \ - "${SA_NAME}@${PROJECT_ID}.iam.gserviceaccount.com" \ - --project="$PROJECT_ID" \ - --role=roles/iam.workloadIdentityUser \ - --member="principalSet://iam.googleapis.com/${POOL_ID}/attribute.repository/${REPO}" -``` - -Then retrieve the values to store as secrets: - -```bash -# Workload identity provider (store as PRODUCTION_WORKLOAD_IDENTITY_PROVIDER) -gcloud iam workload-identity-pools providers describe "$PROVIDER_NAME" \ - --project="$PROJECT_ID" \ - --location=global \ - --workload-identity-pool="$POOL_NAME" \ - --format="value(name)" - -# Service account email (store as PRODUCTION_WORKLOAD_SERVICE_ACCOUNT) -echo "${SA_NAME}@${PROJECT_ID}.iam.gserviceaccount.com" -``` - -**Secrets to add to `APImetrics/APImetrics-cli`:** - -| Secret | Value | -|--------|-------| -| `PRODUCTION_WORKLOAD_IDENTITY_PROVIDER` | Full provider resource name (`projects/.../providers/github`) | -| `PRODUCTION_WORKLOAD_SERVICE_ACCOUNT` | Service account email (`apimetrics-cli-release@….iam.gserviceaccount.com`) | - ---- +> Binaries are distributed as GitHub release assets, so **the repository must be +> public** (or the download URLs used by Homebrew/WinGet must otherwise be +> reachable by end users). No cloud storage or service-account setup is required. -### 2. GitHub App — Homebrew tap access +### 1. GitHub App — Homebrew tap access The workflow uses a GitHub App to push formula updates to `APImetrics/homebrew-tap`. This avoids a long-lived personal access token. @@ -107,7 +43,7 @@ The workflow uses a GitHub App to push formula updates to `APImetrics/homebrew-t --- -### 3. Apple code signing and notarization +### 2. Apple code signing and notarization macOS binaries must be signed and notarized with an Apple Developer ID certificate so they run without Gatekeeper warnings. @@ -136,17 +72,17 @@ base64 -i certificate.p12 | pbcopy # copies base64 to clipboard --- -### 4. Homebrew tap repository +### 3. Homebrew tap repository The Homebrew formula is pushed to `APImetrics/homebrew-tap`. Ensure: - The repository exists. - A `Formula/` directory exists in the repository root (create it with a `.gitkeep` if needed — the release workflow will create `Formula/apimetrics.rb` on first release). -- The GitHub App from step 2 is installed on this repository. +- The GitHub App from step 1 is installed on this repository. --- -### 5. WinGet package +### 4. WinGet package The workflow uses `wingetcreate` to submit a PR to [`microsoft/winget-pkgs`](https://github.com/microsoft/winget-pkgs) after each release, keeping the Windows Package Manager entry up to date. @@ -159,7 +95,7 @@ The package must exist in `microsoft/winget-pkgs` before automated updates can r Invoke-WebRequest -Uri "https://github.com/microsoft/winget-create/releases/download/v1.12.8.0/wingetcreate.exe" -OutFile wingetcreate.exe $version = "0.1.0" # set to the first published version -$installerUrl = "https://storage.googleapis.com/apimetrics-cli/$version/apimetrics-$version-windows-amd64.zip" +$installerUrl = "https://github.com/APImetrics/APImetrics-cli/releases/download/v$version/apimetrics-$version-windows-amd64.zip" .\wingetcreate.exe new $installerUrl ` --id APIContext.APImetricsCLI ` @@ -192,8 +128,6 @@ All secrets required on `APImetrics/APImetrics-cli`: | Secret | Purpose | |--------|---------| -| `PRODUCTION_WORKLOAD_IDENTITY_PROVIDER` | WIF provider for GCS uploads | -| `PRODUCTION_WORKLOAD_SERVICE_ACCOUNT` | GCP service account email for GCS uploads | | `GH_APP_ID` | GitHub App ID for Homebrew tap access | | `GH_APP_PRIVATE_KEY` | GitHub App private key for Homebrew tap access | | `APPLE_CERT_P12` | Base64 Apple Developer ID certificate | From e300115799364b10ba5ee7714376af00475a8663 Mon Sep 17 00:00:00 2001 From: Nick Denny Date: Mon, 6 Jul 2026 15:57:16 -0700 Subject: [PATCH 63/65] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .github/workflows/release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9517a87..5c0473c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -514,7 +514,7 @@ jobs: run: | $ErrorActionPreference = 'Stop' # Requires APIContext.APImetricsCLI to already exist in microsoft/winget-pkgs. - # See RELEASING.md § 5 for the one-time manual submission required before this runs. + # See RELEASING.md § 4 ("WinGet package") for the one-time manual submission required before this runs. $version = $env:GITHUB_REF_NAME -replace '^v', '' $installerUrl = "https://github.com/$env:GITHUB_REPOSITORY/releases/download/$env:GITHUB_REF_NAME/apimetrics-$version-windows-amd64.zip" From 032e1e2ef06649e15bd2905a825da5abbd3ae9df Mon Sep 17 00:00:00 2001 From: Nick Denny Date: Thu, 9 Jul 2026 15:14:10 -0700 Subject: [PATCH 64/65] Enhance README with documentation and attribution Added documentation link and APIContext Inc attribution. --- README.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 5fcd4e8..e44b9d9 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,10 @@ # APImetrics CLI -A command-line interface for managing API monitors, schedules, SLOs, and more on the [APImetrics](https://apimetrics.io) platform. +A command-line interface for managing API monitors, schedules, SLOs, and more on the [APImetrics](https://apimetrics.io) platform, by [APIContext Inc](https://apicontext.com). + +## Documentation + +For more information, please visit our main documentation site, available at [https://docs.apicontext.com/cli](https://docs.apicontext.com/cli). ## Getting Started From c5746ee658201a0864f9192b804d33add23316b6 Mon Sep 17 00:00:00 2001 From: ndenny Date: Thu, 9 Jul 2026 15:26:55 -0700 Subject: [PATCH 65/65] Add install docs and support info --- README.md | 44 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/README.md b/README.md index e44b9d9..c8c1e8e 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,42 @@ A command-line interface for managing API monitors, schedules, SLOs, and more on For more information, please visit our main documentation site, available at [https://docs.apicontext.com/cli](https://docs.apicontext.com/cli). + +## Installation + +## Manual installation + +The binaries for the APImetrics CLI can be downloaded from the [releases page](https://github.com/APImetrics/APImetrics-cli/releases). Choose the appropriate version for your operating system and put the executable in your path for easy access from the command line. + +## MacOS + +You can install the APImetrics CLI using Homebrew: + +```bash +brew install apicontext/tap/apimetrics-cli +``` + +## Windows - Coming soon! + +> Note: Coming soon, not available yet + +You can install the APImetrics CLI using the Windows Package Manager (winget): + +```powershell +winget install APImetrics.APImetricsCLI +``` + +## Linux + +You can install the APImetrics CLI by downloading the binary from the [releases page](https://github.com/APImetrics/APImetrics-cli/releases). + +```bash +curl -LO https://github.com/APImetrics/APImetrics-cli/releases/latest/download/apimetrics-[version]-linux-amd64.tar.gz +tar -xzf apimetrics-[version]-linux-amd64.tar.gz +sudo mv apimetrics /usr/local/bin/apimetrics +``` +Note: [version] should be replaced with the specific version you want to download, e.g., `0.0.1`. + ## Getting Started ### 1. Log in @@ -186,3 +222,11 @@ apimetrics logout ``` This removes cached tokens. Run `apimetrics login` again to re-authenticate. + +# Issues and Contributing + +For support questions, visit [APImetrics Support](https://apicontext.com/support) and open a request. + +For reporting bugs or requesting features, please open an issue on the [GitHub repository](https://github.com/APImetrics/APImetrics-cli/issues). + +We welcome contributions! Feel free to fork the repository, make changes, and submit pull requests.