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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
102 changes: 102 additions & 0 deletions .github/scripts/maintenance-alert.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
const tracked = new Map([
[
"Command Code catalog sync",
{
key: "catalog",
title: "Maintenance: repeated catalog sync failures",
action:
"Inspect the catalog/parser and pricing reports separately. Reproduce with npm run check:commandcode-catalog and npm run check:commandcode-pricing. Review CLI schema changes and official pricing; never automatically copy or zero unknown prices.",
},
],
[
"Latest host compatibility",
{
key: "hosts",
title: "Maintenance: repeated latest-host compatibility failures",
action:
"Inspect the recorded Pi/OMP versions and failing mock suite. Reproduce with those exact hosts, compare the pinned CI baseline, and add a regression test before changing the supported pins.",
},
],
])

/** Uses only trusted workflow identity/conclusions, never logs or artifact text. */
export async function maintainAlert({ github, context }) {
const run = context.payload.workflow_run
const repo = context.repo
const defaultBranch = context.payload.repository.default_branch
const config = tracked.get(run?.name)
if (
!config ||
run.status !== "completed" ||
run.head_branch !== defaultBranch ||
run.repository?.full_name !== `${repo.owner}/${repo.repo}` ||
run.head_repository?.full_name !== `${repo.owner}/${repo.repo}` ||
!["schedule", "workflow_dispatch"].includes(run.event)
)
return "ignored"
const marker = `<!-- commandcode-maintenance:${config.key} -->`
const recent = await github.paginate(github.rest.actions.listWorkflowRuns, {
...repo,
workflow_id: run.workflow_id,
branch: defaultBranch,
status: "completed",
per_page: 100,
})
const eligible = recent

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The threshold relies on listWorkflowRuns already containing the triggering run as completed. workflow_run.completed can fire before the listing API reflects that (eventual consistency). If the current run is missing, eligible[0]/eligible[1] are the two previous runs and the decision is off by one in either direction.

Make it explicit: treat run as the head and take the newest eligible item with run_number < run.run_number as the predecessor, independent of whether the API already lists run.

.filter(
(item) =>
item.run_number <= run.run_number &&
["schedule", "workflow_dispatch"].includes(item.event) &&
item.head_repository?.full_name === `${repo.owner}/${repo.repo}`,
)
.sort((a, b) => b.run_number - a.run_number)
// An older completion/rerun must not overwrite a newer incident or recovery.
if (
recent.some(
(item) =>
item.run_number > run.run_number && ["schedule", "workflow_dispatch"].includes(item.event),
)
)
return "stale"
const issues = await github.paginate(github.rest.issues.listForRepo, {

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

state: "all" + creator paginates every bot-created issue forever. Add a dedicated label (e.g. maintenance-alert) on create and filter by labels here — cheaper and visible to humans in the issue list.

Same idea for listWorkflowRuns above: you only need runs up to the predecessor; github.paginate supports an early done() callback.

...repo,
state: "all",
creator: "github-actions[bot]",
per_page: 100,
})
const issue = issues.find(
(item) =>
!item.pull_request &&
item.body?.startsWith(marker) &&
item.user?.login === "github-actions[bot]",
)
if (run.conclusion === "success") {
if (issue?.state === "open") {
await github.rest.issues.update({
...repo,
issue_number: issue.number,
state: "closed",
state_reason: "completed",
body: `${marker}\n\nRecovered in [run ${run.run_number}](https://github.com/${repo.owner}/${repo.repo}/actions/runs/${run.id}).\n\n${config.action}`,

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Recovery overwrites the body, so the failing-run links disappear from the issue once it is closed. A short comment ("Recovered in run N") plus close keeps the history — that is one comment per incident, not spam.

})
return "closed"
}
return "healthy"
}
const failed = (item) => ["failure", "timed_out"].includes(item?.conclusion)
if (!failed(run) || eligible.length < 2 || !failed(eligible[0]) || !failed(eligible[1]))
return "below-threshold"
const body = `${marker}\n\nTwo consecutive scheduled/manual default-branch runs failed.\n\n- Latest: [run ${run.run_number}](https://github.com/${repo.owner}/${repo.repo}/actions/runs/${run.id})\n- Previous: [run ${eligible[1].run_number}](https://github.com/${repo.owner}/${repo.repo}/actions/runs/${eligible[1].id})\n\n${config.action}\n\nThis issue is updated rather than duplicated and closes after a successful run. No logs, payloads or credentials are copied here. This alert does not authorize a release.`
if (issue) {
await github.rest.issues.update({
...repo,
issue_number: issue.number,
state: "open",
title: config.title,
body,
})
return "updated"
}
await github.rest.issues.create({ ...repo, title: config.title, body, assignees: [repo.owner] })
return "created"
}
36 changes: 36 additions & 0 deletions .github/workflows/maintenance-alert.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
name: Maintenance alerts

on:
workflow_run:
workflows: [Command Code catalog sync, Latest host compatibility]
types: [completed]

permissions:
contents: read
actions: read
issues: write

concurrency:
group: commandcode-maintenance-alerts
cancel-in-progress: false

jobs:
alert:
if: >-
github.repository == 'patlux/pi-commandcode-provider' &&
github.event.workflow_run.head_branch == github.event.repository.default_branch &&
github.event.workflow_run.head_repository.full_name == github.repository &&
(github.event.workflow_run.event == 'schedule' || github.event.workflow_run.event == 'workflow_dispatch')
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
# Always load trusted default-branch code, never the triggering run's ref/artifacts.
- uses: actions/checkout@v4
with:
ref: ${{ github.event.repository.default_branch }}
persist-credentials: false
- uses: actions/github-script@v7
with:
script: |
const { maintainAlert } = await import(`${process.env.GITHUB_WORKSPACE}/.github/scripts/maintenance-alert.mjs`)
core.info(await maintainAlert({ github, context }))
16 changes: 9 additions & 7 deletions .github/workflows/model-metadata.yml
Original file line number Diff line number Diff line change
Expand Up @@ -45,14 +45,15 @@ jobs:
- run: npm ci
- name: Compare with the latest Command Code CLI
shell: bash
run: npm run check:commandcode-catalog | tee commandcode-catalog-report.md
run: npm run check:commandcode-catalog 2>&1 | tee commandcode-catalog-report.md

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

2>&1 into the Markdown report also captures npm's own stderr (npm warn, the npm error banner on failure) into the step summary. If the intent is to keep the script's stderr, run tsx directly or use npm run --silent.

- name: Publish catalog report
if: always()
run: cat commandcode-catalog-report.md >> "$GITHUB_STEP_SUMMARY"
run: |
if [ -f commandcode-catalog-report.md ]; then cat commandcode-catalog-report.md >> "$GITHUB_STEP_SUMMARY"; fi
- name: Compare official pricing
if: always()
shell: bash
run: npm run check:commandcode-pricing | tee commandcode-pricing-report.md
run: npm run check:commandcode-pricing 2>&1 | tee commandcode-pricing-report.md
- name: Publish pricing report
if: always()
run: |
Expand All @@ -75,17 +76,18 @@ jobs:
- run: npm ci
- name: Synchronize with the latest Command Code CLI
shell: bash
run: npm run sync:commandcode-catalog | tee commandcode-catalog-report.md
run: npm run sync:commandcode-catalog 2>&1 | tee commandcode-catalog-report.md
- name: Format and verify synchronized files
run: |
npm run format -- src/commandcode-catalog.ts src/commandcode-catalog-overrides.ts README.md
npx --no-install prettier --write src/commandcode-catalog.ts src/commandcode-catalog-overrides.ts README.md
npm run typecheck
npm run test:models
npm run format:check
git diff --check
- name: Publish catalog report
if: always()
run: cat commandcode-catalog-report.md >> "$GITHUB_STEP_SUMMARY"
run: |
if [ -f commandcode-catalog-report.md ]; then cat commandcode-catalog-report.md >> "$GITHUB_STEP_SUMMARY"; fi
- name: Create or update synchronization PR
uses: peter-evans/create-pull-request@v8
with:
Expand All @@ -112,7 +114,7 @@ jobs:
- name: Compare official pricing
if: always()
shell: bash
run: npm run check:commandcode-pricing | tee commandcode-pricing-report.md
run: npm run check:commandcode-pricing 2>&1 | tee commandcode-pricing-report.md
- name: Publish pricing report
if: always()
run: |
Expand Down
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

## Unreleased

- Open one actionable maintenance issue after repeated scheduled catalog/latest-host failures, update it without comment spam and close it after recovery. Keep report stderr, avoid broad formatting in catalog automation and document weekly release triage without automating publication.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CI/maintainer tooling is not user-facing; CONTRIBUTING limits the changelog to user-facing behavior. I'd drop the entry and let RELEASE.md be the record.


## 0.7.8 - 2026-10-15

- Redact sensitive text in direct stream errors and normalized context-overflow messages before returning them to the host. This closes two error-reporting paths that did not use the shared redactor; it is defensive hardening, not a claim of a confirmed credential leak ([#155](https://github.com/patlux/pi-commandcode-provider/pull/155)).
Expand Down
16 changes: 16 additions & 0 deletions RELEASE.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,22 @@ GitHub collaborators can release through this workflow without personal npm publ

These checks prevent accidental releases from the wrong commit; they are not a sandbox against a trusted writer who can modify workflows. Repository, tag and environment permissions remain the trust boundary.

## Maintenance review and release cadence

Review open maintenance alerts and unreleased fixes at least weekly. A calendar reminder or a green catalog PR is not release authorization. Prioritize broken authentication/transport, missing paid-model pricing, and host compatibility regressions; propose a patch release when a verified user-facing fix is ready rather than waiting for unrelated features.

The daily catalog workflow reports capability-parser drift separately from official-pricing drift. Two consecutive failed scheduled/manual default-branch runs create or reopen one bot-owned maintenance issue. Further failures update that issue; a successful run closes it. The same policy covers weekly latest-host checks when that workflow is installed. Fork/PR runs and canceled runs do not trigger alerts. Investigate the linked reports; do not silence alerts by accepting guessed prices or loosening parser checks.

For every proposed release, record:

- Which merged PRs and issues the release includes, with contributor credit.
- Whether live catalog/pricing checks pass; distinguish expected upstream drift from code failures. Review unknown prices and expired promotions explicitly.
- Host versions actually tested, local versus packed-package results, and any separately authorized live checks. Mock tests do not prove upstream performance or plan access.
- Any migration requirements and known limitations. Breaking entitlement/routing changes belong in a separately approved minor-version release while the package is pre-1.0, with a clearly labeled migration notice; do not slip them into a routine patch.
- The requested version/channel and release authorization before executing the existing tag/OIDC process.

After publication, use the completion checklist below before reporting an issue as released. Keep “merged”, “tested from source” and “available on npm” distinct.

## Prepare a release PR

Confirm the requested version and channel, a clean/current checkout, the canonical repository and the maintainer's own GitHub identity. Record the current npm dist-tags and latest stable GitHub Release so prerelease verification can prove they were preserved:
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@
"LICENSE"
],
"scripts": {
"test": "npm run typecheck && node --test tests/test-release.mjs tests/test-pi-child-cleanup.mjs && tsx tests/test-package-manifest.ts && tsx tests/test-api-key.ts && tsx tests/test-pure-functions.ts && tsx tests/test-models.ts && tsx tests/test-model-metadata-check.ts && tsx tests/test-runtime.ts && tsx tests/test-pricing.ts && tsx tests/test-pricing-check.ts && tsx tests/test-cost.ts && tsx tests/test-oauth.ts && tsx tests/test-abort.ts && tsx tests/test-overflow.ts && tsx tests/test-stream.ts && tsx tests/test-quota.ts && tsx tests/test-quota-command.ts && tsx tests/test-usage.ts && tsx tests/test-retry.ts && tsx tests/test-transport.ts && node tests/test-pi-isolated.mjs && node tests/test-pi-authenticated.mjs && node tests/test-pi-local.mjs && node tests/test-omp-compat.mjs",
"test": "npm run typecheck && node --test tests/test-release.mjs tests/test-pi-child-cleanup.mjs tests/test-maintenance-alert.mjs && tsx tests/test-package-manifest.ts && tsx tests/test-api-key.ts && tsx tests/test-pure-functions.ts && tsx tests/test-models.ts && tsx tests/test-model-metadata-check.ts && tsx tests/test-runtime.ts && tsx tests/test-pricing.ts && tsx tests/test-pricing-check.ts && tsx tests/test-cost.ts && tsx tests/test-oauth.ts && tsx tests/test-abort.ts && tsx tests/test-overflow.ts && tsx tests/test-stream.ts && tsx tests/test-quota.ts && tsx tests/test-quota-command.ts && tsx tests/test-usage.ts && tsx tests/test-retry.ts && tsx tests/test-transport.ts && node tests/test-pi-isolated.mjs && node tests/test-pi-authenticated.mjs && node tests/test-pi-local.mjs && node tests/test-omp-compat.mjs",
"typecheck": "tsc --noEmit",
"format:check": "prettier --check '**/*.{ts,mjs,json,md}'",
"format": "prettier --write '**/*.{ts,mjs,json,md}'",
Expand Down
118 changes: 118 additions & 0 deletions tests/test-maintenance-alert.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import assert from "node:assert/strict"
import { test } from "node:test"
import { maintainAlert } from "../.github/scripts/maintenance-alert.mjs"

function fixture({
conclusion = "failure",
previous = "failure",
issues = [],
event = "schedule",
branch = "main",
fork = false,
extra = [],
} = {}) {
const writes = []
const repo = { full_name: "patlux/pi-commandcode-provider" }
const run = {
name: "Command Code catalog sync",
status: "completed",
conclusion,
head_branch: branch,
repository: repo,
head_repository: fork ? { full_name: "fork/repo" } : repo,
event,
workflow_id: 7,
run_number: 12,
id: 120,
}
const methods = {
actions: { listWorkflowRuns: "runs" },
issues: {
listForRepo: "issues",
create: async (value) => writes.push(value),
update: async (value) => writes.push(value),
},
}
const github = {
rest: methods,
paginate: async (method) =>
method === "runs"
? [run, { ...run, run_number: 11, id: 110, conclusion: previous }, ...extra]
: issues,
}
const context = {
repo: { owner: "patlux", repo: "pi-commandcode-provider" },
payload: { workflow_run: run, repository: { default_branch: "main" } },
}
return { github, context, writes }
}
const incident = {
number: 42,
state: "open",
body: "<!-- commandcode-maintenance:catalog -->\nold",
user: { login: "github-actions[bot]" },
}

test("creates an actionable issue after two failures without including raw logs", async () => {
const f = fixture()
assert.equal(await maintainAlert(f), "created")
assert.equal(f.writes.length, 1)
assert.match(f.writes[0].body, /actions\/runs\/120/)
assert.match(f.writes[0].body, /official pricing/)
assert.deepEqual(f.writes[0].assignees, ["patlux"])
})
test("updates/reopens the same bot-owned incident instead of duplicating", async () => {
for (const state of ["open", "closed"]) {
const f = fixture({ issues: [{ ...incident, state }] })
assert.equal(await maintainAlert(f), "updated")
assert.equal(f.writes[0].issue_number, 42)
assert.equal(f.writes[0].state, "open")
}
})
test("closes an incident after recovery", async () => {
const f = fixture({ conclusion: "success", issues: [incident] })
assert.equal(await maintainAlert(f), "closed")
assert.equal(f.writes[0].state, "closed")
})
test("does not notify on a single failure, canceled run, or missing predecessor", async () => {
for (const settings of [
{ previous: "success" },
{ previous: "cancelled" },
{ conclusion: "cancelled" },
]) {
const f = fixture(settings)
assert.equal(await maintainAlert(f), "below-threshold")
assert.equal(f.writes.length, 0)
}
const f = fixture()
f.github.paginate = async () => []
assert.equal(await maintainAlert(f), "below-threshold")
})
test("ignores forks, PRs and non-default branches", async () => {
for (const settings of [{ fork: true }, { event: "pull_request" }, { branch: "feature" }]) {
const f = fixture(settings)
assert.equal(await maintainAlert(f), "ignored")
assert.equal(f.writes.length, 0)
}
})
test("ignores stale reruns after a newer completed run", async () => {
const f = fixture({ extra: [{ run_number: 13, event: "schedule", conclusion: "success" }] })
assert.equal(await maintainAlert(f), "stale")
assert.equal(f.writes.length, 0)
})
test("does not edit user-authored matching issues or PRs", async () => {
const f = fixture({
issues: [
{ ...incident, user: { login: "someone" } },
{ ...incident, pull_request: {} },
],
})
assert.equal(await maintainAlert(f), "created")
assert.equal(f.writes[0].issue_number, undefined)
})
test("tracks host failures independently", async () => {
const f = fixture({ issues: [incident] })
f.context.payload.workflow_run.name = "Latest host compatibility"
assert.equal(await maintainAlert(f), "created")
assert.match(f.writes[0].body, /maintenance:hosts/)
})
Loading