From bfeae738e377bda4932796892d7fb26eb215d033 Mon Sep 17 00:00:00 2001 From: eduard256 Date: Wed, 25 Mar 2026 17:37:20 +0000 Subject: [PATCH] Add add_protocol skill, update release skills with DB download step --- .claude/skills/add_protocol_strix/SKILL.md | 563 +++++++++++++++++++++ .claude/skills/release_strix/SKILL.md | 32 +- .claude/skills/release_strix_dev/SKILL.md | 16 +- 3 files changed, 597 insertions(+), 14 deletions(-) create mode 100644 .claude/skills/add_protocol_strix/SKILL.md diff --git a/.claude/skills/add_protocol_strix/SKILL.md b/.claude/skills/add_protocol_strix/SKILL.md new file mode 100644 index 0000000..0f47de5 --- /dev/null +++ b/.claude/skills/add_protocol_strix/SKILL.md @@ -0,0 +1,563 @@ +--- +name: add_protocol_strix +description: Add a new protocol support to Strix -- full flow from research to implementation. Covers stream handler registration, URL builder updates, database issues, and go2rtc integration. +disable-model-invocation: true +argument-hint: [protocol-name] +--- + +# Add Protocol to Strix + +You are adding support for a new protocol to Strix. Follow every step in order. Be thorough -- read all referenced files completely before writing any code. + +The protocol name is provided as argument (e.g. `/add_protocol_strix bubble`). If no argument, use AskUserQuestion to ask which protocol to add. + +## Repositories + +- Strix: `/home/user/Strix` +- go2rtc: `/home/user/go2rtc` (reference implementation, read-only) +- StrixCamDB: issues at https://github.com/eduard256/StrixCamDB/issues (for database updates) + +--- + +## STEP 0: Understand the existing RTSP implementation (REFERENCE) + +Before doing anything, read these files completely to understand the patterns: + +``` +/home/user/Strix/pkg/tester/source.go -- handler registry + RTSP reference implementation +/home/user/Strix/pkg/tester/worker.go -- how handlers are called, screenshot logic +/home/user/Strix/pkg/tester/session.go -- session data structures +/home/user/Strix/pkg/camdb/streams.go -- URL builder, placeholder replacement +/home/user/Strix/internal/test/test.go -- API layer for tester +/home/user/Strix/internal/search/search.go -- search API (rarely needs changes) +``` + +### How RTSP works (the reference pattern) + +**Registration** in `pkg/tester/source.go`: +```go +var handlers = map[string]SourceHandler{} + +func RegisterSource(scheme string, handler SourceHandler) { + handlers[scheme] = handler +} + +func init() { + RegisterSource("rtsp", rtspHandler) + RegisterSource("rtsps", rtspHandler) + RegisterSource("rtspx", rtspHandler) +} +``` + +**Handler** -- receives a URL string, returns go2rtc `core.Producer`: +```go +func rtspHandler(rawURL string) (core.Producer, error) { + rawURL, _, _ = strings.Cut(rawURL, "#") + + conn := rtsp.NewClient(rawURL) + conn.Backchannel = false + + if err := conn.Dial(); err != nil { + return nil, fmt.Errorf("rtsp: dial: %w", err) + } + + if err := conn.Describe(); err != nil { + _ = conn.Stop() + return nil, fmt.Errorf("rtsp: describe: %w", err) + } + + return conn, nil +} +``` + +**Data flow**: URL -> GetHandler(url) -> handler(url) -> core.Producer -> GetMedias() -> codecs, latency -> getScreenshot() -> Result + +**Key**: The handler ONLY needs to return a `core.Producer`. Everything else (codecs extraction, screenshot capture, session management) is handled automatically by `worker.go`. + +### How URLs are built in `pkg/camdb/streams.go`: + +1. Database has URL templates like `/cam/realmonitor?channel=[CHANNEL]&subtype=0` +2. `replacePlaceholders()` substitutes `[CHANNEL]`, `[USERNAME]`, `[PASSWORD]`, etc. +3. `buildURL()` prepends `protocol://user:pass@host:port` to the path +4. Credentials are URL-encoded with `url.PathEscape` / `url.QueryEscape` + +Default ports are defined in `defaultPorts` map: +```go +var defaultPorts = map[string]int{ + "rtsp": 554, "rtsps": 322, "http": 80, "https": 443, + "rtmp": 1935, "mms": 554, "rtp": 5004, +} +``` + +--- + +## STEP 1: Research the protocol in go2rtc + +go2rtc already implements most camera protocols. Study the implementation: + +### Where to look in go2rtc + +| What | Where | +|------|-------| +| Protocol client logic | `/home/user/go2rtc/pkg/{protocol}/` | +| Module registration | `/home/user/go2rtc/internal/{protocol}/` | +| Core interfaces | `/home/user/go2rtc/pkg/core/core.go` | +| Stream handler registry | `/home/user/go2rtc/internal/streams/handlers.go` | +| Keyframe capture | `/home/user/go2rtc/pkg/magic/keyframe.go` | + +### Protocol map in go2rtc + +| Protocol | pkg/ (Dial function) | internal/ (Init glue) | +|----------|---------------------|----------------------| +| rtsp/rtsps | `pkg/rtsp/client.go` | `internal/rtsp/rtsp.go` | +| http/https | `pkg/magic/producer.go`, `pkg/tcp/request.go` | `internal/http/http.go` | +| rtmp | `pkg/rtmp/` | `internal/rtmp/rtmp.go` | +| bubble | `pkg/bubble/` | `internal/bubble/bubble.go` | +| dvrip | `pkg/dvrip/` | `internal/dvrip/dvrip.go` | +| onvif | `pkg/onvif/` | `internal/onvif/onvif.go` | +| homekit | `pkg/homekit/`, `pkg/hap/` | `internal/homekit/homekit.go` | +| tapo | `pkg/tapo/` | `internal/tapo/tapo.go` | +| kasa | `pkg/kasa/` | `internal/kasa/kasa.go` | +| eseecloud | `pkg/eseecloud/` | `internal/eseecloud/eseecloud.go` | +| nest | `pkg/nest/` | `internal/nest/init.go` | +| ring | `pkg/ring/` | `internal/ring/ring.go` | +| wyze | `pkg/wyze/` | `internal/wyze/wyze.go` | +| xiaomi | `pkg/xiaomi/` | `internal/xiaomi/xiaomi.go` | +| tuya | `pkg/tuya/` | `internal/tuya/tuya.go` | +| doorbird | `pkg/doorbird/` | `internal/doorbird/doorbird.go` | +| isapi | `pkg/isapi/` | `internal/isapi/init.go` | +| flussonic | `pkg/flussonic/` | `internal/flussonic/flussonic.go` | +| gopro | `pkg/gopro/` | `internal/gopro/gopro.go` | +| roborock | `pkg/roborock/` | `internal/roborock/roborock.go` | + +### What to read + +1. Read `/home/user/go2rtc/internal/{protocol}/{protocol}.go` -- find `streams.HandleFunc` call, understand what function is called and how +2. Read `/home/user/go2rtc/pkg/{protocol}/` -- find the `Dial()` or `NewClient()` function, understand its signature and what it returns +3. Understand: does it return `core.Producer`? Does it need special setup before Dial? Does it need credentials differently? + +### Typical go2rtc internal module (e.g. kasa -- simplest): +```go +package kasa + +import ( + "github.com/AlexxIT/go2rtc/internal/streams" + "github.com/AlexxIT/go2rtc/pkg/core" + "github.com/AlexxIT/go2rtc/pkg/kasa" +) + +func Init() { + streams.HandleFunc("kasa", func(source string) (core.Producer, error) { + return kasa.Dial(source) + }) +} +``` + +Most protocols follow this exact pattern: `pkg/{protocol}.Dial(url)` returns `core.Producer`. + +--- + +## STEP 2: Classify the protocol + +Use AskUserQuestion to discuss with the user. Determine the protocol type: + +### Type A: Standard URL-based protocol (rtsp, rtmp, bubble, dvrip, http, etc.) + +- Has URL scheme (e.g. `bubble://host:port/path`) +- URLs stored in StrixCamDB database +- Flow: user searches camera -> gets URL templates -> URLs built with credentials -> sent to tester +- Needs: stream handler in tester + default port in URL builder + database issue + +### Type B: Custom/discovery protocol (homekit, onvif, etc.) + +- Does NOT use standard URL templates from database +- Has custom discovery or authentication flow +- Data comes from probe endpoint or direct user input, NOT from camera search +- Needs: source handler in tester with custom logic, possibly probe endpoint update +- Does NOT need database issue + +### Type C: HTTP sub-protocol (mjpeg, jpeg snapshot, hls) + +- Uses http:// or https:// URL scheme +- Already has URLs in database (same as HTTP) +- Needs special handling in tester based on Content-Type response +- Needs: stream handler that detects content type and handles accordingly + +--- + +## STEP 3: For Type A -- Create StrixCamDB issue + +ONLY for Type A protocols that have URL patterns stored in the database. + +Create a GitHub issue using `gh` CLI for the new protocol: + +```bash +cd /home/user/Strix +gh issue create --repo eduard256/StrixCamDB \ + --title "[New Protocol] {PROTOCOL_NAME}" \ + --label "new-protocol" \ + --body "$(cat <<'ISSUE_EOF' +```yaml +protocol: {PROTOCOL_NAME} +default_port: {PORT} +url_format: {EXAMPLE_URL_PATTERN} +``` + +## Description + +{DESCRIPTION -- what cameras use this, what firmware, how it works} + +## Known brands + +- {BRAND1} +- {BRAND2} + +## URL patterns + +- {PATTERN1} -- main stream +- {PATTERN2} -- sub stream + +## Where to research + +- go2rtc source: https://github.com/AlexxIT/go2rtc/tree/master/pkg/{PROTOCOL_NAME} +- ispyconnect: search for "{PROTOCOL_NAME}" cameras + +## Notes + +{ANY_NOTES} +ISSUE_EOF +)" +``` + +If the protocol introduces new placeholders (e.g. `[STREAM]`), create a separate issue: + +```bash +gh issue create --repo eduard256/StrixCamDB \ + --title "[New Placeholder] {PLACEHOLDER}" \ + --label "new-placeholder" \ + --body "$(cat <<'ISSUE_EOF' +placeholder: "{PLACEHOLDER}" +alternatives: ["{alt1}", "{alt2}"] +description: "{WHAT_IT_DOES}" +example_values: ["{VAL1}", "{VAL2}"] + +## URL examples + +- {URL_EXAMPLE_1} +- {URL_EXAMPLE_2} + +## Known brands using this + +- {BRAND1} +- {BRAND2} +ISSUE_EOF +)" +``` + +DO NOT wait for issue approval. Continue immediately to the next step. + +--- + +## STEP 4: Update URL builder (Type A only) + +If the protocol needs a new default port, edit `/home/user/Strix/pkg/camdb/streams.go`: + +Add the port to `defaultPorts` map: +```go +var defaultPorts = map[string]int{ + "rtsp": 554, "rtsps": 322, "http": 80, "https": 443, + "rtmp": 1935, "mms": 554, "rtp": 5004, + // add new protocol here: + "bubble": 80, +} +``` + +If the protocol needs new placeholders in `replacePlaceholders()`, add them to the pairs slice. Follow the existing pattern -- both `[UPPER]` and `[lower]` variants, plus `{curly}` variants. + +### Files to edit for URL builder: +- `/home/user/Strix/pkg/camdb/streams.go` -- `defaultPorts` map and `replacePlaceholders()` function + +--- + +## STEP 5: Add stream handler to tester + +### Before writing code + +1. Read ALL existing handlers in `/home/user/Strix/pkg/tester/source.go` completely +2. Read the go2rtc pkg/ implementation for this protocol (Step 1) +3. Understand what the `Dial()` function needs and returns + +### For standard protocols (Type A, most Type C) + +Most protocols follow the same pattern as RTSP. The handler: +1. Takes a URL string +2. Calls go2rtc's `pkg/{protocol}.Dial(url)` or equivalent +3. Returns `core.Producer` + +Add the handler to `/home/user/Strix/pkg/tester/source.go`. + +**Pattern for simple protocols** (bubble, dvrip, rtmp, kasa, etc.): + +```go +import "github.com/AlexxIT/go2rtc/pkg/{protocol}" + +// in init(): +RegisterSource("{scheme}", {scheme}Handler) + +// handler: +func {scheme}Handler(rawURL string) (core.Producer, error) { + return {protocol}.Dial(rawURL) +} +``` + +If the protocol needs extra setup before Dial (like RTSP needs `Backchannel = false`), add it. Study the go2rtc internal module to see what setup is done. + +**Pattern for protocols that need connection setup** (like RTSP): + +```go +func {scheme}Handler(rawURL string) (core.Producer, error) { + rawURL, _, _ = strings.Cut(rawURL, "#") + + conn := {protocol}.NewClient(rawURL) + // any setup specific to this protocol + + if err := conn.Dial(); err != nil { + return nil, fmt.Errorf("{scheme}: dial: %w", err) + } + + // protocol-specific validation (like RTSP Describe) + + return conn, nil +} +``` + +### For custom protocols (Type B -- homekit, onvif, etc.) + +These protocols do NOT go through the standard URL -> handler flow. They need a **source handler** that receives custom parameters and produces results directly. + +The current architecture uses `SourceHandler func(rawURL string) (core.Producer, error)` for standard protocols. For custom protocols, you need to: + +1. Extend the POST /api/test request to accept custom source blocks +2. Handle them separately from the `streams` array + +Current request format: +```json +{ + "sources": { + "streams": ["rtsp://...", "http://..."] + } +} +``` + +Extended format for custom protocols: +```json +{ + "sources": { + "streams": ["rtsp://...", "http://..."], + "homekit": {"device_id": "AA:BB:CC", "pin": "123-45-678"}, + "onvif": {"host": "192.168.1.100", "username": "admin", "password": "pass"} + } +} +``` + +To implement this: + +1. Define a source handler type in `/home/user/Strix/pkg/tester/source.go`: +```go +// SourceBlockHandler processes a custom source block, writes results directly to session +type SourceBlockHandler func(data json.RawMessage, s *Session) + +var sourceHandlers = map[string]SourceBlockHandler{} + +func RegisterSourceBlock(name string, handler SourceBlockHandler) { + sourceHandlers[name] = handler +} +``` + +2. Update `/home/user/Strix/internal/test/test.go` `apiTestCreate()` to parse and dispatch custom source blocks. + +3. Write the handler for your protocol. It receives raw JSON and a Session, and is responsible for: + - Parsing its own parameters + - Running its own discovery/test logic + - Adding Results to the Session + - Calling `s.AddTested()` for progress tracking + +**IMPORTANT**: Before implementing a custom protocol, discuss the approach with the user. Custom protocols are rare and need careful design. + +--- + +## STEP 6: Test the implementation + +### Build and verify + +```bash +cd /home/user/Strix +go build ./... +``` + +If it compiles, test with the running container: + +```bash +# rebuild image +docker build -t strix:test . + +# restart container +docker rm -f strix +docker run -d --name strix --network host --restart unless-stopped strix:test + +# check logs +docker logs strix + +# test the new protocol (example for bubble) +curl -s -X POST http://localhost:4567/api/test \ + -H 'Content-Type: application/json' \ + -d '{"sources":{"streams":["bubble://admin:password@192.168.1.100:80/"]}}' +``` + +### What to verify + +1. Handler is registered -- check logs for no errors at startup +2. URLs with the new scheme are dispatched to the correct handler +3. If Type A: verify `/api/streams` returns URLs with correct scheme and port +4. Test with a real device if available + +--- + +## STEP 7: Commit and push + +```bash +cd /home/user/Strix +git add -A +git commit -m "Add {protocol} protocol support + +- Register {protocol} stream handler using go2rtc pkg/{protocol} +- Add default port {port} for {protocol} scheme +- {any other changes}" + +git push origin develop +``` + +--- + +## CODE STYLE RULES + +All code MUST follow AlexxIT go2rtc style: + +### File organization +- One handler per protocol is fine in `source.go` if it's a one-liner (`return pkg.Dial(url)`) +- If handler needs >10 lines of custom logic, create `source_{protocol}.go` +- Keep `source.go` as the registry + simple handlers +- Complex protocols get their own file + +### Naming +- Handler: `{scheme}Handler` (e.g. `bubbleHandler`, `rtmpHandler`) +- Error prefix: `"{scheme}: dial: ..."` or `"{scheme}: ..."` +- Short var names: `conn` for connection, `prod` for producer + +### Error handling +- Wrap errors with protocol prefix: `fmt.Errorf("bubble: dial: %w", err)` +- Close/stop connections on error: `_ = conn.Stop()` +- Return nil Producer on error, never a half-initialized one + +### Comments +- Comment ONLY if the "why" is not obvious +- No docstrings on every function +- Inline examples: `// ex. "bubble://admin:pass@192.168.1.100:80/"` + +### Imports +- go2rtc packages: `"github.com/AlexxIT/go2rtc/pkg/{protocol}"` +- Always import `"github.com/AlexxIT/go2rtc/pkg/core"` for Producer interface +- Group: stdlib, then go2rtc, then project packages + +--- + +## go2rtc INTERNALS REFERENCE + +### core.Producer interface (pkg/core/core.go) + +Every protocol handler must return something that implements `core.Producer`: + +```go +type Producer interface { + GetMedias() []*Media // what tracks are available (video/audio codecs) + GetTrack(media *Media, codec *Codec) (*Receiver, error) // get specific track + Start() error // start receiving packets (blocking) + Stop() error // close connection +} +``` + +The tester uses: +1. `GetMedias()` -- to list codecs (H264, AAC, etc.) +2. `GetTrack()` + `Start()` -- to capture screenshot (keyframe) +3. `Stop()` -- to clean up + +### How screenshot works (pkg/tester/worker.go) + +1. `getScreenshot(prod)` is called after successful Dial +2. Creates `magic.NewKeyframe()` consumer +3. Matches video media between producer and consumer +4. Gets track via `prod.GetTrack()` +5. Starts `prod.Start()` in goroutine (blocking -- reads packets) +6. Waits for first keyframe via `cons.WriteTo()` with 10s timeout +7. If H264/H265 -- converts to JPEG via ffmpeg +8. If already JPEG -- uses as-is + +This works automatically for ANY protocol that returns a valid `core.Producer`. You do NOT need to implement screenshot logic per protocol. + +### magic.NewKeyframe() (pkg/magic/keyframe.go) + +Captures first video keyframe from any Producer. Supports H264, H265, JPEG, MJPEG. The tester uses this -- you never call it directly from a protocol handler. + +### Connection patterns in go2rtc + +**Simple Dial** (most protocols): +```go +// pkg/bubble/client.go +func Dial(rawURL string) (core.Producer, error) { + // parse URL, connect, return producer +} +``` + +**Client with setup** (rtsp): +```go +// pkg/rtsp/client.go +conn := rtsp.NewClient(rawURL) +conn.Backchannel = false // optional setup +conn.Dial() // TCP connect +conn.Describe() // RTSP DESCRIBE (gets SDP) +// conn is now a Producer +``` + +**HTTP-based** (complex -- content type detection): +```go +// pkg/magic/producer.go +// Opens HTTP connection, detects Content-Type: +// - multipart/x-mixed-replace -> MJPEG +// - image/jpeg -> single JPEG frame +// - application/vnd.apple.mpegurl -> HLS +// - video/mp2t -> MPEG-TS +// - etc. +``` + +### TCP/TLS connection (pkg/tcp/) + +Many protocols use `pkg/tcp` for low-level connection: +- `tcp.Dial(rawURL)` -- TCP connect with timeout +- `tcp.Client` -- HTTP client with digest/basic auth +- Used by RTSP, HTTP, and others internally + +--- + +## CHECKLIST BEFORE FINISHING + +- [ ] Read all existing protocol handlers in `source.go` +- [ ] Read go2rtc pkg/ and internal/ for this protocol +- [ ] Determined protocol type (A/B/C) +- [ ] For Type A: created StrixCamDB issue (protocol + placeholders if needed) +- [ ] For Type A: added default port to `defaultPorts` in `streams.go` (if not already there) +- [ ] Added handler registration in `source.go` init() or new file +- [ ] Handler follows RTSP pattern: Dial -> return Producer +- [ ] Error messages prefixed with protocol name +- [ ] Connections closed on error +- [ ] Code compiles: `go build ./...` +- [ ] Committed and pushed to develop diff --git a/.claude/skills/release_strix/SKILL.md b/.claude/skills/release_strix/SKILL.md index 0d7a0e8..5cf4a86 100644 --- a/.claude/skills/release_strix/SKILL.md +++ b/.claude/skills/release_strix/SKILL.md @@ -47,7 +47,19 @@ Offer options: Wait for answer. Store the chosen version as VERSION (without "v" prefix). -## Step 3: Verify build +## Step 3: Download latest camera database + +```bash +cd /home/user/Strix +gh release download latest --repo eduard256/StrixCamDB --pattern "cameras.db" --clobber +``` + +Verify the database was downloaded: +```bash +ls -lh cameras.db +``` + +## Step 4: Verify build ```bash cd /home/user/Strix @@ -57,7 +69,7 @@ go build ./... If tests or build fail -- STOP and report the error. Do not continue. -## Step 4: Update CHANGELOG.md +## Step 5: Update CHANGELOG.md Read `/home/user/Strix/CHANGELOG.md`. Add a new section at the top (after the header lines), based on the commits from Step 1. Follow the existing format exactly: @@ -76,7 +88,7 @@ Read `/home/user/Strix/CHANGELOG.md`. Add a new section at the top (after the he Use today's date. Categorize commits into Added/Fixed/Changed/Technical sections. Only include sections that have entries. Write clear, user-facing descriptions (not raw commit messages). -## Step 5: Git -- commit, merge, tag, push +## Step 6: Git -- commit, merge, tag, push ```bash cd /home/user/Strix @@ -94,7 +106,7 @@ git merge main git push origin develop ``` -## Step 6: Build and push Docker image +## Step 7: Build and push Docker image ```bash cd /home/user/Strix @@ -107,7 +119,7 @@ docker buildx build --platform linux/amd64,linux/arm64 \ --push . ``` -## Step 7: Verify Docker Hub +## Step 8: Verify Docker Hub ```bash curl -s "https://hub.docker.com/v2/repositories/eduard256/strix/tags/?page_size=10" | jq '.results[].name' @@ -116,7 +128,7 @@ docker manifest inspect eduard256/strix:$VERSION | jq '.manifests[].platform' Verify the new version tag exists and both amd64 and arm64 platforms are present. -## Step 8: Smoke test +## Step 9: Smoke test ```bash docker run --rm -d --name strix-smoke-test -p 14567:4567 eduard256/strix:$VERSION @@ -127,7 +139,7 @@ docker stop strix-smoke-test Verify the health endpoint returns the correct version string. -## Step 9: Update hassio-strix +## Step 10: Update hassio-strix ```bash cd /home/user/hassio-strix @@ -136,7 +148,7 @@ git pull origin main Edit `/home/user/hassio-strix/strix/config.json` -- change `"version"` to the new VERSION. -Edit `/home/user/hassio-strix/strix/CHANGELOG.md` -- add the same CHANGELOG section as in Step 4. +Edit `/home/user/hassio-strix/strix/CHANGELOG.md` -- add the same CHANGELOG section as in Step 5. ```bash cd /home/user/hassio-strix @@ -145,7 +157,7 @@ git commit -m "Release v$VERSION" git push origin main ``` -## Step 10: GitHub Release +## Step 11: GitHub Release ```bash cd /home/user/Strix @@ -155,7 +167,7 @@ gh release create v$VERSION \ --notes "$(git log --oneline ${PREV_TAG}..v$VERSION)" ``` -## Step 11: Final report +## Step 12: Final report Output a summary: diff --git a/.claude/skills/release_strix_dev/SKILL.md b/.claude/skills/release_strix_dev/SKILL.md index b7b6df9..1880574 100644 --- a/.claude/skills/release_strix_dev/SKILL.md +++ b/.claude/skills/release_strix_dev/SKILL.md @@ -22,21 +22,29 @@ git rev-parse --short HEAD Store this as COMMIT_HASH (e.g. `fe93aa3`). -## Step 2: Build Docker image +## Step 2: Download latest camera database + +```bash +cd /home/user/Strix +gh release download latest --repo eduard256/StrixCamDB --pattern "cameras.db" --clobber +ls -lh cameras.db +``` + +## Step 3: Build Docker image ```bash cd /home/user/Strix docker build --build-arg VERSION=dev-$COMMIT_HASH -t eduard256/strix:dev -t eduard256/strix:dev-$COMMIT_HASH . ``` -## Step 3: Push to Docker Hub +## Step 4: Push to Docker Hub ```bash docker push eduard256/strix:dev docker push eduard256/strix:dev-$COMMIT_HASH ``` -## Step 4: Update hassio-strix +## Step 5: Update hassio-strix ```bash cd /home/user/hassio-strix @@ -52,7 +60,7 @@ git commit -m "Dev build dev-$COMMIT_HASH" git push origin main ``` -## Step 5: Report +## Step 6: Report Output a summary: