5pm 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
data/docs/releasing.md ADDED
@@ -0,0 +1,154 @@
1
+ # Release preparation
2
+
3
+ `bin/package` builds the gem, checks its exact file inventory and MIT license, then
4
+ loads the installed artifact in a subprocess outside Bundler's source checkout.
5
+ It does not publish. `bin/release-check VERSION FULL_COMMIT_SHA` is also read-only.
6
+
7
+ ## Owner-controlled live validation
8
+
9
+ Stan Carver II decides when another live API check is useful. Live API access,
10
+ an active trial or paid subscription, Keychain credentials, and fresh live evidence
11
+ are not release prerequisites. The 15-day trial does not create an ongoing account
12
+ requirement. Releases and CI use offline tests, stored contracts, package verification
13
+ and maintainer acceptance of the exact candidate.
14
+
15
+ Historical reports retain their actual outcomes and tested source. Do not relabel
16
+ an old report as a fresh check. `bin/validate` reuses its local sanitized report
17
+ without expiration or network requests, even if source changes; `source_matches`
18
+ indicates whether its fingerprint still matches. Only an explicit owner-requested
19
+ refresh contacts 5PM. See [cached validation](cached-validation.md).
20
+
21
+ Before a release:
22
+
23
+ 1. Review the source and matching core/MCP versions, and date the changelog.
24
+ 2. Pass `bin/spec`, `bin/lint --cache false`, and `bin/package-mcp` offline.
25
+ 3. Merge reviewed changes through PRs with passing CI. Fetch origin/master and tags.
26
+ 4. Run `bin/release-check VERSION FULL_COMMIT_SHA` from a clean checkout. It checks
27
+ accepted source ancestry, matching versions, the dated changelog and conflicting
28
+ tags. It does not contact 5PM or require `docs/live-validation.json`.
29
+ 5. Rebuild from the accepted source, inspect both artifacts and independently accept
30
+ their hashes. Recheck gem-name availability and obtain maintainer publication
31
+ approval. Publish the exact accepted bytes, core first and then MCP.
32
+
33
+ Optional live checks remain available when the owner requests them. If used, keep
34
+ them outside CI, designate a disposable tenant, and enable mutations separately.
35
+ Record actual results and clean up labelled records; do not invent success receipts.
36
+ See [live validation](live-validation.md). Outstanding API observations can be
37
+ tracked as known issues without imposing a mandatory new live test campaign.
38
+
39
+ ## Connect GitHub and RubyGems once
40
+
41
+ This follows [abbu's release workflow](https://github.com/scarver2/abbu/blob/4c64d0eb0ccdbc0a8c04b2e50da92594faf75d09/.github/workflows/release.yml)
42
+ (browser dispatch, exact commit, separate tag and publish jobs) and
43
+ [printavo's release workflow](https://github.com/scarver2/printavo-ruby/blob/464ae6f29b8ff7627cf829c02351d1b9eadea37d/.github/workflows/release.yml)
44
+ (pinned RubyGems action and Bundler tasks). Both packages publish through
45
+ [trusted publishing](https://guides.rubygems.org/trusted-publishing/), without a
46
+ long-lived RubyGems API key or GitHub PAT in repository secrets.
47
+
48
+ A repository administrator must create the
49
+ [release environment](https://github.com/scarver2/5pm-ruby/settings/environments):
50
+
51
+ - Name: `release`; required reviewer: `scarver2`.
52
+ - Disable administrator bypass. Allow the initiating maintainer to approve, matching abbu.
53
+ - Select deployment branches and tags: branch `master` and tag `v*`, only.
54
+
55
+ Both owner prerequisites remain required: this protected GitHub `release`
56
+ environment and RubyGems trusted publishers for **both** `5pm` and `5pm-mcp`.
57
+ Workflow environment variables only describe execution context and the selected
58
+ candidate; they are not independent authorization. GitHub's required-reviewer
59
+ approval gate and RubyGems' matching OIDC publisher policies enforce authorization.
60
+
61
+ The workflow checks these protections before building or tagging. Missing protection
62
+ or inaccessible environment metadata stops the run. The current GitHub connection
63
+ cannot create this environment: GitHub returned HTTP 403, administrator rights required.
64
+
65
+ Sign into RubyGems.org and create two
66
+ [pending trusted publishers](https://rubygems.org/profile/oidc/pending_trusted_publishers)
67
+ for the first publication:
68
+
69
+ | Gem name | GitHub owner | Repository | Workflow filename | Environment |
70
+ | --- | --- | --- | --- | --- |
71
+ | `5pm` | `scarver2` | `5pm-ruby` | `release.yml` | `release` |
72
+ | `5pm-mcp` | `scarver2` | `5pm-ruby` | `release.yml` | `release` |
73
+
74
+ These are separate gem registrations using the same workflow identity. For an
75
+ already-owned gem, configure its trusted publisher instead of a pending publisher.
76
+ Recheck both names for availability before publication. Account setup must
77
+ be completed by the authenticated RubyGems owner; repository configuration alone
78
+ does not establish that connection.
79
+
80
+ ## Shared bin-script conventions
81
+
82
+ Like abbu and printavo, Bash entrypoints source `bin/_lib.sh`, change to the
83
+ repository root, and select Ruby through mise when available. Run them directly:
84
+
85
+ ```sh
86
+ bin/spec
87
+ bin/lint
88
+ bin/package
89
+ bin/package-mcp
90
+ bin/release-check VERSION FULL_COMMIT_SHA
91
+ ```
92
+
93
+ `bin/package` writes `pkg/5pm-VERSION.gem`; `bin/package-mcp` also writes
94
+ `mcp/pkg/5pm-mcp-VERSION.gem`. Inventory checks live in `bin/verify-package.rb`;
95
+ package installation, smoke checks, and release logic live in Ruby helpers under
96
+ `bin/support/`, following printavo's layout. Temporary installs are removed on exit.
97
+ Dependencies come from the verified bundle, as in abbu, without network installation.
98
+
99
+ `bin/verify-release-tag` reads `RELEASE_TAG_REF` (or `GITHUB_REF` on tag events), requires `refs/tags/vVERSION`, and
100
+ verifies that the tag matches HEAD and passes the accepted-commit preflight.
101
+ It is read-only. Publication remains the protected workflow's `rake release`.
102
+
103
+ ## Build and approve a release
104
+
105
+ Standard Bundler tasks build the core and MCP artifacts respectively:
106
+
107
+ ```sh
108
+ bundle exec rake build
109
+ bundle exec rake -f mcp/Rakefile build
110
+ bundle exec rake
111
+ bundle exec rubocop --cache false
112
+ bin/package-mcp
113
+ ```
114
+
115
+ The packaging checks inspect the actual installed artifacts. CI repeats validation
116
+ on Ruby 3.2, 3.3, 3.4 and 4.0. Release builds set `SOURCE_DATE_EPOCH` to the approved
117
+ commit timestamp. Neither build task publishes.
118
+
119
+ Before dispatch, complete the offline checks and dated changelog above. Both
120
+ gem versions must match. Live reports are optional historical evidence. Preparing
121
+ this workflow does not advance the version, date the changelog or authorize publication.
122
+
123
+ After merging the workflow, open Actions → **Release gems** → **Run workflow** on
124
+ `master`. Enter the explicitly approved stable version without `v` and its full
125
+ 40-character accepted commit SHA. The read-only preflight rejects an unaccepted
126
+ commit, a dirty checkout, an undated changelog, mismatched versions or a conflicting tag.
127
+ Tag pushes never start publication. The only release entry point is manual dispatch
128
+ from `master`; dispatch from any other branch or tag is rejected. Version and full
129
+ commit inputs must identify the explicitly approved candidate.
130
+
131
+ Approve the protected tag job only after inspecting the candidate summary and
132
+ verification jobs. It creates `vVERSION` only if absent and verifies its exact target;
133
+ it never moves an existing tag. Approve each protected publication job for that same
134
+ candidate. Publishing has OIDC permission and read-only repository contents; only
135
+ the separate tag job can write repository contents. Bundler's source-control push
136
+ action is disabled. Local publishing tasks refuse to run outside this workflow.
137
+
138
+ The pinned `rubygems/release-gem` action invokes `bundle exec rake release` for the
139
+ core gem, waits for RubyGems availability, then does the same in `mcp/`. The MCP
140
+ package depends on the matching core version. The workflow does not create a GitHub
141
+ Release. No workflow should be dispatched merely to test the connection: a successful
142
+ run publishes both gems.
143
+
144
+ If core publication succeeds and MCP fails, inspect the published core version and
145
+ provenance, fix the cause, and rerun only the failed jobs against the same approved
146
+ candidate. Do not rerun an already-successful publish job, move the tag, overwrite a
147
+ published version or automatically yank it. Code changes require a new reviewed
148
+ candidate and fresh evidence; arrange a new version when the core is already published.
149
+
150
+
151
+ —
152
+ Stan Carver II
153
+ Made in Texas 🤠
154
+ https://stancarver.com
@@ -0,0 +1,12 @@
1
+ require "five_pm"
2
+
3
+ # Environment variables can be injected by Infisical or another process secret provider.
4
+ client = FivePM::Client.new(host: ENV.fetch("FIVE_PM_HOST"), login: ENV.fetch("FIVE_PM_LOGIN"),
5
+ password: ENV.fetch("FIVE_PM_PASSWORD"), response_mode: :typed)
6
+ begin
7
+ # One bounded request; returned account content may be sensitive.
8
+ response = client.tasks.list(filter: { projectId: Integer(ENV.fetch("FIVE_PM_PROJECT_ID")) }, count: 20)
9
+ response.list.each { |task| puts "#{task.id}: #{task.name}" }
10
+ ensure
11
+ client.sign_out
12
+ end
@@ -0,0 +1,145 @@
1
+ require "json"
2
+
3
+ module FivePM
4
+ OPERATIONS = JSON.parse(File.read(File.join(__dir__, "operations.json")))
5
+ SERVICE_NAMES = { authentication: "authentication", projects: "projects", project_groups: "projectsgroups",
6
+ tasks: "tasks", activity: "activity", users: "users" }.freeze
7
+
8
+ class Service
9
+ def initialize(client, name)
10
+ @client = client
11
+ @name = name
12
+ end
13
+
14
+ # Parameter names follow the upstream contract exactly; unknown fields inside
15
+ # complex objects are preserved. Responses remain intact until JSON is verified.
16
+ def call(operation, **parameters)
17
+ @client.call(@name, operation, **parameters)
18
+ end
19
+
20
+ def inspect
21
+ "#<FivePM::Service #{@name}>"
22
+ end
23
+ end
24
+
25
+ require_relative "resource"
26
+ require_relative "services"
27
+
28
+ class Client
29
+ SESSION_TTL = 30 * 60
30
+
31
+ def initialize(host:, login:, password:, open_timeout: 5, read_timeout: 30, write_timeout: 30,
32
+ response_mode: :raw, read_retries: 0, max_retry_delay: 30, max_response_bytes: 5 * 1024 * 1024,
33
+ transport: nil, clock: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) })
34
+ unless [login, password].all? { |value| value.is_a?(String) && !value.empty? }
35
+ raise ConfigurationError, "login and password must be nonempty strings"
36
+ end
37
+
38
+ raise ConfigurationError, "response_mode must be :raw or :typed" unless %i[raw typed].include?(response_mode)
39
+
40
+ @retry_policy = RetryPolicy.new(max_retries: read_retries, max_delay: max_retry_delay)
41
+ @response_mode = response_mode
42
+ validated = Transport.new(host: host, open_timeout: open_timeout, read_timeout: read_timeout,
43
+ write_timeout: write_timeout, max_response_bytes: max_response_bytes)
44
+ @transport = transport || validated
45
+ @login = login.dup.freeze
46
+ @password = password.dup.freeze
47
+ @clock = clock
48
+ @mutex = Mutex.new
49
+ @services = SERVICE_NAMES.to_h do |name, wire|
50
+ type = { authentication: Authentication, users: Users, activity: Activity }.fetch(name, Resource)
51
+ [name, type.new(self, wire)]
52
+ end
53
+ end
54
+
55
+ SERVICE_NAMES.each_key { |name| define_method(name) { @services.fetch(name) } }
56
+
57
+ def call(service, operation, **parameters)
58
+ contract = OPERATIONS.dig(service.to_s, operation.to_s)
59
+ raise ArgumentError, "unsupported 5pm operation" unless contract
60
+ if service.to_s == "authentication" && %w[signIn signOut].include?(operation.to_s)
61
+ raise ArgumentError, "use Client#sign_in or Client#sign_out"
62
+ end
63
+
64
+ params = parameters.transform_keys(&:to_s)
65
+ allowed = contract.fetch("parameters").keys - %w[sessionId login password]
66
+ raise ArgumentError, "unsupported parameter names" unless (params.keys - allowed).empty?
67
+
68
+ Request.operation!(service.to_s, operation.to_s, params)
69
+ token = @mutex.synchronize { session_id } if contract.fetch("parameters").key?("sessionId")
70
+ params["sessionId"] = token if token
71
+ binary = service.to_s == "activity" && operation.to_s == "downloadFile"
72
+ begin
73
+ payload = @retry_policy.call(service.to_s, operation.to_s) do
74
+ binary ? @transport.download_file(params) : @transport.post(service.to_s, operation.to_s, params)
75
+ end
76
+ rescue AuthenticationError
77
+ @mutex.synchronize { @session_id = nil if @session_id == token }
78
+ raise
79
+ end
80
+ return payload if binary
81
+
82
+ # Injected transports may return unchecked JSON. Keep this public boundary
83
+ # safe as well as Transport's direct-call boundary.
84
+ APIResult.check!(payload)
85
+ @response_mode == :typed ? Response.new(payload, contract: contract) : payload
86
+ end
87
+
88
+ def sign_in
89
+ @mutex.synchronize do
90
+ @session_id = nil
91
+ session_id
92
+ end
93
+ self
94
+ end
95
+
96
+ def download_file(id)
97
+ call("activity", "downloadFile", id: id, stream: false)
98
+ end
99
+
100
+ def sign_out
101
+ @mutex.synchronize do
102
+ return false unless @session_id
103
+
104
+ begin
105
+ payload = @transport.post("authentication", "signOut", { "sessionId" => @session_id })
106
+ APIResult.check!(payload)
107
+ contract = OPERATIONS.fetch("authentication").fetch("signOut")
108
+ @response_mode == :typed ? Response.new(payload, contract: contract) : payload
109
+ ensure
110
+ @session_id = nil
111
+ end
112
+ end
113
+ end
114
+
115
+ def marshal_dump
116
+ raise TypeError, "FivePM clients cannot be serialized"
117
+ end
118
+
119
+ def inspect
120
+ "#<FivePM::Client>"
121
+ end
122
+
123
+ private
124
+
125
+ def session_id
126
+ return @session_id if @session_id && @clock.call < @expires_at
127
+
128
+ @session_id = nil
129
+ started = @clock.call
130
+ response = @transport.post("authentication", "signIn", { "login" => @login, "password" => @password })
131
+ APIResult.check!(response)
132
+ # The test tenant returns data.sessionId; retain the WSDL-shaped envelope
133
+ # for compatibility. Reject ambiguous responses rather than picking a token.
134
+ envelopes = response.is_a?(Hash) ? %w[data auth].select { |key| response.key?(key) } : []
135
+ envelope = response[envelopes.first] if envelopes.length == 1
136
+ token = envelope["sessionId"] if envelope.is_a?(Hash)
137
+ unless token.is_a?(String) && !token.empty?
138
+ raise ProtocolError, "5pm signIn response did not contain an unambiguous sessionId"
139
+ end
140
+
141
+ @expires_at = started + SESSION_TTL - 30
142
+ @session_id = token
143
+ end
144
+ end
145
+ end
@@ -0,0 +1,16 @@
1
+ module FivePM
2
+ # Explicit access to bounded binary data; inspection never includes its contents.
3
+ class Download
4
+ attr_reader :bytes, :content_type
5
+
6
+ def initialize(bytes:, content_type:)
7
+ @bytes = bytes.b.freeze
8
+ @content_type = content_type&.dup&.freeze
9
+ freeze
10
+ end
11
+
12
+ def inspect
13
+ "#<FivePM::Download>"
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,73 @@
1
+ module FivePM
2
+ class Error < StandardError; end
3
+ class ConfigurationError < Error; end
4
+ class TransportError < Error; end
5
+ class ProtocolError < Error; end
6
+
7
+ class SchemaError < ProtocolError
8
+ attr_reader :field, :expected_type, :actual_type
9
+
10
+ def initialize(field:, expected_type:, value:)
11
+ @field = field.dup.freeze
12
+ @expected_type = expected_type.dup.freeze
13
+ @actual_type = case value
14
+ when Hash then "object"
15
+ when Array then "array"
16
+ when String then "string"
17
+ when Numeric then "number"
18
+ when true, false then "boolean"
19
+ when nil then "null"
20
+ else "unknown"
21
+ end.freeze
22
+ super("5pm response contains an invalid schema field")
23
+ end
24
+ end
25
+
26
+ class AuthenticationError < Error; end
27
+
28
+ class APIError < Error
29
+ attr_reader :code
30
+
31
+ def initialize(code)
32
+ @code = code
33
+ super("5pm API request failed (code #{code})")
34
+ end
35
+ end
36
+
37
+ module APIResult
38
+ def self.check!(payload)
39
+ if payload.is_a?(Hash) && payload["code"].is_a?(Integer) && payload["message"].is_a?(String) &&
40
+ (!payload.key?("data") || payload["code"] >= 400)
41
+ raise APIError, payload.fetch("code")
42
+ end
43
+
44
+ payload
45
+ end
46
+ end
47
+
48
+ class HTTPError < Error
49
+ attr_reader :status
50
+
51
+ def initialize(status)
52
+ @status = status
53
+ super("5pm HTTP request failed (#{status})")
54
+ end
55
+ end
56
+
57
+ class RateLimitError < HTTPError
58
+ attr_reader :retry_after
59
+
60
+ def initialize(retry_after: nil, retryable: true)
61
+ @retry_after = retry_after
62
+ @retryable = retryable
63
+ super(429)
64
+ end
65
+
66
+ def retryable?
67
+ @retryable
68
+ end
69
+ end
70
+
71
+ class NotFoundError < HTTPError; end
72
+ class ServerError < HTTPError; end
73
+ end