Skip to content

feat(ci): add Slack notifications for build success/failure#14

Merged
justin-layerv merged 2 commits into
mainfrom
feat/slack-notifications
Apr 10, 2026
Merged

feat(ci): add Slack notifications for build success/failure#14
justin-layerv merged 2 commits into
mainfrom
feat/slack-notifications

Conversation

@justin-layerv
Copy link
Copy Markdown
Contributor

Summary

qurl-python had no Slack notifications. Adds a notify job matching the standardized format across all LayerV repos.

Notification format

:white_check_mark: *QURL Python SDK Build* successful

Branch:   main (abc1234)     Trigger:  Push by username
Tests:    :white_check_mark:           Python:   3.10, 3.12, 3.13

Commit: `feat: add response validation`

abc1234  |  View workflow

Checklist

  • Verify SLACK_WEBHOOK_URL secret exists in qurl-python repo settings
  • Merge and push to main to trigger a real notification

🤖 Generated with Claude Code

Adds a notify job matching the standardized format used across
qurl-service, nhp, website, and traefik-plugins repos.

Header: "QURL Python SDK Build successful/failed"
Fields: branch, trigger, test status, Python versions
Footer: commit SHA link + workflow link

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@justin-layerv justin-layerv self-assigned this Apr 10, 2026
@claude
Copy link
Copy Markdown

claude Bot commented Apr 10, 2026

PR Review: feat(ci): add Slack notifications for build success/failure

Overall

Straightforward addition of a Slack notification job to the CI workflow. The approach is reasonable — single inline shell step, graceful skip when the secret is missing, continue-on-error: true so notifications never block the pipeline.

Issues

1. Script injection via expressions in run block (Security — Medium)

COMMIT_MESSAGE is set via env: (good), but github.ref_name, github.actor, github.server_url, github.repository, github.run_id, and github.sha are interpolated directly via expression syntax inside the run: block. A crafted branch name containing backticks or command substitution could inject shell commands in the workflow context.

Recommendation: Move all expression interpolations into env: variables (like you already do for COMMIT_MESSAGE), then reference them as shell variables in the script. This is the standard mitigation for GitHub Actions script injection.

2. Python version list is hardcoded (Maintainability — Low)

The notification hardcodes Python: 3.10, 3.12, 3.13 but the actual matrix is defined separately in the test job. If the matrix changes, this will silently drift. Worth a comment to keep in sync with the test matrix at minimum.

3. JSON construction via string concatenation (Robustness — Low)

Building JSON by concatenating shell strings is fragile. The sed escaping on the commit message handles backslashes and double quotes but misses newlines, tabs, and control characters. Consider using jq (pre-installed on ubuntu-latest) to build the payload, which handles all escaping correctly.

Looks Good

  • if: always() && github.event_name != 'pull_request' — correct gate.
  • continue-on-error: true and timeout-minutes: 5 — good defensive defaults.
  • Graceful exit when SLACK_WEBHOOK_URL is not set.
  • curl -sfS flags — appropriate for CI.

Summary

The main item to address before merging is issue 1 (script injection via expression interpolation in run: blocks). This is a well-documented GitHub Actions security pitfall. The rest are minor improvements.

Expression interpolation in run: blocks is vulnerable to injection
via crafted branch names containing shell metacharacters. Move all
github.* expressions to env: variables.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@justin-layerv
Copy link
Copy Markdown
Contributor Author

CR feedback addressed

Fixed

  • Script injection via expression interpolation — Moved all github.* context expressions from run: block into env: variables. A crafted branch name like `curl attacker.com` could have executed arbitrary commands when interpolated directly in the shell script.

Deferred

  • Hardcoded Python versions — Low priority, comment-level concern. The matrix is 3 lines above the notification.
  • jq for JSON construction — Consistent with all other repos' notification patterns.

@claude
Copy link
Copy Markdown

claude Bot commented Apr 10, 2026

PR Review - feat(ci): add Slack notifications. Overall well-structured. Security is good - github context in env vars prevents injection. Potential issues: (1) hardcoded Python versions will go stale if matrix changes, (2) JSON via bash string concat is fragile but acceptable with continue-on-error, (3) HEADER on line 92 missing Build. Looks good to merge once SLACK_WEBHOOK_URL secret is confirmed.

@claude
Copy link
Copy Markdown

claude Bot commented Apr 10, 2026

Detailed Review

Security: GitHub context in env vars prevents shell injection via crafted branch names or actor names. Webhook URL handling gracefully exits when secret is missing. Commit message sanitization (line 128) escapes backslashes and quotes, but newlines/control characters could break JSON - consider jq -Rs.

Issue 1 - Hardcoded Python versions (line 121): The version string is hardcoded but the matrix is in the test job. If the matrix changes, notifications will be stale. Add a sync comment.

Issue 2 - JSON string concatenation: Fragile but acceptable with continue-on-error. Consider jq if expanding.

Issue 3 - Matrix result semantics: needs.test.result aggregates all matrix jobs correctly (success = all pass).

Nit: HEADER on line 92 says QURL Python SDK but other branches say QURL Python SDK Build - missing word Build for incomplete case.

Nit: curl write-out with http_code would help debug notification failures.

Good: timeout-minutes + continue-on-error, skips PR builds, clean job separation.

Verdict: Looks good to merge. Security is well-handled, continue-on-error prevents CI blocking.

@justin-layerv justin-layerv merged commit 5687eda into main Apr 10, 2026
5 checks passed
@justin-layerv justin-layerv deleted the feat/slack-notifications branch April 10, 2026 20:17
justin-layerv added a commit that referenced this pull request Apr 11, 2026
Brings the Python SDK into parity with every improvement made to
qurl-typescript and qurl-mcp during the recent review and seam-audit
rounds. Cross-references the qurl-service OpenAPI spec
(qurl/api/openapi.yaml) and the Go handler code.

### Critical — real bug

* parse_error detail fallback. RFC 7807 leaves `detail` optional and
  the qurl Error schema only requires type/title/status/code.
  Previously the parser used `err.get("detail", "")`, producing
  "Forbidden (403): " when the API omitted detail. Now falls back
  `detail -> message -> title -> HTTP {status}`. QURLError also
  defaults detail to title in its constructor so Exception.args is
  never empty-string padded.

### RFC 7807 structured fields

* QURLError now carries `type` and `instance` (the problem-type URI
  and occurrence URI). Both are optional per the spec; the SDK was
  silently dropping them before.
* parse_error extracts both from the envelope.

### Backward compatibility

* Legacy `{error: {code, message}}` envelope supported in the
  fallback chain. If the API ever regresses to the pre-RFC-7807
  shape, the SDK degrades gracefully instead of showing empty detail.

### Type narrowing

* QURLStatus clarified as resource-only ("active" | "revoked" | str).
* New TokenStatus for AccessToken ("active" | "consumed" | "expired"
  | "revoked" | str) — per QurlSummary.status in the spec, tokens
  have a wider enum than resources.
* AccessToken.status now uses TokenStatus.
* New QuotaPlan ("free" | "growth" | "enterprise" | str); Quota.plan
  uses it. Uses the (Literal | str) pattern so the API can add new
  plans without a breaking SDK change.

### Spec-derived input validation

New validate_create_input / validate_update_input / validate_mint_input
helpers in _utils.py enforcing the constraints documented on each
request schema in openapi.yaml:

  - target_url: maxLength 2048
  - label: maxLength 500 (on create + mint_link)
  - description: maxLength 500 (on update)
  - custom_domain: maxLength 253 (on create)
  - max_sessions: 0-1000 integer (on create + mint_link)
  - tags: max 10, each 1-50 chars, regex ^[a-zA-Z0-9][a-zA-Z0-9 _-]*$

batch_create runs validate_create_input on every item and attributes
errors by index (`items[N]: ...`) so bulk mistakes fail fast.

### Mutual-exclusion pre-flight checks

* update: rejects both extend_by + expires_at
* update: rejects empty input (at least one field required)
* mint_link: rejects both expires_in + expires_at
  Extend() inherits the update() checks via delegation.

### delete() r_ prefix enforcement

Per the OpenAPI spec DELETE /v1/qurls/:id description: "Requires a
resource ID (r_ prefix). To revoke a single token, use DELETE
/v1/resources/:id/qurls/:qurl_id". New require_resource_id_prefix
helper raises ValueError client-side for q_ IDs with a clear message
pointing at the token-scoped endpoint.

### batch_create HTTP 400 passthrough

The API returns a populated BatchCreateOutput body on HTTP 400 (all
items rejected) — see qurl/internal/api/handlers/server.go:1126.
Added `allow_statuses` to _raw_request and _request, and batch_create
whitelists 400 so the per-item errors are surfaced instead of being
swallowed by the generic raise-on-error path. Non-400 errors (401,
403, 429, 5xx) still raise the appropriate QURLError subclass.
Matches the qurl-typescript and qurl-mcp implementations.

### create() parameter cleanup

Dropped the spurious `expires_at` kwarg from both sync and async
create(). CreateQurlRequest in openapi.yaml has only `expires_in` —
the previous signature let callers pass a field the API doesn't
accept.

### Dual-prefix documentation

get/update/extend/mint_link docstrings now document that both r_
(resource) and q_ (QURL display) IDs are accepted; the API resolves
q_ IDs to the parent resource automatically. delete() stays narrow
(r_ only) matching its client-side enforcement.

### parse_create_output: normalize empty qurl_id to None

Empty-string qurl_id from a response (mock or legacy shape) is now
normalized to None so callers can use `if result.qurl_id:` as a
presence check instead of having "" be silently truthy-false.

