diff --git a/.github/release/pre-commit.js b/.github/release/pre-commit.js new file mode 100644 index 0000000..97d5665 --- /dev/null +++ b/.github/release/pre-commit.js @@ -0,0 +1,120 @@ +'use strict' + +/** + * conventional-changelog-action@v5 pre-commit hook. + * + * Runs AFTER the changelog/version-file bump and BEFORE the action's + * git add/commit/tag. Files staged here land in the release commit AND + * the tag, so tag-pinned consumers (agent-plugins pins ref:vX.Y.Z) get a + * tree whose SKILL.md / .codex-plugin/plugin.json versions match the tag. + * + * This replaces the old post-tag amend + force-push, which left the tag + * pointing at a pre-sync tree. + * + * Node built-ins only (fs, path, child_process). No npm deps. The action + * does NOT auto-stage hook-modified files (only version-file + the + * changelog), so the hook stages them explicitly. + * + * SKILL.md metadata.version stays the single CI-owned version source; + * .codex-plugin/plugin.json mirrors it for the Codex plugin host. + */ + +const fs = require('fs') +const path = require('path') +const { execSync } = require('child_process') + +const SKILL_REL = 'skills/terraform-skill/SKILL.md' +const MANIFEST_REL = '.codex-plugin/plugin.json' +const SENTINEL_REL = 'version.json' + +function repoRoot() { + const root = process.env.GITHUB_WORKSPACE || process.cwd() + if (!fs.existsSync(path.join(root, SENTINEL_REL))) { + throw new Error( + `pre-commit: sentinel ${SENTINEL_REL} not found in repo root ` + + `${root}; refusing to run (wrong working directory?)` + ) + } + return root +} + +// Rewrite the SKILL.md YAML frontmatter " version: X.Y.Z" line in place, +// preserving the exact 2-space indent (version is a child of metadata:). +function updateSkillVersion(root, version) { + const file = path.join(root, SKILL_REL) + if (!fs.existsSync(file)) { + throw new Error(`pre-commit: ${SKILL_REL} not found`) + } + const lines = fs.readFileSync(file, 'utf8').split('\n') + let updated = false + // Frontmatter is the leading block; the version line sits within the + // first handful of lines. Bound the search to avoid a body match. + const limit = Math.min(lines.length, 15) + for (let i = 0; i < limit; i++) { + if (lines[i].trimStart().startsWith('version:')) { + lines[i] = ` version: ${version}` + updated = true + break + } + } + if (!updated) { + throw new Error( + `pre-commit: version line not found in ${SKILL_REL} frontmatter` + ) + } + fs.writeFileSync(file, lines.join('\n')) + return SKILL_REL +} + +// Value-only replace of the top-level " "version": "..."" line. +// Anchored to a 2-space indent; rewrites only the quoted value so the +// trailing comma / key position / byte layout are untouched (no JSON +// re-serialize, no diff noise, position-independent). +function updateCodexManifest(root, version) { + const file = path.join(root, MANIFEST_REL) + if (!fs.existsSync(file)) { + return null + } + const src = fs.readFileSync(file, 'utf8') + const re = /^( {2}"version":\s*)"[^"]*"/m + if (!re.test(src)) { + throw new Error( + `pre-commit: top-level "version" line not found in ${MANIFEST_REL}` + ) + } + fs.writeFileSync(file, src.replace(re, `$1"${version}"`)) + return MANIFEST_REL +} + +async function preCommit(props) { + const version = props && props.version + if (!version || typeof version !== 'string') { + throw new Error(`pre-commit: invalid version from action: ${version}`) + } + + let root + try { + root = repoRoot() + const skill = updateSkillVersion(root, version) + console.log(`pre-commit: synced ${skill} -> ${version}`) + + const manifest = updateCodexManifest(root, version) + if (manifest) { + console.log(`pre-commit: synced ${manifest} -> ${version}`) + } else { + console.log(`pre-commit: ${MANIFEST_REL} absent; skipped`) + } + + // The action stages only version-file + changelog. Stage ours so + // they are in the release commit and the tag. + const toStage = [skill] + if (manifest) toStage.push(manifest) + execSync(`git add ${toStage.join(' ')}`, { cwd: root, stdio: 'inherit' }) + } catch (err) { + // Fail loud so the release job stops rather than tagging a stale tree. + console.error(`pre-commit: ${err && err.message ? err.message : err}`) + throw err + } +} + +module.exports = { preCommit } diff --git a/.github/workflows/automated-release.yml b/.github/workflows/automated-release.yml index aac8818..4ad566e 100644 --- a/.github/workflows/automated-release.yml +++ b/.github/workflows/automated-release.yml @@ -47,110 +47,11 @@ jobs: skip-on-empty: 'false' skip-version-file: 'false' skip-commit: 'false' - - # 2b. Sync version fields and git ref - # This ensures all version fields stay synchronized: - # - root version.json (updated by conventional-changelog-action above) - # - SKILL.md metadata.version (synced here) - # - .codex-plugin/plugin.json version (synced here, if present) - - name: Sync Plugin Version and SKILL.md - if: steps.changelog.outputs.skipped == 'false' - env: - VERSION: ${{ steps.changelog.outputs.version }} - run: | - python3 << 'EOF' - import json - import os - import sys - - def update_skill_version(version): - """Update version in SKILL.md YAML frontmatter.""" - skill_path = 'skills/terraform-skill/SKILL.md' - - if not os.path.exists(skill_path): - raise FileNotFoundError(f"{skill_path} not found") - - with open(skill_path, 'r') as f: - lines = f.readlines() - - # Find and update version line in frontmatter (typically line 7) - updated = False - for i, line in enumerate(lines): - # Match " version: X.Y.Z" pattern (2 spaces indent) - if line.strip().startswith('version:') and i < 10: # Within frontmatter - lines[i] = f' version: {version}\n' - updated = True - break - - if not updated: - raise ValueError("Could not find version field in SKILL.md frontmatter") - - with open(skill_path, 'w') as f: - f.writelines(lines) - - return skill_path - - def update_codex_manifest(version): - """Mirror the version into .codex-plugin/plugin.json if present. - - The Codex plugin host reads this manifest when the - agent-plugins marketplace resolves terraform-skill as a - url-source plugin. Keep its version in lockstep with the - SKILL.md single version source so it never goes stale. - """ - manifest_path = '.codex-plugin/plugin.json' - - if not os.path.exists(manifest_path): - return None - - with open(manifest_path, 'r') as f: - lines = f.readlines() - - updated = False - for i, line in enumerate(lines): - # Top-level key only: exactly 2-space indent. Anchored so a - # nested "version" key could never be matched by accident. - if line.startswith(' "version":'): - lines[i] = f' "version": "{version}"\n' - updated = True - break - - if not updated: - raise ValueError( - "Could not find version field in .codex-plugin/plugin.json") - - with open(manifest_path, 'w') as f: - f.writelines(lines) - - return manifest_path - - try: - version = os.environ['VERSION'] - - # SKILL.md frontmatter is the single version source. - skill_path = update_skill_version(version) - print(f"✅ Synced {skill_path} metadata.version to {version}") - - manifest_path = update_codex_manifest(version) - if manifest_path: - print(f"✅ Synced {manifest_path} version to {version}") - else: - print("ℹ️ No .codex-plugin/plugin.json; skipped") - - except Exception as e: - print(f"❌ ERROR: Failed to sync versions: {e}") - sys.exit(1) - EOF - - # Commit the sync - git config user.name "github-actions[bot]" - git config user.email "github-actions[bot]@users.noreply.github.com" - git add version.json skills/terraform-skill/SKILL.md - if [ -f .codex-plugin/plugin.json ]; then - git add .codex-plugin/plugin.json - fi - git commit --amend --no-edit - git push --force-with-lease + skip-ci: 'true' + # Sync SKILL.md + .codex-plugin/plugin.json BEFORE the action's + # commit/tag so tag-pinned consumers get a matching tree. Replaces + # the old post-tag amend + force-push. + pre-commit: '.github/release/pre-commit.js' # 3. Create GitHub Release # Only run if a new version was created diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index 4f711e2..148e6b3 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -6,12 +6,16 @@ on: - 'skills/**' - '.claude-plugin/**' - '.codex-plugin/**' + - '.github/workflows/**' + - '.github/release/**' push: branches: [master, main] paths: - 'skills/**' - '.claude-plugin/**' - '.codex-plugin/**' + - '.github/workflows/**' + - '.github/release/**' workflow_dispatch: jobs: