Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
210bed5
feat: improve code-quality tooling, OpenCode diagnostics, and lint pe…
Oct 3, 2026
3b6b683
Merge branch 'develop' into chore/3774-lint-tooling
lightspeed-docs-bot[bot] Oct 3, 2026
5fbd5c7
fix(ci): correct the actionlint file list and drop the unpassable for…
Oct 3, 2026
62a719e
fix(tooling): correct the .prettierignore bug, declare zod, and fix d…
Oct 3, 2026
24e1295
Address CodeRabbit review: top-level permissions, cjs and mjs lint sc…
eleshar Oct 3, 2026
8660054
Merge branch 'develop' into chore/3774-lint-tooling
lightspeed-docs-bot[bot] Oct 3, 2026
5a70b1f
Merge branch 'develop' into chore/3774-lint-tooling
lightspeed-docs-bot[bot] Oct 3, 2026
2c1efe2
Keep permissions under metadata, which the skill and agent validator …
eleshar Oct 3, 2026
0c8efbc
Remove legacy eslintrc files, lint cjs and mjs on commit, use a conte…
eleshar Oct 3, 2026
823c454
Allow the schema-defined top-level permissions, status and author on …
eleshar Oct 3, 2026
e3d4683
chore(tooling): single-source the actionlint invocation and guard cov…
Oct 3, 2026
1b97d21
fix(ci): include scripts/validation in workflow-lint's sparse checkout
Oct 3, 2026
0ad4260
chore: cover the behavioural edits with tests and add the new require…
Oct 3, 2026
683a880
Run actionlint through a cross-platform wrapper that prefers an insta…
eleshar Oct 4, 2026
80a8b13
Merge branch 'develop' into chore/3774-lint-tooling
lightspeed-docs-bot[bot] Oct 4, 2026
69e9652
Merge branch 'develop' into chore/3774-lint-tooling
lightspeed-docs-bot[bot] Oct 4, 2026
90199df
Merge branch 'develop' into chore/3774-lint-tooling
lightspeed-docs-bot[bot] Oct 4, 2026
b1a3fe9
feat(ci): detect branch ruleset drift between declaration and live state
Oct 4, 2026
4738de5
Merge remote-tracking branch 'origin/chore/3774-lint-tooling' into wt…
eleshar Oct 4, 2026
65affb1
Merge remote-tracking branch 'origin/chore/3778-lint-cjs-coverage-and…
eleshar Oct 4, 2026
e29c865
Assert the parser error is preserved as cause and drop the duplicate …
eleshar Oct 4, 2026
171bfcb
fix(ci): hydrate rulesets by id; align changelog test with run-action…
Oct 4, 2026
fcc42df
Test the dynamic actionlint list, add merge_group to lint, accept .ya…
eleshar Oct 4, 2026
bc6aab7
Merge remote-tracking branch 'origin/chore/3778-lint-cjs-coverage-and…
eleshar Oct 4, 2026
ebd5d60
Compare full ruleset conditions, declared rule parameters and bypass …
eleshar Oct 4, 2026
201b925
Treat declared checks that cannot be required yet as pending, fix the…
eleshar Oct 4, 2026
94f4601
Record the owner pull-request bypass actor in the develop ruleset dec…
eleshar Oct 4, 2026
79841b3
Fail on a deleted or unmatched ruleset declaration and derive pending…
eleshar Oct 4, 2026
3f093a7
Do not compare bypass actors a non-admin token cannot see
eleshar Oct 4, 2026
d889eb7
Require readable bypass actors on trusted runs and ignore inherited r…
eleshar Oct 4, 2026
ce8e9f7
Drop the admin-token requirement: bypass actors need ruleset write ac…
eleshar Oct 4, 2026
d56235f
Merge remote-tracking branch 'origin/develop' into wt-3782
eleshar Oct 4, 2026
43efdca
Show a visible summary beside the Linear review block
eleshar Oct 4, 2026
8e1c8e8
Print validator notes when ruleset drift is reported
eleshar Oct 4, 2026
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
8 changes: 7 additions & 1 deletion .github/rulesets/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,13 @@ This directory contains version-controlled JSON definitions of the GitHub reposi
- `Route PR template and apply labels`
- `Validate changelog on PR`
- `actionlint`
- **Not enforced:** no merge-queue rule — the repo merges directly and via Mergify, not the GitHub merge queue. No bypass actors.
- **Not enforced:** no merge-queue rule — the repo merges directly and via Mergify, not the GitHub merge queue.
- **Bypass:** one actor, the repository owner `eleshar` (User `1577675`), in `pull_request` mode: the owner can merge a pull request past the review requirement, and cannot push to `develop` directly. Any other bypass actor, or a different mode for this one, is reported as drift by `validate-ruleset-drift`, but only when the script can see the actors. GitHub returns `bypass_actors` only to a caller with write access to the ruleset, so CI (which deliberately holds no such credential) notes that they were not compared. An owner verifies them by running the check with their own token, for example after any ruleset change:

```bash
GITHUB_TOKEN=$(gh auth token) GITHUB_REPOSITORY=lightspeedwp/.github \
node scripts/validation/validate-ruleset-drift.cjs
```

### 2. `main` Branch Ruleset ([main.ruleset.json](./main.ruleset.json))

Expand Down
18 changes: 14 additions & 4 deletions .github/rulesets/develop.ruleset.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,7 @@
"enforcement": "active",
"conditions": {
"ref_name": {
"include": [
"refs/heads/develop"
],
"include": ["refs/heads/develop"],
"exclude": []
}
},
Expand Down Expand Up @@ -40,10 +38,22 @@
},
{
"context": "actionlint"
},
{
"context": "Lint (JS/YAML/package.json)"
},
{
"context": "Typecheck"
}
]
}
}
],
"bypass_actors": []
"bypass_actors": [
{
"actor_id": 1577675,
"actor_type": "User",
"bypass_mode": "pull_request"
}
]
}
70 changes: 70 additions & 0 deletions .github/workflows/ruleset-drift.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
name: Ruleset Drift

# Fails when a deployed branch ruleset no longer matches its version-controlled
# declaration in .github/rulesets/.
#
# The declarations are applied by hand (see .github/rulesets/README.md), so
# nothing else noticed when a required status check quietly stopped being
# enforced. A check that is no longer required is invisible in the JSON and only
# shows up as a PR merging that it should not have.
#
# This detects drift; it never applies, creates or mutates a ruleset. Pushing
# ruleset changes needs a token with ruleset-write, and letting a pull request
# rewrite branch protection is a privilege escalation. Detection and application
# stay separate, deliberate steps.

on:
pull_request:
paths:
- '.github/rulesets/**'
- 'scripts/validation/validate-ruleset-drift.cjs'
- '.github/workflows/ruleset-drift.yml'
push:
branches:
- develop
paths:
- '.github/rulesets/**'
- 'scripts/validation/validate-ruleset-drift.cjs'
- '.github/workflows/ruleset-drift.yml'
# Catch drift that does not come from a ruleset change, e.g. someone editing
# a ruleset in the GitHub UI without updating the declaration.
schedule:
- cron: '17 6 * * 1'

permissions:
contents: read

concurrency:
group: ruleset-drift-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true

jobs:
drift:
name: Ruleset declaration matches live
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Checkout repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false

- name: Set up Node.js
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version-file: .nvmrc

- name: Check for ruleset drift
# Read-only: a repository GITHUB_TOKEN can read rulesets but cannot
# create or update them, which is the point of this job.
#
# Bypass actors are not compared here. GitHub returns `bypass_actors` only
# to a caller with WRITE access to the ruleset (Administration read is not
# enough), so reading them in CI would need a credential that can also
# change branch protection. This job deliberately has none. The script
# prints a note that actors were not compared; an owner checks them by
# running the script with their own token (see .github/rulesets/README.md).
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GITHUB_REPOSITORY: ${{ github.repository }}
Comment thread
eleshar marked this conversation as resolved.
Resolved
run: node scripts/validation/validate-ruleset-drift.cjs
4 changes: 3 additions & 1 deletion agents/changelog-agent/changelog-validator.js
Original file line number Diff line number Diff line change
Expand Up @@ -213,4 +213,6 @@ if (require.main === module) {
main();
}