### _serialize_value: stop stripping None from nested dicts

Previously the dict branch filtered out None values, which would
silently drop explicit nulls callers send to clear nested fields
(e.g. `{"access_policy": {"ai_agent_policy": null}}`). Top-level
None-stripping still happens in build_body since that serves the
"drop unset kwargs" case. Nested None is now preserved; dataclass
fields still skip None (dataclasses distinguish unset vs explicit).

### Misc

* build_list_params type annotation tightened — the `int | None`
  arm was misordered in the old union.
* test_update_with_tags corrected to use spec-compliant tags
  (previous test used `team:engineering` with a colon that the
  ^[a-zA-Z0-9][a-zA-Z0-9 _-]*$ regex rejects).
* test_batch_create_empty_raises regex updated for the new error
  message ("requires at least 1 item").
* test_create_sends_correct_body now covers one_time_use,
  max_sessions, and session_duration alongside label (reviewer #9
  gap note).

### Tests (74 -> 101)

Twenty-seven new tests covering:
  - Create rejection: target_url > 2048, label > 500,
    custom_domain > 253, max_sessions > 1000, max_sessions < 0
  - Create boundaries: max_sessions 0 and 1000 both accepted
  - Update rejection: description > 500, > 10 tags, tag > 50 chars,
    tag regex pattern mismatch, empty input, mutual-exclusion
  - Update success: empty tags array clears all tags
  - mint_link rejection: label > 500, max_sessions > 1000,
    mutual-exclusion
  - delete q_ prefix rejection
  - batch_create per-item validation with index attribution
  - batch_create missing target_url surfaces index
  - Async batch_create empty/>100 (reviewer #7 symmetry gap)
  - batch_create HTTP 400 passthrough with per-item errors
  - batch_create still raises on 401 (passthrough is surgical)
  - Error type/instance surfacing
  - Error detail fallback when RFC 7807 detail missing
  - Legacy error.message fallback
  - parse_create_output empty qurl_id normalization

BREAKING CHANGE: `active_qurls_percent` on `Quota.usage` is now
`float | None` instead of `float` with a `0.0` default; callers
doing arithmetic must None-check. Also `create()` no longer accepts
an `expires_at` kwarg — that field wasn't in `CreateQurlRequest`.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
justin-layerv added a commit that referenced this pull request Apr 12, 2026
6 fix-now items (2 defensive bug fixes, 1 signature improvement,
3 test coverage gaps), 4 deferrals (2 filed as issues).

Fixed:
- _parse_access_policy: add isinstance(ap, dict) guard against non-dict
  ai_agent_policy values. Without this, a bare string/bool from the API
  would raise AttributeError on .get("block_all"). Consistent with the
  defensive posture in _validate_batch_create_shape.
- QURLError.detail: change default from str="" to str|None=None with
  `detail if detail is not None else title`. Distinguishes "not provided"
  (falls back to title) from "explicitly empty" (stored as-is). The old
  `detail or title` coercion silently converted `detail=""` to title,
  which surprised callers who explicitly passed empty string.
- build_list_params: client-side limit validation (1-100) per OpenAPI
  spec (GET /v1/qurls → limit: integer, minimum: 1, maximum: 100,
  default: 20). Rejects 0, negative, floats, and bool (bool is subclass
  of int — must be explicitly rejected). Python-parity follow-up from
  qurl-typescript PR #14 round 11 (commit 3d471fa).

Tests: 144 → 157 passing (+13 new regression guards).
- batch_create 207 Multi-Status routes through success path (sync+async)
- create(expires_at=...) TypeError invariant guard (sync+async)
- _parse_access_policy null/missing ai_agent_policy yields None
- _parse_access_policy non-dict ai_agent_policy is silently ignored
- list() rejects limit: 0 / 101 / negative / float / bool
- list() accepts limit at boundaries (1 and 100)
- list() omitted limit produces no query param

Deferred:
- sync/async duplication refactor — filed as #20
- test_client.py split — filed as #21
- QURLError.type builtin shadow — docstring already acknowledges
- http:// URL scheme warning — caller's responsibility per reviewer
justin-layerv added a commit that referenced this pull request Apr 18, 2026
Drops the bespoke uv-based pyproject.toml age-check (introduced
in #14) in favor of the shared ops-routines age-check-pip
reusable workflow pinned at v0.1.4. The shared script now parses
PEP 621 list form (dependencies = ["pkg==ver"]) directly — no uv
resolver invocation needed.

This finishes the migration started in the prior commit:
- dependency-age-check-actions.yml -> shared actions check
- dependency-age-check-pip.yml -> shared pip check (NEW)
- dependency-age-check.yml (legacy) -> deleted

Centralizes the age-gate so all LayerV repos run the same hardened
implementation; this repo inherits future fixes for free.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant