Skip to content

Commit b0d60e9

Browse files
nullvariantclaude
andauthored
feat(git-id-switcher): redesign hash verification workflow (#183)
* feat(git-id-switcher): redesign hash verification workflow for branch protection - Add hash-check.yml for pre-merge hash validation on PRs - Simplify deploy-docs.yml to CDN-only deployment - Add getHashKey() function for consistent hash key generation - Support monorepo root files (README.md, CONTRIBUTING.md, LICENSE) - Update hash keys to align with CDN paths - Regenerate DOCUMENT_HASHES with 38 files (32 extension + 6 monorepo) - Bump version to 0.13.8 🖥️ IDE: [Cursor](https://cursor.sh) 🔌 Extension: [Claude Code](https://claude.ai/download) Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com> Model-Raw: claude-opus-4-5-20251101 * chore: regenerate CHANGELOG hash after version update 🖥️ IDE: [Cursor](https://cursor.sh) 🔌 Extension: [Claude Code](https://claude.ai/download) Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com> Model-Raw: claude-opus-4-5-20251101 --------- Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 61160e1 commit b0d60e9

7 files changed

Lines changed: 328 additions & 93 deletions

File tree

.github/workflows/deploy-docs.yml

Lines changed: 3 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ jobs:
1818
deploy:
1919
runs-on: ubuntu-latest
2020
permissions:
21-
contents: write # Required for hash update commits
21+
contents: read # Read-only: CDN deploy only (Option D)
2222
# Prevent forks from deploying (no Cloudflare secrets)
2323
if: github.repository == 'nullvariant/nullvariant-vscode-extensions'
2424

@@ -31,30 +31,8 @@ jobs:
3131
- name: Checkout
3232
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
3333

34-
- name: Setup Node.js
35-
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
36-
with:
37-
node-version: '20'
38-
39-
- name: Install dependencies for hash script
40-
working-directory: extensions/git-id-switcher
41-
run: npm ci
42-
43-
- name: Update document hashes
44-
working-directory: extensions/git-id-switcher
45-
run: node scripts/update-doc-hashes.mjs
46-
47-
- name: Commit hash updates
48-
run: |
49-
git config user.name "github-actions[bot]"
50-
git config user.email "github-actions[bot]@users.noreply.github.com"
51-
git add extensions/git-id-switcher/src/documentation.internal.ts
52-
if git diff --staged --quiet; then
53-
echo "No hash changes"
54-
else
55-
git commit -m "chore: auto-update document hashes"
56-
git push
57-
fi
34+
# Note: Hash update is handled by developers locally + hash-check.yml in PRs
35+
# This workflow only deploys to CDN (Option D: Pre-merge CI verification)
5836

5937
- name: Deploy documentation to R2
6038
run: |

.github/workflows/hash-check.yml

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
name: Hash Check
2+
3+
on:
4+
pull_request:
5+
paths:
6+
# Monorepo root documentation (all .md files + LICENSE)
7+
- '*.md'
8+
- 'LICENSE'
9+
# Extension documentation (add new extensions here)
10+
- 'extensions/git-id-switcher/**/*.md'
11+
- 'extensions/git-id-switcher/LICENSE'
12+
- 'extensions/git-id-switcher/src/documentation.internal.ts'
13+
14+
permissions:
15+
contents: read
16+
17+
jobs:
18+
check-hashes:
19+
runs-on: ubuntu-latest
20+
strategy:
21+
fail-fast: false # Check all extensions even if one fails
22+
matrix:
23+
# Monorepo support: add new extensions here
24+
extension:
25+
- { dir: "git-id-switcher", name: "Git ID Switcher" }
26+
27+
steps:
28+
- name: Harden Runner
29+
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
30+
with:
31+
egress-policy: audit
32+
33+
- name: Checkout
34+
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
35+
36+
- name: Setup Node.js
37+
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
38+
with:
39+
node-version: '20'
40+
41+
- name: Install dependencies
42+
working-directory: extensions/${{ matrix.extension.dir }}
43+
run: npm ci
44+
45+
- name: Check hash consistency
46+
working-directory: extensions/${{ matrix.extension.dir }}
47+
run: |
48+
echo "Checking ${{ matrix.extension.name }} document hashes..."
49+
50+
# Generate expected hashes (includes monorepo root files)
51+
node scripts/update-doc-hashes.mjs
52+
53+
# Check if there are uncommitted changes
54+
if git diff --quiet src/documentation.internal.ts; then
55+
echo "DOCUMENT_HASHES is up to date"
56+
else
57+
echo ""
58+
echo "DOCUMENT_HASHES is out of sync!"
59+
echo ""
60+
echo "This can happen when:"
61+
echo " - A .md file was added, modified, or deleted in the extension"
62+
echo " - A .md file at monorepo root was added, modified, or deleted"
63+
echo ""
64+
echo "Run the following command locally and commit the changes:"
65+
echo " cd extensions/${{ matrix.extension.dir }} && node scripts/update-doc-hashes.mjs"
66+
echo ""
67+
echo "Diff:"
68+
git diff src/documentation.internal.ts
69+
exit 1
70+
fi

extensions/git-id-switcher/CHANGELOG.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
## [0.13.8] - 2026-01-19
11+
12+
### Changed
13+
14+
- **Hash Verification Workflow**: Redesigned for branch protection compliance
15+
- Added `hash-check.yml` workflow for pre-merge hash validation on PRs
16+
- Simplified `deploy-docs.yml` to CDN-only deployment (removed auto-commit)
17+
- Hash keys now align with CDN paths for consistency
18+
- Added monorepo root file support (README.md, CONTRIBUTING.md, LICENSE)
19+
- Total files tracked: 38 (32 extension + 6 monorepo root)
20+
21+
### Fixed
22+
23+
- **Hash Key Format**: Fixed hash verification failing due to key format mismatch
24+
- Added `getHashKey()` function for consistent hash key generation
25+
- Updated `verifyContentHash()` to support both extension and monorepo root files
26+
1027
## [0.13.7] - 2026-01-19
1128

1229
### Fixed

extensions/git-id-switcher/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
"name": "git-id-switcher",
33
"displayName": "%extension.displayName%",
44
"description": "%extension.description%",
5-
"version": "0.13.7",
5+
"version": "0.13.8",
66
"publisher": "nullvariant",
77
"icon": "images/icon.png",
88
"engines": {

extensions/git-id-switcher/scripts/update-doc-hashes.mjs

Lines changed: 108 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -3,19 +3,29 @@
33
* Update DOCUMENT_HASHES in documentation.internal.ts
44
*
55
* Target files:
6-
* - ALL .md files (anywhere in the extension directory)
7-
* - LICENSE (only non-.md exception, explicitly included)
6+
* - Extension: ALL .md files + LICENSE (anywhere in the extension directory)
7+
* - Monorepo root: ALL *.md files + LICENSE (auto-detected via glob)
88
*
9-
* This ensures any new .md file is automatically detected.
9+
* Hash keys (CDN path based):
10+
* - Extension files: extensions/git-id-switcher/{relative_path}
11+
* - Monorepo root files: {filename} (e.g., README.md, CONTRIBUTING.md)
12+
*
13+
* This ensures DOCUMENT_HASHES keys match CDN paths exactly.
1014
*
1115
* Usage: node scripts/update-doc-hashes.mjs
1216
*/
1317
import { createHash } from 'node:crypto';
14-
import { readFile, writeFile } from 'node:fs/promises';
18+
import { readFile, writeFile, access } from 'node:fs/promises';
1519
import { glob } from 'glob';
1620

1721
const HASH_FILE = 'src/documentation.internal.ts';
1822

23+
// Extension identifier for CDN path construction
24+
const EXTENSION_CDN_PREFIX = 'extensions/git-id-switcher';
25+
26+
// Monorepo root relative path (from extension directory)
27+
const MONOREPO_ROOT = '../..';
28+
1929
/**
2030
* Compute SHA-256 hash of file content
2131
* @param {string} filePath - Path to file
@@ -26,9 +36,25 @@ async function computeSha256(filePath) {
2636
return createHash('sha256').update(content).digest('hex');
2737
}
2838

39+
/**
40+
* Check if a file exists
41+
* @param {string} filePath - Path to check
42+
* @returns {Promise<boolean>} true if file exists
43+
*/
44+
async function fileExists(filePath) {
45+
try {
46+
await access(filePath);
47+
return true;
48+
} catch {
49+
return false;
50+
}
51+
}
52+
2953
async function main() {
30-
// Discover ALL .md files + LICENSE (the only non-.md exception)
31-
const allFiles = await glob([
54+
// ========================================
55+
// 1. Discover extension files
56+
// ========================================
57+
const extensionFiles = await glob([
3258
'**/*.md', // All .md files anywhere
3359
'LICENSE', // Only non-.md exception
3460
], {
@@ -39,25 +65,68 @@ async function main() {
3965
],
4066
});
4167

42-
if (allFiles.length === 0) {
43-
console.error('Error: No files found. Are you running from the correct directory?');
68+
if (extensionFiles.length === 0) {
69+
console.error('Error: No extension files found. Are you running from the correct directory?');
4470
process.exit(1);
4571
}
4672

47-
// Compute hashes for all discovered files
73+
// ========================================
74+
// 2. Compute hashes for extension files
75+
// ========================================
4876
const hashes = {};
49-
for (const file of allFiles.sort()) {
50-
hashes[file] = await computeSha256(file);
77+
78+
for (const file of extensionFiles.sort()) {
79+
// CDN key for extension files: extensions/git-id-switcher/{file}
80+
const cdnKey = `${EXTENSION_CDN_PREFIX}/${file}`;
81+
hashes[cdnKey] = await computeSha256(file);
82+
}
83+
84+
console.log(`Found ${extensionFiles.length} extension files`);
85+
86+
// ========================================
87+
// 3. Discover monorepo root files
88+
// ========================================
89+
const monorepoFiles = await glob([
90+
'*.md', // All .md files at monorepo root (not recursive)
91+
'LICENSE', // LICENSE file
92+
], {
93+
cwd: MONOREPO_ROOT,
94+
});
95+
96+
console.log(`Found ${monorepoFiles.length} monorepo root files`);
97+
98+
// ========================================
99+
// 4. Compute hashes for monorepo root files
100+
// ========================================
101+
for (const file of monorepoFiles.sort()) {
102+
const localPath = `${MONOREPO_ROOT}/${file}`;
103+
104+
// Verify file exists (defensive check)
105+
if (!(await fileExists(localPath))) {
106+
console.warn(`Warning: Monorepo file not found: ${file}`);
107+
continue;
108+
}
109+
110+
// CDN key for monorepo root: just the filename
111+
hashes[file] = await computeSha256(localPath);
51112
}
52113

53-
// Generate DOCUMENT_HASHES entries
114+
// ========================================
115+
// 5. Generate DOCUMENT_HASHES block
116+
// ========================================
117+
const totalFiles = Object.keys(hashes).length;
118+
119+
// Sort entries by key for consistent diffs
54120
const entries = Object.entries(hashes)
121+
.sort(([a], [b]) => a.localeCompare(b))
55122
.map(([path, hash]) => ` '${path}': '${hash}',`)
56123
.join('\n');
57124

58125
const hashBlock = `export const DOCUMENT_HASHES: Record<string, string> = {\n${entries}\n};`;
59126

60-
// Read and update the file
127+
// ========================================
128+
// 6. Read and update the file
129+
// ========================================
61130
let content;
62131
try {
63132
content = await readFile(HASH_FILE, 'utf-8');
@@ -79,17 +148,38 @@ async function main() {
79148

80149
// Check if there are actual changes
81150
if (content === originalContent) {
82-
console.log(`DOCUMENT_HASHES is already up to date (${Object.keys(hashes).length} files)`);
151+
console.log(`\nDOCUMENT_HASHES is already up to date (${totalFiles} files)`);
83152
process.exit(0); // Success - no changes needed
84153
}
85154

86155
await writeFile(HASH_FILE, content);
87-
console.log(`Regenerated DOCUMENT_HASHES with ${Object.keys(hashes).length} files`);
156+
console.log(`\nRegenerated DOCUMENT_HASHES with ${totalFiles} files`);
88157

89-
// List all files for verification
158+
// ========================================
159+
// 7. List all files for verification
160+
// ========================================
90161
console.log('\nFiles included:');
91-
for (const file of Object.keys(hashes).sort()) {
92-
console.log(` - ${file}`);
162+
163+
// Separate extension and monorepo files for clarity
164+
const extensionKeys = Object.keys(hashes)
165+
.filter(k => k.startsWith(EXTENSION_CDN_PREFIX))
166+
.sort();
167+
const monorepoKeys = Object.keys(hashes)
168+
.filter(k => !k.startsWith(EXTENSION_CDN_PREFIX))
169+
.sort();
170+
171+
if (extensionKeys.length > 0) {
172+
console.log('\n [Extension files]');
173+
for (const key of extensionKeys) {
174+
console.log(` - ${key}`);
175+
}
176+
}
177+
178+
if (monorepoKeys.length > 0) {
179+
console.log('\n [Monorepo root files]');
180+
for (const key of monorepoKeys) {
181+
console.log(` - ${key}`);
182+
}
93183
}
94184
}
95185

0 commit comments

Comments
 (0)