From 21d914fa77166827b9972d733c78eafdff376b4c Mon Sep 17 00:00:00 2001 From: Wen Date: Wed, 14 Oct 2026 16:44:53 -0300 Subject: [PATCH] fix(opensource-pipeline): publish only the commit the sanitizer scanned Signed-off-by: Wen --- manifests/context-packs/skill-triggers@1.json | 2 +- skills/opensource-pipeline/SKILL.md | 14 ++- .../opensource-pipeline-publish-guard.test.js | 114 ++++++++++++++++++ 3 files changed, 126 insertions(+), 4 deletions(-) create mode 100644 tests/skills/opensource-pipeline-publish-guard.test.js diff --git a/manifests/context-packs/skill-triggers@1.json b/manifests/context-packs/skill-triggers@1.json index 56e0cfd5f..6d8e46deb 100644 --- a/manifests/context-packs/skill-triggers@1.json +++ b/manifests/context-packs/skill-triggers@1.json @@ -10,7 +10,7 @@ "id": "hand-seeded", "source": "manual-curation-pending-regeneration" }, - "registryDigest": "50469290f7d1e5b9c7768ffb36aa91f05eb6bc0f83302b67a2fe496b2cc4476f", + "registryDigest": "8ea4a063831fcfff5b0828da0c2154a01e1dfa83a98d3c7c149068cd31ff5c33", "schemaVersion": 1, "triggers": { "skill:agent-runtime-gateway-smoke-test": [ diff --git a/skills/opensource-pipeline/SKILL.md b/skills/opensource-pipeline/SKILL.md index 7d8e4c5a9..d0f03397c 100644 --- a/skills/opensource-pipeline/SKILL.md +++ b/skills/opensource-pipeline/SKILL.md @@ -192,6 +192,12 @@ sanitizer again exactly as in Step 4, against what will be published. Apply the Step 4, including the fix loop and the unreadable-files check. A FAIL here stops the pipeline: packaging can add a personal email, a home path or a key in an example. +When the gate passes, record the commit the sanitizer scanned as `{SCANNED_COMMIT}`: + +```bash +git -C "{STAGING_PATH}" rev-parse HEAD +``` + #### Step 6: Final Review Present to user: @@ -220,13 +226,15 @@ Proceed with GitHub creation? (yes/no/review first) #### Step 7: GitHub Publish (on user approval) -Push only the commit the sanitizer passed. If the tree changed since Step 5b (for example the user -edited files after "review first"), go back to Step 5b instead of pushing. +Push only the commit the sanitizer passed. If the tree or the commit changed since Step 5b (for +example the user edited files after "review first" and amended), go back to Step 5b instead of +pushing. ```bash cd "{STAGING_PATH}" test -z "$(git status --porcelain)" && test "$(git rev-list --count HEAD)" = 1 \ - || { echo "Uncommitted changes or extra commits: return to Step 5b" >&2; exit 1; } + && test "$(git rev-parse HEAD)" = "{SCANNED_COMMIT}" \ + || { echo "Uncommitted changes, extra commits, or HEAD is not the scanned commit: return to Step 5b" >&2; exit 1; } gh repo create "{github_org}/{github_repo}" --public --source=. --push --description "{description}" ``` diff --git a/tests/skills/opensource-pipeline-publish-guard.test.js b/tests/skills/opensource-pipeline-publish-guard.test.js new file mode 100644 index 000000000..a91e46b79 --- /dev/null +++ b/tests/skills/opensource-pipeline-publish-guard.test.js @@ -0,0 +1,114 @@ +#!/usr/bin/env node +/** + * Runs the documented Step 7 publish guard of skills/opensource-pipeline/SKILL.md + * against a throwaway git repo, with `gh` stubbed so nothing is published. + * + * Run with: node tests/skills/opensource-pipeline-publish-guard.test.js + */ + +'use strict'; + +const assert = require('assert'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const { spawnSync } = require('child_process'); + +const SKILL_MD = path.join(__dirname, '..', '..', 'skills', 'opensource-pipeline', 'SKILL.md'); + +function test(name, fn) { + try { + fn(); + console.log(` ✓ ${name}`); + return true; + } catch (err) { + console.log(` ✗ ${name}`); + console.log(` Error: ${err.message}`); + return false; + } +} + +function publishBlock() { + const md = fs.readFileSync(SKILL_MD, 'utf8'); + const step = md.split('#### Step 7: GitHub Publish')[1]; + assert.ok(step, 'SKILL.md is missing "#### Step 7: GitHub Publish"'); + const m = step.match(/```bash\n([\s\S]*?)```/); + assert.ok(m, 'no bash block in Step 7'); + return m[1]; +} + +function git(cwd, ...args) { + const r = spawnSync('git', args, { cwd, encoding: 'utf8' }); + assert.strictEqual(r.status, 0, r.stderr); + return r.stdout.trim(); +} + +// One-commit staging repo; returns the commit the sanitizer "scanned". +function withStaging(fn) { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'oss-publish-')); + try { + const staging = path.join(root, 'staging'); + const bin = path.join(root, 'bin'); + fs.mkdirSync(staging); + fs.mkdirSync(bin); + fs.writeFileSync(path.join(bin, 'gh'), `#!/bin/sh\ntouch "${path.join(root, 'published')}"\n`, { mode: 0o755 }); + git(staging, 'init', '-q'); + git(staging, 'config', 'user.email', 'test@example.com'); + git(staging, 'config', 'user.name', 'test'); + fs.writeFileSync(path.join(staging, 'README.md'), 'clean\n'); + git(staging, 'add', '-A'); + git(staging, 'commit', '-q', '-m', 'init'); + const scanned = git(staging, 'rev-parse', 'HEAD'); + const publish = () => { + const script = publishBlock() + .replaceAll('{STAGING_PATH}', staging) + .replaceAll('{SCANNED_COMMIT}', scanned); + const r = spawnSync('bash', ['-c', script], { + encoding: 'utf8', + env: { ...process.env, PATH: `${bin}${path.delimiter}${process.env.PATH}` }, + }); + return { status: r.status, published: fs.existsSync(path.join(root, 'published')) }; + }; + fn({ staging, publish }); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +} + +function runTests() { + console.log('\n=== Testing opensource-pipeline publish guard ===\n'); + + if (process.platform === 'win32') { + console.log(' - skipped: bash guard test on Windows'); + console.log('\nPassed: 0'); + console.log('Failed: 0'); + return; + } + + let passed = 0; + let failed = 0; + + if (test('publishes the commit the sanitizer scanned', () => { + withStaging(({ publish }) => { + const r = publish(); + assert.strictEqual(r.status, 0); + assert.ok(r.published, 'gh repo create was not run'); + }); + })) passed++; else failed++; + + if (test('refuses to publish a commit amended after the scan', () => { + withStaging(({ staging, publish }) => { + fs.writeFileSync(path.join(staging, 'README.md'), 'edited during review: me@example.com\n'); + git(staging, 'commit', '-q', '-a', '--amend', '--no-edit'); + const r = publish(); + assert.notStrictEqual(r.status, 0); + assert.ok(!r.published, 'gh repo create ran for an unscanned commit'); + }); + })) passed++; else failed++; + + console.log(`\nPassed: ${passed}`); + console.log(`Failed: ${failed}`); + process.exitCode = failed > 0 ? 1 : 0; +} + +runTests();