diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d73e6aa6a..6ff26187f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -104,7 +104,7 @@ jobs: run: node tests/plugin-manifest.test.js - name: Validate Grok marketplace source pin - run: node scripts/ci/validate-grok-pin.js + run: node scripts/ci/validate-grok-pin.js --release - name: Check npm publish state id: npm_publish_state diff --git a/.github/workflows/reusable-release.yml b/.github/workflows/reusable-release.yml index a4c4daf7f..99372e05e 100644 --- a/.github/workflows/reusable-release.yml +++ b/.github/workflows/reusable-release.yml @@ -102,7 +102,7 @@ jobs: run: node tests/plugin-manifest.test.js - name: Validate Grok marketplace source pin - run: node scripts/ci/validate-grok-pin.js + run: node scripts/ci/validate-grok-pin.js --release - name: Check npm publish state id: npm_publish_state diff --git a/.grok-plugin/README.md b/.grok-plugin/README.md index c26b75d5a..8461ee91e 100644 --- a/.grok-plugin/README.md +++ b/.grok-plugin/README.md @@ -25,6 +25,13 @@ node scripts/uninstall.js --target grok rollback use the same canonical apply path; pass `--source-sha <40-hex>` to reapply a previous pinned commit. +The marketplace pin advances with releases, not every source commit. Ordinary +CI validates the SHA and its ancestry. `scripts/release.sh` commits the release +metadata, pins that source commit in a separate catalog commit, and validates +the result before tagging. Release workflows also run +`node scripts/ci/validate-grok-pin.js --release` to reject unpublished source +changes after the pinned snapshot. + Grok CLI marketplace add / install / enable is **discovery only**. `grok plugin install --trust` skips Grok's confirmation prompt; it is **not ECC capability consent** and does not write an ECC receipt. `plugin.json` diff --git a/.grok-plugin/marketplace.json b/.grok-plugin/marketplace.json index 7725d83c3..2d9063084 100644 --- a/.grok-plugin/marketplace.json +++ b/.grok-plugin/marketplace.json @@ -9,7 +9,7 @@ { "name": "ecc", "description": "Harness-native ECC operator layer - 68 agents, 286 skills, 94 legacy command shims, reusable hooks, rules, selective install profiles, and production-ready workflows for Grok Build, Claude Code, Codex, OpenCode, Cursor, and related agent harnesses", - "version": "2.2.1", + "version": "2.2.2", "author": { "name": "Affaan Mustafa", "email": "me@affaanmustafa.com" diff --git a/.grok-plugin/plugin.json b/.grok-plugin/plugin.json index f8bae0102..341b76f40 100644 --- a/.grok-plugin/plugin.json +++ b/.grok-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "ecc", - "version": "2.2.1", + "version": "2.2.2", "description": "Harness-native ECC plugin for engineering teams - 68 agents, 286 skills, 94 legacy command shims, reusable hooks, rules, MCP conventions, and operator workflows for Grok Build plus adjacent agent harnesses", "author": { "name": "Affaan Mustafa", diff --git a/scripts/ci/validate-grok-pin.js b/scripts/ci/validate-grok-pin.js index b58438c36..7025fcac4 100644 --- a/scripts/ci/validate-grok-pin.js +++ b/scripts/ci/validate-grok-pin.js @@ -14,7 +14,7 @@ function git(root, args) { }).trim(); } -function validateGrokPin(root = path.resolve(__dirname, '../..')) { +function validateGrokPin(root = path.resolve(__dirname, '../..'), { release = false } = {}) { const marketplace = JSON.parse(fs.readFileSync(path.join(root, MARKETPLACE_PATH), 'utf8')); const source = marketplace.plugins && marketplace.plugins[0] && marketplace.plugins[0].source; const sha = source && source.sha; @@ -30,18 +30,28 @@ function validateGrokPin(root = path.resolve(__dirname, '../..')) { } catch { throw new Error(`Grok marketplace pin ${sha} is not an ancestor of HEAD ${head}`); } - try { - git(root, ['diff', '--quiet', sha, 'HEAD', '--', '.', `:(exclude)${MARKETPLACE_PATH}`]); - } catch { - throw new Error(`Grok marketplace pin ${sha} is stale; source changes exist after the pinned commit`); + // Development retains the last pinned snapshot; releases must include all source changes. + if (release) { + try { + git(root, ['diff', '--quiet', sha, 'HEAD', '--', '.', `:(exclude)${MARKETPLACE_PATH}`]); + } catch { + throw new Error(`Grok marketplace pin ${sha} is stale; source changes exist after the pinned commit`); + } } return { sha, head }; } if (require.main === module) { try { - const { sha, head } = validateGrokPin(); - console.log(`Grok marketplace pin ${sha} matches source at ${head}`); + const args = process.argv.slice(2); + if (args.length > 1 || (args.length === 1 && args[0] !== '--release')) { + throw new Error('Usage: node scripts/ci/validate-grok-pin.js [--release]'); + } + const release = args[0] === '--release'; + const { sha, head } = validateGrokPin(undefined, { release }); + console.log(release + ? `Grok marketplace pin ${sha} matches source at ${head}` + : `Grok marketplace pin ${sha} is a valid ancestor of ${head}`); } catch (error) { console.error(error.message); process.exitCode = 1; diff --git a/scripts/release.sh b/scripts/release.sh index 3da2f3704..d3ff96da0 100755 --- a/scripts/release.sh +++ b/scripts/release.sh @@ -354,9 +354,10 @@ node -e ' plugin.source.sha = sha; fs.writeFileSync(file, `${JSON.stringify(marketplace, null, 2)}\n`); ' "$GROK_MARKETPLACE_JSON" "$GROK_SOURCE_SHA" -node scripts/ci/validate-grok-pin.js +node scripts/ci/validate-grok-pin.js --release git add "$GROK_MARKETPLACE_JSON" git commit -m "chore: pin Grok marketplace to release source" +node scripts/ci/validate-grok-pin.js --release git tag "v$VERSION" git push origin main "v$VERSION" diff --git a/tests/ci/grok-pin.test.js b/tests/ci/grok-pin.test.js index d0d474cc3..1d51f1f70 100644 --- a/tests/ci/grok-pin.test.js +++ b/tests/ci/grok-pin.test.js @@ -1,7 +1,7 @@ 'use strict'; const assert = require('assert'); -const { execFileSync } = require('child_process'); +const { execFileSync, spawnSync } = require('child_process'); const fs = require('fs'); const os = require('os'); const path = require('path'); @@ -16,7 +16,7 @@ function writeMarketplace(root, sha) { const directory = path.join(root, '.grok-plugin'); fs.mkdirSync(directory, { recursive: true }); fs.writeFileSync(path.join(directory, 'marketplace.json'), JSON.stringify({ - plugins: [{ source: { sha } }], + plugins: [{ name: 'ecc', source: { sha } }], })); } @@ -25,6 +25,9 @@ function createFixture() { git(root, 'init', '--quiet'); git(root, 'config', 'user.name', 'ECC test'); git(root, 'config', 'user.email', 'ecc-test@example.invalid'); + const validatorPath = path.join(root, 'scripts/ci/validate-grok-pin.js'); + fs.mkdirSync(path.dirname(validatorPath), { recursive: true }); + fs.copyFileSync(path.resolve(__dirname, '../../scripts/ci/validate-grok-pin.js'), validatorPath); fs.writeFileSync(path.join(root, 'payload.txt'), 'verified source\n'); writeMarketplace(root, '0'.repeat(40)); git(root, 'add', '.'); @@ -57,22 +60,131 @@ test('accepts a pin commit that differs only by the pin update', () => { const fixture = createFixture(); try { assert.strictEqual(validateGrokPin(fixture.root).sha, fixture.sha); + assert.strictEqual(validateGrokPin(fixture.root, { release: true }).sha, fixture.sha); } finally { fs.rmSync(fixture.root, { recursive: true, force: true }); } }); -test('rejects source changes after the pinned snapshot', () => { +test('ordinary source commits keep a valid historical pin but cannot be released', () => { const fixture = createFixture(); try { fs.writeFileSync(path.join(fixture.root, 'payload.txt'), 'changed source\n'); git(fixture.root, 'add', 'payload.txt'); git(fixture.root, 'commit', '--quiet', '-m', 'change source without repinning'); - assert.throws(() => validateGrokPin(fixture.root), /pin .* is stale/); + assert.strictEqual(validateGrokPin(fixture.root).sha, fixture.sha); + assert.throws(() => validateGrokPin(fixture.root, { release: true }), /pin .* is stale/); + + const validator = path.join(fixture.root, 'scripts/ci/validate-grok-pin.js'); + const normal = spawnSync(process.execPath, [validator], { encoding: 'utf8' }); + assert.strictEqual(normal.status, 0, normal.stderr); + assert.doesNotMatch(normal.stdout, /matches source/); + const release = spawnSync(process.execPath, [validator, '--release'], { encoding: 'utf8' }); + assert.strictEqual(release.status, 1); + assert.match(release.stderr, /pin .* is stale/); + + const sourceSha = git(fixture.root, 'rev-parse', 'HEAD'); + const releaseScript = fs.readFileSync(path.resolve(__dirname, '../../scripts/release.sh'), 'utf8'); + const updater = releaseScript.slice(releaseScript.indexOf('GROK_SOURCE_SHA=')) + .match(/node -e '([\s\S]*?)' "\$GROK_MARKETPLACE_JSON" "\$GROK_SOURCE_SHA"/); + assert.ok(updater, 'exercise the pin updater shipped in release.sh'); + const updated = spawnSync(process.execPath, ['-e', updater[1], + path.join(fixture.root, '.grok-plugin/marketplace.json'), sourceSha, + ], { encoding: 'utf8' }); + assert.strictEqual(updated.status, 0, updated.stderr); + git(fixture.root, 'add', '.'); + git(fixture.root, 'commit', '--quiet', '-m', 'pin updated source'); + assert.strictEqual(git(fixture.root, 'diff', '--name-only', 'HEAD^', 'HEAD'), + '.grok-plugin/marketplace.json'); + assert.strictEqual(validateGrokPin(fixture.root, { release: true }).sha, sourceSha); + const repinned = spawnSync(process.execPath, [validator, '--release'], { encoding: 'utf8' }); + assert.strictEqual(repinned.status, 0, repinned.stderr); + assert.match(repinned.stdout, /matches source/); } finally { fs.rmSync(fixture.root, { recursive: true, force: true }); } }); +test('both modes reject malformed, missing, and non-ancestor pins', () => { + const fixture = createFixture(); + try { + for (const marketplace of [{}, { plugins: [] }, { plugins: [{}] }]) { + fs.writeFileSync(path.join(fixture.root, '.grok-plugin/marketplace.json'), JSON.stringify(marketplace)); + for (const release of [false, true]) { + assert.throws(() => validateGrokPin(fixture.root, { release }), /must pin a 40-character/); + } + } + for (const sha of ['main', '0'.repeat(40)]) { + writeMarketplace(fixture.root, sha); + for (const release of [false, true]) { + assert.throws(() => validateGrokPin(fixture.root, { release })); + } + } + // Create a real commit that is not reachable from HEAD, without moving any refs. + const tree = git(fixture.root, 'rev-parse', 'HEAD^{tree}'); + const unrelated = git(fixture.root, 'commit-tree', tree, '-m', 'unrelated history'); + writeMarketplace(fixture.root, unrelated); + for (const release of [false, true]) { + assert.throws(() => validateGrokPin(fixture.root, { release }), /not an ancestor/); + } + } finally { + fs.rmSync(fixture.root, { recursive: true, force: true }); + } +}); + +test('CLI rejects unknown options instead of silently disabling release validation', () => { + for (const args of [['--relase'], ['--release', 'unexpected']]) { + const result = spawnSync(process.execPath, [ + path.resolve(__dirname, '../../scripts/ci/validate-grok-pin.js'), ...args, + ], { encoding: 'utf8' }); + assert.strictEqual(result.status, 1); + assert.match(result.stderr, /Usage:/); + } +}); + +// release.sh is a POSIX entrypoint; the validator and updater tests above run on all platforms. +if (process.platform !== 'win32') { + for (const contaminate of [false, true]) { + test(contaminate ? 'release blocks tagging when a commit hook changes source' + : 'release script tags the verified pin-only commit', () => { + const fixture = createFixture(); + try { + fs.writeFileSync(path.join(fixture.root, 'payload.txt'), 'new release source\n'); + git(fixture.root, 'add', '.'); + git(fixture.root, 'commit', '--quiet', '-m', 'release source'); + const sourceSha = git(fixture.root, 'rev-parse', 'HEAD'); + if (contaminate) { + fs.writeFileSync(path.join(fixture.root, '.git/hooks/pre-commit'), + '#!/bin/sh\nprintf "unexpected source change\\n" > payload.txt\ngit add payload.txt\n', + { mode: 0o755 }); + } + const source = fs.readFileSync(path.resolve(__dirname, '../../scripts/release.sh'), 'utf8'); + const start = source.indexOf('GROK_SOURCE_SHA=$(git rev-parse HEAD)'); + const end = source.indexOf('git push origin main', start); + assert.ok(start >= 0 && end > start); + const result = spawnSync('bash', ['-euo', 'pipefail'], { + cwd: fixture.root, + input: source.slice(start, end), + encoding: 'utf8', + env: { ...process.env, GROK_MARKETPLACE_JSON: '.grok-plugin/marketplace.json', VERSION: '9.9.9' }, + }); + if (contaminate) { + assert.strictEqual(result.status, 1, result.stderr); + assert.match(result.stderr, /pin .* is stale/); + assert.strictEqual(git(fixture.root, 'tag', '--list'), ''); + } else { + assert.strictEqual(result.status, 0, result.stderr); + assert.strictEqual(validateGrokPin(fixture.root, { release: true }).sha, sourceSha); + assert.strictEqual(git(fixture.root, 'rev-parse', 'v9.9.9'), git(fixture.root, 'rev-parse', 'HEAD')); + assert.strictEqual(git(fixture.root, 'diff', '--name-only', sourceSha, 'HEAD'), + '.grok-plugin/marketplace.json'); + } + } finally { + fs.rmSync(fixture.root, { recursive: true, force: true }); + } + }); + } +} + console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`); if (failed > 0) process.exitCode = 1; diff --git a/tests/scripts/release.test.js b/tests/scripts/release.test.js index 1a1a508ef..c92a95e1d 100644 --- a/tests/scripts/release.test.js +++ b/tests/scripts/release.test.js @@ -84,7 +84,7 @@ function runTests() { const releaseCommitIndex = source.indexOf('git commit -m "chore: bump plugin version to $VERSION"'); const sourceShaIndex = source.indexOf('GROK_SOURCE_SHA=$(git rev-parse HEAD)'); const pinUpdateIndex = source.indexOf('plugin.source.sha = sha;'); - const pinValidationIndex = source.indexOf('node scripts/ci/validate-grok-pin.js'); + const pinValidationIndex = source.indexOf('node scripts/ci/validate-grok-pin.js --release'); const pinCommitIndex = source.indexOf('git commit -m "chore: pin Grok marketplace to release source"'); const tagIndex = source.indexOf('git tag "v$VERSION"'); @@ -96,9 +96,24 @@ function runTests() { assert.ok(pinUpdateIndex > sourceShaIndex); assert.ok(pinValidationIndex > pinUpdateIndex); assert.ok(pinCommitIndex > pinValidationIndex); + const finalValidationIndex = source.lastIndexOf('node scripts/ci/validate-grok-pin.js --release'); + assert.ok(finalValidationIndex > pinCommitIndex && finalValidationIndex < tagIndex, + 'validate the committed release snapshot before tagging'); assert.ok(tagIndex > pinCommitIndex, 'the release tag must point at the pin-only commit'); })) passed++; else failed++; + if (test('release workflows enforce snapshot equality while ordinary CI accepts historical pins', () => { + for (const workflow of [releaseWorkflowSource, reusableReleaseWorkflowSource]) { + assert.match(workflow, /run: node scripts\/ci\/validate-grok-pin\.js --release/); + } + const reusableValidation = fs.readFileSync(path.join( + __dirname, '../../.github/workflows/reusable-validate.yml' + ), 'utf8'); + for (const workflow of [ciWorkflowSource, reusableValidation]) { + assert.match(workflow, /run: node scripts\/ci\/validate-grok-pin\.js\s*\n/); + } + })) passed++; else failed++; + if (test('release script supports prerelease semver and release heading sync', () => { assert.ok( source.includes('2.0.0-rc.1'),