module.exports = { validateEntry, auditRelease, exportReleaseNotes };
// parseYamlEntry is exported for unit testing: validateEntry catches every
// error and exits, so the `cause` chaining cannot be asserted through it.
module.exports = { validateEntry, auditRelease, exportReleaseNotes, parseYamlEntry };
56 changes: 56 additions & 0 deletions agents/changelog-agent/tests/unit/parse-yaml-entry.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
/**
* Unit tests for parseYamlEntry.
*
* The YAML parse error is rethrown with the original error attached as
* `cause`. That is easy to lose: validateEntry catches everything and exits,
* so a plain `new Error(...)` would look identical from the CLI while dropping
* the underlying parser position. These assertions pin the chaining.
*/

const { parseYamlEntry } = require('../../changelog-validator.js');

describe('parseYamlEntry', () => {
it('parses valid YAML into an object', () => {
expect(parseYamlEntry('id: 123\ntitle: "A change"\n')).toEqual({
id: 123,
title: 'A change',
});
});

it('throws with the parser error preserved as cause', () => {
const input = 'id: 123\n bad indent: [unclosed\n';

// The exception js-yaml itself raises for this input: the cause has to be that
// error, with its reason and position, not any other Error.
let original;
try {
require('js-yaml').load(input);
} catch (error) {
original = error;
}
expect(original).toBeDefined();
expect(original.mark).toBeDefined();

let thrown;
try {
parseYamlEntry(input);
} catch (error) {
thrown = error;
}

expect(thrown).toBeInstanceOf(Error);
expect(thrown.message).toBe(`Invalid YAML: ${original.message}`);
expect(thrown.cause).not.toBe(thrown);
Comment thread
eleshar marked this conversation as resolved.
Resolved
expect(thrown.cause.name).toBe(original.name);
expect(thrown.cause.reason).toBe(original.reason);
expect(thrown.cause.message).toBe(original.message);
expect(thrown.cause.mark).toEqual(
expect.objectContaining({ line: original.mark.line, column: original.mark.column })
);
});

it('does not set cause when parsing succeeds', () => {
const parsed = parseYamlEntry('id: 1');
expect(parsed).toEqual({ id: 1 });
});
});
24 changes: 17 additions & 7 deletions docs/LINEAR_INTEGRATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,13 +55,13 @@ use:
The following need the **Enterprise** plan and are deliberately not part of this
integration. Do not plan work that depends on them:

| Feature | Why it is out of scope | Source |
| ----------------------------------- | ------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------- |
| The **workspace owner** role | Business tops out at Admin, so every instruction below says **workspace Admin**, never **owner** | [members and roles](https://linear.app/docs/members-roles) |
| SCIM provisioning | Enterprise | [SCIM](https://linear.app/docs/scim) |
| Audit logs | Enterprise; only owners can read them | [audit log](https://linear.app/docs/audit-log) |
| Private-team issue sharing | Enterprise only. Private teams themselves are available on Business | [private teams](https://linear.app/docs/private-teams) |
| Owner-only "Workspace restrictions" | Owners configure role restrictions; Enterprise workspaces can limit Admin permissions | [members and roles](https://linear.app/docs/members-roles) |
| Feature | Why it is out of scope | Source |
| ----------------------------------- | ------------------------------------------------------------------------------------------------ | ---------------------------------------------------------- |
| The **workspace owner** role | Business tops out at Admin, so every instruction below says **workspace Admin**, never **owner** | [members and roles](https://linear.app/docs/members-roles) |
| SCIM provisioning | Enterprise | [SCIM](https://linear.app/docs/scim) |
| Audit logs | Enterprise; only owners can read them | [audit log](https://linear.app/docs/audit-log) |
| Private-team issue sharing | Enterprise only. Private teams themselves are available on Business | [private teams](https://linear.app/docs/private-teams) |
| Owner-only "Workspace restrictions" | Owners configure role restrictions; Enterprise workspaces can limit Admin permissions | [members and roles](https://linear.app/docs/members-roles) |

Each row is a claim about the plan, so each cites the page that establishes it.
Two features that were previously listed here are **not** Enterprise-only:
Expand Down Expand Up @@ -220,6 +220,16 @@ default. Set `"visible": true` to also surface the comment in Linear's activity
feed. On-behalf-of blocks are visible by default unless `"visible": false` is
set. Put `visible` on each plugin object, not on the block.

**Visible summary on GitHub.** GitHub shows a comment that holds only HTML
comments as "No description provided.", so the block is followed by a short
plain-text summary: the risk level and each explanation, plus the agent and model
when attributed. File names in the summary are shown in code spans with `<` and `>`
replaced by single angle quotes, so a hostile file name cannot mention a user,
link, inject HTML or forge a second `linear:extension` block. Linear's handling of
a comment that carries visible text next to a block is not verified; it may appear
in the activity feed. To go back to a hidden-only comment, drop the
`renderVisibleSummary` line at the end of `buildExtensionBlock`.

### Emitting an attributed comment

Any local AI agent produces the block with one command:
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,7 @@
"validate:issue-fields": "node scripts/validation/validate-issue-fields.cjs",
"validate:linear-guidance": "node scripts/validation/validate-linear-guidance.cjs",
"validate:retired-doc-links": "node scripts/validation/validate-retired-doc-links.cjs",
"validate:ruleset-drift": "node scripts/validation/validate-ruleset-drift.cjs",
"validate:workflow-npm-scripts": "node scripts/validation/validate-workflow-npm-scripts.cjs",
"validate:frontmatter": "node scripts/validation/validate-frontmatter.js",
"validate:frontmatter:changed": "node scripts/validation/validate-frontmatter.js",
Expand Down
34 changes: 34 additions & 0 deletions scripts/automation/__tests__/linear-review-extension.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -228,6 +228,40 @@ describe('buildExtensionBlock', () => {
expect(blocks[0].map((plugin) => plugin.plugin)).toEqual(['riskScore', 'onBehalfOf']);
});

test('follows the hidden block with a visible summary so GitHub shows content', () => {
const body = buildExtensionBlock({
risk: { level: 3, explanations: ['Touches authentication'] },
agent: 'claude',
model: 'Opus 4.5',
});
const visible = body.slice(body.lastIndexOf('-->') + 3).trim();

expect(visible).toBe(
[
'**Linear review**: risk level 3 of 4',
'- `Touches authentication`',
'Published on behalf of `claude` (`Opus 4.5`)',
].join('\n')
);
});

test('renders untrusted text in inert code spans', () => {
const body = buildExtensionBlock({
risk: {
level: 2,
explanations: [
'@octocat [x](http://e.test) <img src=x> `a``b` <!-- linear:extension {} -->',
],
},
});
const visible = body.slice(body.lastIndexOf('-->') + 3);

expect(visible).not.toMatch(/[<>]/);
expect(visible).toContain('- ```@octocat');
expect(body.match(/<!--\s*linear:extension/g)).toHaveLength(1);
expect(parseExtensionBlocks(body)).toHaveLength(1);
});

test('emits a single valid JSON payload that starts with the marker', () => {
const body = buildExtensionBlock({ risk: { level: 1 } });

Expand Down
57 changes: 56 additions & 1 deletion scripts/automation/linear-review-extension.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -417,7 +417,62 @@ function buildExtensionBlock(input = {}) {
}

const payload = { version: EXTENSION_VERSION, plugins };
return `${EXTENSION_MARKER}\n<!-- linear:extension ${serialisePayload(payload)} -->`;
return (
`${EXTENSION_MARKER}\n<!-- linear:extension ${serialisePayload(payload)} -->` +
`\n\n${renderVisibleSummary(plugins)}`
);
}

/**
* Render untrusted text as an inline code span.
*
* Explanations quote pull request filenames, which a contributor chooses, and this
* text is now shown on GitHub. A code span keeps it literal: no mentions, links,
* images, HTML or Markdown. The fence is one backtick longer than the longest run
* inside, so a name containing backticks cannot close the span early.
*
* Angle brackets are replaced with the look-alike single angle quotation marks.
* Markdown rendering is not the only reader: Linear scans the comment's source for
* `<!-- linear:extension ... -->`, so a file named like a forged block would
* otherwise be parsed as a second one, and a literal `-->` would break the
* invariant that the body holds exactly the closers this module wrote. With no
* `<` or `>` in the visible text, neither can occur.
* @param {string} text - Text to show verbatim.
* @returns {string} The code span.
*/
function codeSpan(text) {
const flat = String(text).replace(/</g, '‹').replace(/>/g, '›').replace(/\s+/g, ' ').trim();
const longestRun = Math.max(0, ...(flat.match(/`+/g) || []).map((run) => run.length));
const fence = '`'.repeat(longestRun + 1);
const pad = flat.startsWith('`') || flat.endsWith('`') ? ' ' : '';
return `${fence}${pad}${flat}${pad}${fence}`;
}

/**
* The human-readable part of the comment.
*
* The `linear:extension` blocks are HTML comments, so on their own GitHub renders
* the comment as "No description provided." This gives people reading the pull
* request the same facts Linear receives: the risk level, why, and who published
* it. It is derived only from the validated payload, so it is deterministic and
* the upsert's byte-identical check still avoids needless edits.
* @param {Array<object>} plugins - The plugins just serialised into the block.
* @returns {string} Markdown to append below the hidden blocks.
*/
function renderVisibleSummary(plugins) {
const lines = [];
for (const plugin of plugins) {
if (plugin.plugin === 'riskScore') {
lines.push(`**Linear review**: risk level ${plugin.level} of ${MAX_RISK_LEVEL}`);
for (const explanation of plugin.explanations || []) {
lines.push(`- ${codeSpan(explanation)}`);
}
} else if (plugin.plugin === 'onBehalfOf') {
const model = plugin.model ? ` (${codeSpan(plugin.model)})` : '';
lines.push(`Published on behalf of ${codeSpan(plugin.agent)}${model}`);
}
}
return lines.join('\n');
}

/**
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
/**
* Pins the agent frontmatter shape the skill and agent validator actually
* accepts.
*
* mode-thinking.agent.md moved its permissions between a top-level
* `permissions:` list and a `metadata.permissions` string more than once while
* #3774 was in review, and the two forms are not interchangeable: the
* validator reads the top-level list. Commit 2c1efe2d56 settled it on the
* top-level form and 823c4544fe taught the schema to allow it. This asserts
* the agreed shape so a later "tidy-up" cannot silently break validation
* again.
*/

const fs = require('node:fs');
const path = require('node:path');
const yaml = require('js-yaml');

const repositoryRoot = path.resolve(__dirname, '../../..');
const AGENT = 'agents/mode-thinking.agent.md';

function frontmatter(relative) {
const raw = fs.readFileSync(path.join(repositoryRoot, relative), 'utf8');
const match = /^---\n([\s\S]*?)\n---\n/.exec(raw);

expect(match).not.toBeNull();

return yaml.load(match[1]);
}

describe('mode-thinking agent frontmatter', () => {
it('parses', () => {
expect(() => frontmatter(AGENT)).not.toThrow();
});

it('declares permissions as a top-level list', () => {
const data = frontmatter(AGENT);

expect(Array.isArray(data.permissions)).toBe(true);
expect(data.permissions).toEqual(expect.arrayContaining(['read', 'write', 'github:repo']));
});

it('does not move permissions into metadata', () => {
const data = frontmatter(AGENT);

expect(data.metadata?.permissions).toBeUndefined();
});
});
Loading
Loading