@marcomigozzi/trailhead 0.12.0 → 0.12.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/trailhead.test.js +27 -6
- package/package.json +1 -1
- package/plugins/trailhead/.claude-plugin/plugin.json +1 -1
- package/plugins/trailhead/hooks/lib/fixtures/label-guard-0.12.0.yml +55 -0
- package/plugins/trailhead/hooks/lib/label-guard-sync.test.js +156 -0
- package/plugins/trailhead/hooks/trailhead-check-update.js +25 -1
- package/plugins/trailhead/skills/_shared/load-first.md +2 -0
- package/plugins/trailhead/skills/_shared/substrate-commands.md +1 -1
- package/plugins/trailhead/skills/_shared/teamwork.md +2 -0
- package/plugins/trailhead/skills/trailhead-manage/references/updating.md +4 -0
- package/plugins/trailhead/templates/trailhead-label-guard-sync.js +212 -0
- package/plugins/trailhead/templates/trailhead-label-guard.yml +5 -0
package/bin/trailhead.test.js
CHANGED
|
@@ -123,6 +123,10 @@ const codexCommitMsgSync = path.join(codexDir, 'skills', 'trailhead', 'templates
|
|
|
123
123
|
ok('codex: commit-msg-sync script projected', fs.existsSync(codexCommitMsgSync));
|
|
124
124
|
ok('codex: commit-msg-sync script is byte-identical to source',
|
|
125
125
|
fs.readFileSync(codexCommitMsgSync, 'utf8') === fs.readFileSync(path.join(repoRoot, 'plugins', 'trailhead', 'templates', 'trailhead-commit-msg-sync.js'), 'utf8'));
|
|
126
|
+
// Il sync del label guard (migrazione all'if a livello di job) viaggia con templates/.
|
|
127
|
+
const codexLabelGuardSync = path.join(codexDir, 'skills', 'trailhead', 'templates', 'trailhead-label-guard-sync.js');
|
|
128
|
+
ok('codex: label-guard-sync script is byte-identical to source', fs.existsSync(codexLabelGuardSync) &&
|
|
129
|
+
fs.readFileSync(codexLabelGuardSync, 'utf8') === fs.readFileSync(path.join(repoRoot, 'plugins', 'trailhead', 'templates', 'trailhead-label-guard-sync.js'), 'utf8'));
|
|
126
130
|
// #193: the audit fast path / attribution walk script rides in templates/ on Codex too.
|
|
127
131
|
const codexAuditWalk = path.join(codexDir, 'skills', 'trailhead', 'templates', 'trailhead-audit-walk.js');
|
|
128
132
|
ok('codex: audit-walk script projected', fs.existsSync(codexAuditWalk));
|
|
@@ -870,6 +874,9 @@ const claudeCommitMsgSync = path.join(claudeDir, 'trailhead', 'templates', 'trai
|
|
|
870
874
|
ok('claude: commit-msg-sync script projected', fs.existsSync(claudeCommitMsgSync));
|
|
871
875
|
ok('claude: commit-msg-sync script is byte-identical to source',
|
|
872
876
|
fs.readFileSync(claudeCommitMsgSync, 'utf8') === fs.readFileSync(path.join(repoRoot, 'plugins', 'trailhead', 'templates', 'trailhead-commit-msg-sync.js'), 'utf8'));
|
|
877
|
+
const claudeLabelGuardSync = path.join(claudeDir, 'trailhead', 'templates', 'trailhead-label-guard-sync.js');
|
|
878
|
+
ok('claude: label-guard-sync script is byte-identical to source', fs.existsSync(claudeLabelGuardSync) &&
|
|
879
|
+
fs.readFileSync(claudeLabelGuardSync, 'utf8') === fs.readFileSync(path.join(repoRoot, 'plugins', 'trailhead', 'templates', 'trailhead-label-guard-sync.js'), 'utf8'));
|
|
873
880
|
const claudeAuditWalk = path.join(claudeDir, 'trailhead', 'templates', 'trailhead-audit-walk.js');
|
|
874
881
|
ok('claude: audit-walk script projected', fs.existsSync(claudeAuditWalk));
|
|
875
882
|
ok('claude: audit-walk script is byte-identical to source',
|
|
@@ -1159,6 +1166,14 @@ for (const [label, src] of [
|
|
|
1159
1166
|
ok(`source: ${label} has no em-dash`, !src.includes('—'));
|
|
1160
1167
|
}
|
|
1161
1168
|
|
|
1169
|
+
// --- label guard: setup e update rilevano/migrano un guard senza if a livello di job
|
|
1170
|
+
ok('source: teamwork.md references trailhead-label-guard-sync.js', teamworkSource.includes('trailhead-label-guard-sync.js'));
|
|
1171
|
+
ok('source: substrate-commands.md references trailhead-label-guard-sync.js', substrateCommandsSource.includes('trailhead-label-guard-sync.js'));
|
|
1172
|
+
ok('source: load-first.md runs the label guard check with --notice',
|
|
1173
|
+
fs.readFileSync(path.join(repoRoot, 'plugins', 'trailhead', 'skills', '_shared', 'load-first.md'), 'utf8').includes('trailhead-label-guard-sync.js --repo <project root> --notice'));
|
|
1174
|
+
ok('source: updating.md realigns the repo label guard',
|
|
1175
|
+
fs.readFileSync(path.join(repoRoot, 'plugins', 'trailhead', 'skills', 'trailhead-manage', 'references', 'updating.md'), 'utf8').includes('label guard sync script'));
|
|
1176
|
+
|
|
1162
1177
|
// --- #189: script mancante / errore node -> fallback che ispeziona la hooks dir
|
|
1163
1178
|
// invece di dichiarare alla cieca "git non applica le regole" ------------------
|
|
1164
1179
|
ok('source: teamwork.md fallback resolves the hooks dir via rev-parse --git-path hooks',
|
|
@@ -1583,9 +1598,16 @@ const auditDescLine192 = (auditCmdSrc.match(/^description: (.+)$/m) || [])[1] ||
|
|
|
1583
1598
|
ok('source: commands/audit.md description mentions regressions, starts "Audit and stays under 180 chars',
|
|
1584
1599
|
/^"Audit /.test(auditDescLine192) && /regression/i.test(auditDescLine192) && auditDescLine192.length < 180);
|
|
1585
1600
|
ok('source: manage SKILL.md describes the regression audit', /regression/i.test((manageSkillSrc190.match(/^description: .+$/m) || [''])[0]) || /regression/i.test((manageSkillSrc190.match(/\*\*`audit[^\n]*/) || [''])[0]));
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
1601
|
+
// Il sito è diviso: commands.md è solo l'indice, il dettaglio di audit vive
|
|
1602
|
+
// nella sezione `### /trailhead:audit` della guida manage.
|
|
1603
|
+
const siteManageDoc = path.join('site', 'src', 'content', 'docs', 'docs', 'commands', 'manage.md');
|
|
1604
|
+
const auditMention = (doc) => {
|
|
1605
|
+
const src = fs.readFileSync(path.join(repoRoot, doc), 'utf8');
|
|
1606
|
+
if (doc === siteManageDoc) return (src.match(/### \/trailhead:audit[\s\S]*?(?=\n### |\nNext:|$)/) || [''])[0];
|
|
1607
|
+
return src.split('\n').filter((l) => l.includes('/trailhead:audit')).join('\n');
|
|
1608
|
+
};
|
|
1609
|
+
for (const doc of ['README.md', 'README.it.md', siteManageDoc]) {
|
|
1610
|
+
const auditPara192 = auditMention(doc);
|
|
1589
1611
|
ok(`docs: ${doc} audit mention covers unchanged and evolved`, /unchanged|invariat/i.test(auditPara192) && /evolved|evolut/i.test(auditPara192));
|
|
1590
1612
|
}
|
|
1591
1613
|
|
|
@@ -1622,9 +1644,8 @@ ok('source: auditing.md step 5 dispatches the agent-bound set and reads code at
|
|
|
1622
1644
|
ok('source: auditing.md step 8 counts sum to the audited set with the light-check share',
|
|
1623
1645
|
/light check/i.test(step8Slice192) && /light-check share/.test(step8Slice192) && /not implemented, unverifiable/.test(step8Slice192));
|
|
1624
1646
|
ok('source: auditing.md step 10 flags unverifiable from step 4 or step 6', /\(step 4 or step 6\)/.test(step10Slice190));
|
|
1625
|
-
for (const doc of ['README.md', 'README.it.md',
|
|
1626
|
-
const
|
|
1627
|
-
const para = docSrc.split('\n').filter((l) => l.includes('/trailhead:audit')).join('\n');
|
|
1647
|
+
for (const doc of ['README.md', 'README.it.md', siteManageDoc]) {
|
|
1648
|
+
const para = auditMention(doc);
|
|
1628
1649
|
ok(`docs: ${doc} audit mention names the Refs trailer fast path`, /Refs: #<n>/.test(para));
|
|
1629
1650
|
ok(`docs: ${doc} audit mention covers tasks and prototypes in the light check`, /task/i.test(para) && /prototyp|prototipi/i.test(para));
|
|
1630
1651
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@marcomigozzi/trailhead",
|
|
3
|
-
"version": "0.12.
|
|
3
|
+
"version": "0.12.1",
|
|
4
4
|
"description": "Start and drive large projects as a map of tickets on GitHub Issues. Map-based onboarding + a discuss→plan→execute→verify engine, all on the Issues. Installs the trailhead skill, commands, and hooks into your agent's config dir.",
|
|
5
5
|
"bin": {
|
|
6
6
|
"trailhead": "bin/trailhead.js"
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "trailhead",
|
|
3
|
-
"version": "0.12.
|
|
3
|
+
"version": "0.12.1",
|
|
4
4
|
"description": "Start and drive large projects as a map of tickets on GitHub Issues, resolving one at a time. Map-based onboarding + a discuss→plan→execute→verify engine with atomic commits, all on the Issues. Ships as a dispatcher plus cohesion-clustered skills over a shared core; depends on nothing beyond an authenticated gh CLI.",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Marco Migozzi",
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# trailhead label guard: repo-side enforcement.
|
|
2
|
+
# Copy this file to your repository's .github/workflows/ directory.
|
|
3
|
+
# It strips any `trailhead:*` label applied by an actor without write access,
|
|
4
|
+
# so external / triage users (and stray automations) can't inject tickets into
|
|
5
|
+
# the trailhead map. Maintainers (write/admin/maintain) apply labels normally.
|
|
6
|
+
#
|
|
7
|
+
# Optional: set a repo variable TRAILHEAD_LABEL_ALLOWLIST to a comma-separated
|
|
8
|
+
# list of extra logins that are always allowed (e.g. a bot account trailhead runs as).
|
|
9
|
+
|
|
10
|
+
name: trailhead label guard
|
|
11
|
+
|
|
12
|
+
on:
|
|
13
|
+
issues:
|
|
14
|
+
types: [labeled]
|
|
15
|
+
|
|
16
|
+
permissions:
|
|
17
|
+
issues: write
|
|
18
|
+
|
|
19
|
+
jobs:
|
|
20
|
+
guard:
|
|
21
|
+
runs-on: ubuntu-latest
|
|
22
|
+
steps:
|
|
23
|
+
- uses: actions/github-script@v7
|
|
24
|
+
env:
|
|
25
|
+
ALLOWLIST: ${{ vars.TRAILHEAD_LABEL_ALLOWLIST }}
|
|
26
|
+
with:
|
|
27
|
+
script: |
|
|
28
|
+
const label = context.payload.label && context.payload.label.name || '';
|
|
29
|
+
if (!label.startsWith('trailhead:')) return;
|
|
30
|
+
|
|
31
|
+
const actor = context.payload.sender.login;
|
|
32
|
+
const extra = (process.env.ALLOWLIST || '')
|
|
33
|
+
.split(',').map(s => s.trim().toLowerCase()).filter(Boolean);
|
|
34
|
+
if (extra.includes(actor.toLowerCase())) return;
|
|
35
|
+
|
|
36
|
+
let permission = 'none';
|
|
37
|
+
try {
|
|
38
|
+
const r = await github.rest.repos.getCollaboratorPermissionLevel({
|
|
39
|
+
owner: context.repo.owner, repo: context.repo.repo, username: actor });
|
|
40
|
+
permission = r.data.permission; // admin | write | read | none (triage→read, maintain→write)
|
|
41
|
+
} catch (e) { /* not a collaborator → none */ }
|
|
42
|
+
|
|
43
|
+
if (['admin', 'write', 'maintain'].includes(permission)) return; // authorized
|
|
44
|
+
|
|
45
|
+
// Unauthorized: strip the reserved label and explain.
|
|
46
|
+
await github.rest.issues.removeLabel({
|
|
47
|
+
owner: context.repo.owner, repo: context.repo.repo,
|
|
48
|
+
issue_number: context.payload.issue.number, name: label,
|
|
49
|
+
}).catch(() => {});
|
|
50
|
+
|
|
51
|
+
await github.rest.issues.createComment({
|
|
52
|
+
owner: context.repo.owner, repo: context.repo.repo,
|
|
53
|
+
issue_number: context.payload.issue.number,
|
|
54
|
+
body: `⚠️ \`${label}\` is a **trailhead-reserved** label and was removed automatically: only repo maintainers (write access) apply \`trailhead:*\` labels. It was applied by @${actor}, who lacks write access. If this issue should join the trailhead map, a maintainer will adopt it.`,
|
|
55
|
+
}).catch(() => {});
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Tests for templates/trailhead-label-guard.yml and
|
|
3
|
+
// templates/trailhead-label-guard-sync.js. Run: node label-guard-sync.test.js
|
|
4
|
+
// No framework: plain asserts, mirrors the style of commit-msg-sync.test.js.
|
|
5
|
+
const assert = require('assert');
|
|
6
|
+
const { execFileSync } = require('child_process');
|
|
7
|
+
const path = require('path');
|
|
8
|
+
const fs = require('fs');
|
|
9
|
+
const os = require('os');
|
|
10
|
+
|
|
11
|
+
const templatesDir = path.resolve(__dirname, '..', '..', 'templates');
|
|
12
|
+
const scriptPath = path.join(templatesDir, 'trailhead-label-guard-sync.js');
|
|
13
|
+
const templatePath = path.join(templatesDir, 'trailhead-label-guard.yml');
|
|
14
|
+
const oldFixturePath = path.join(__dirname, 'fixtures', 'label-guard-0.12.0.yml');
|
|
15
|
+
const repoGuardPath = path.resolve(__dirname, '..', '..', '..', '..', '.github', 'workflows', 'trailhead-label-guard.yml');
|
|
16
|
+
|
|
17
|
+
const {
|
|
18
|
+
isTrailheadGuard, jobGuardIf, hasJobLevelFilter, classify, syncLabelGuard, sessionNotice, ackGuard, parseArgs, GUARD_REL, ACK_REL,
|
|
19
|
+
} = require(scriptPath);
|
|
20
|
+
|
|
21
|
+
let passed = 0;
|
|
22
|
+
const ok = (name, cond) => { assert.ok(cond, name); passed++; };
|
|
23
|
+
|
|
24
|
+
const templateText = fs.readFileSync(templatePath, 'utf8');
|
|
25
|
+
const oldText = fs.readFileSync(oldFixturePath, 'utf8');
|
|
26
|
+
|
|
27
|
+
function mktmp() {
|
|
28
|
+
return fs.mkdtempSync(path.join(os.tmpdir(), 'trailhead-label-guard-sync-test-'));
|
|
29
|
+
}
|
|
30
|
+
function writeGuard(repoDir, text) {
|
|
31
|
+
const file = path.join(repoDir, GUARD_REL);
|
|
32
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
33
|
+
fs.writeFileSync(file, text);
|
|
34
|
+
return file;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// --- il template filtra a livello di job (i job saltati non sono fatturati) ---
|
|
38
|
+
const cond = jobGuardIf(templateText);
|
|
39
|
+
ok('template: jobs.guard has a job-level if', cond !== null);
|
|
40
|
+
ok('template: job-level if filters on the trailhead: prefix',
|
|
41
|
+
cond.includes("startsWith(github.event.label.name, 'trailhead:')"));
|
|
42
|
+
ok('template: job-level if skips the repository owner',
|
|
43
|
+
cond.includes('github.event.sender.login != github.repository_owner'));
|
|
44
|
+
ok('template: job-level if skips allowlisted logins',
|
|
45
|
+
cond.includes("!contains(format(',{0},', vars.TRAILHEAD_LABEL_ALLOWLIST), format(',{0},', github.event.sender.login))"));
|
|
46
|
+
ok('template: the job-level if sits before runs-on',
|
|
47
|
+
templateText.indexOf(' if: >-') < templateText.indexOf(' runs-on: ubuntu-latest'));
|
|
48
|
+
ok('template: the in-script permission check is kept',
|
|
49
|
+
templateText.includes('getCollaboratorPermissionLevel') && templateText.includes("if (!label.startsWith('trailhead:')) return;"));
|
|
50
|
+
ok("template: the repo's own guard is byte-identical to the template",
|
|
51
|
+
fs.readFileSync(repoGuardPath, 'utf8') === templateText);
|
|
52
|
+
|
|
53
|
+
// --- riconoscimento e classificazione ----------------------------------------
|
|
54
|
+
ok('isTrailheadGuard: template recognised', isTrailheadGuard(templateText));
|
|
55
|
+
ok('isTrailheadGuard: 0.12.0 guard recognised', isTrailheadGuard(oldText));
|
|
56
|
+
ok('isTrailheadGuard: foreign workflow rejected', !isTrailheadGuard('name: ci\non: push\n'));
|
|
57
|
+
ok('jobGuardIf: 0.12.0 guard has no job-level if', jobGuardIf(oldText) === null);
|
|
58
|
+
ok('jobGuardIf: a step-level if does not count',
|
|
59
|
+
jobGuardIf('jobs:\n guard:\n runs-on: x\n steps:\n - if: startsWith(github.event.label.name, \'trailhead:\')\n') === null);
|
|
60
|
+
ok('jobGuardIf: an if on another job does not count',
|
|
61
|
+
jobGuardIf("jobs:\n guard:\n runs-on: x\n other:\n if: startsWith(github.event.label.name, 'trailhead:')\n") === null);
|
|
62
|
+
ok('hasJobLevelFilter: single-line if accepted',
|
|
63
|
+
hasJobLevelFilter("jobs:\n guard:\n if: startsWith(github.event.label.name, 'trailhead:')\n runs-on: x\n"));
|
|
64
|
+
|
|
65
|
+
ok('classify: missing file -> absent', classify({ existingText: null, templateText }) === 'absent');
|
|
66
|
+
ok('classify: template -> current', classify({ existingText: templateText, templateText }) === 'current');
|
|
67
|
+
ok('classify: CRLF template -> current', classify({ existingText: templateText.replace(/\n/g, '\r\n'), templateText }) === 'current');
|
|
68
|
+
ok('classify: 0.12.0 guard -> outdated', classify({ existingText: oldText, templateText }) === 'outdated');
|
|
69
|
+
ok('classify: filtered but edited -> custom',
|
|
70
|
+
classify({ existingText: templateText + '# local tweak\n', templateText }) === 'custom');
|
|
71
|
+
ok('classify: foreign workflow -> foreign', classify({ existingText: 'name: x\n', templateText }) === 'foreign');
|
|
72
|
+
|
|
73
|
+
// --- syncLabelGuard: check-only non scrive, --apply migra --------------------
|
|
74
|
+
{
|
|
75
|
+
const repo = mktmp();
|
|
76
|
+
const file = writeGuard(repo, oldText);
|
|
77
|
+
const r = syncLabelGuard({ repoDir: repo, templateText });
|
|
78
|
+
ok('sync check: outdated reported', r.status === 'outdated' && r.file === file);
|
|
79
|
+
ok('sync check: file untouched', fs.readFileSync(file, 'utf8') === oldText);
|
|
80
|
+
const a = syncLabelGuard({ repoDir: repo, templateText, apply: true });
|
|
81
|
+
ok('sync apply: outdated -> upgraded', a.status === 'upgraded');
|
|
82
|
+
ok('sync apply: file now equals the template', fs.readFileSync(file, 'utf8') === templateText);
|
|
83
|
+
ok('sync apply: no temp file left', fs.readdirSync(path.dirname(file)).length === 1);
|
|
84
|
+
ok('sync apply: second run -> current', syncLabelGuard({ repoDir: repo, templateText, apply: true }).status === 'current');
|
|
85
|
+
}
|
|
86
|
+
{
|
|
87
|
+
const repo = mktmp();
|
|
88
|
+
const a = syncLabelGuard({ repoDir: repo, templateText, apply: true });
|
|
89
|
+
ok('sync apply: absent -> installed', a.status === 'installed' && fs.readFileSync(a.file, 'utf8') === templateText);
|
|
90
|
+
}
|
|
91
|
+
for (const [label, text] of [['custom', templateText + '# local\n'], ['foreign', 'name: x\n']]) {
|
|
92
|
+
const repo = mktmp();
|
|
93
|
+
const file = writeGuard(repo, text);
|
|
94
|
+
const a = syncLabelGuard({ repoDir: repo, templateText, apply: true });
|
|
95
|
+
ok(`sync apply: ${label} left untouched`, a.status === label && fs.readFileSync(file, 'utf8') === text);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// --- offerta di sessione + ack ----------------------------------------------
|
|
99
|
+
{
|
|
100
|
+
const repo = mktmp();
|
|
101
|
+
const file = writeGuard(repo, oldText);
|
|
102
|
+
const notice = sessionNotice({ repoDir: repo, templateText, scriptPath: scriptPath });
|
|
103
|
+
ok('notice: outdated guard -> offer', notice !== null && notice.includes('--apply') && notice.includes('--ack') && notice.includes('job-level'));
|
|
104
|
+
ok('notice: asks the agent to explain why (billed minutes) in the question', notice.includes('put the reason in the question') && notice.includes('at least one minute') && notice.includes('skipped runs are free'));
|
|
105
|
+
ok('notice: read-only (no ack written)', !fs.existsSync(path.join(repo, ACK_REL)));
|
|
106
|
+
ok('notice: still offered on the next session', sessionNotice({ repoDir: repo, templateText, scriptPath }) !== null);
|
|
107
|
+
ok('ack: records the answer', ackGuard({ repoDir: repo }) === true && fs.existsSync(path.join(repo, ACK_REL)));
|
|
108
|
+
ok('notice: acked guard -> null', sessionNotice({ repoDir: repo, templateText, scriptPath }) === null);
|
|
109
|
+
fs.writeFileSync(file, oldText + '# edited\n');
|
|
110
|
+
ok('notice: comes back when the guard file changes', sessionNotice({ repoDir: repo, templateText, scriptPath }) !== null);
|
|
111
|
+
}
|
|
112
|
+
for (const [label, text] of [['current', templateText], ['custom', templateText + '# x\n'], ['foreign', 'name: x\n']]) {
|
|
113
|
+
const repo = mktmp();
|
|
114
|
+
writeGuard(repo, text);
|
|
115
|
+
ok(`notice: ${label} guard -> null`, sessionNotice({ repoDir: repo, templateText, scriptPath }) === null);
|
|
116
|
+
}
|
|
117
|
+
ok('notice: no guard -> null', sessionNotice({ repoDir: mktmp(), templateText, scriptPath }) === null);
|
|
118
|
+
ok('ack: no guard -> false', ackGuard({ repoDir: mktmp() }) === false);
|
|
119
|
+
|
|
120
|
+
// --- CLI ---------------------------------------------------------------------
|
|
121
|
+
ok('parseArgs: --repo needs a value', Boolean(parseArgs(['--repo']).error));
|
|
122
|
+
ok('parseArgs: unknown flag rejected', Boolean(parseArgs(['--nope']).error));
|
|
123
|
+
ok('parseArgs: --apply with --notice rejected', Boolean(parseArgs(['--notice', '--apply']).error));
|
|
124
|
+
ok('parseArgs: --notice with --ack rejected', Boolean(parseArgs(['--notice', '--ack']).error));
|
|
125
|
+
{
|
|
126
|
+
const repo = mktmp();
|
|
127
|
+
writeGuard(repo, oldText);
|
|
128
|
+
const out = JSON.parse(execFileSync('node', [scriptPath, '--repo', repo], { encoding: 'utf8' }));
|
|
129
|
+
ok('cli: outdated reported as JSON', out.status === 'outdated');
|
|
130
|
+
const applied = JSON.parse(execFileSync('node', [scriptPath, '--repo', repo, '--apply'], { encoding: 'utf8' }));
|
|
131
|
+
ok('cli: --apply upgrades', applied.status === 'upgraded');
|
|
132
|
+
}
|
|
133
|
+
{
|
|
134
|
+
const repo = mktmp();
|
|
135
|
+
writeGuard(repo, oldText);
|
|
136
|
+
ok('cli: --notice prints the offer', execFileSync('node', [scriptPath, '--repo', repo, '--notice'], { encoding: 'utf8' }).includes('outdated'));
|
|
137
|
+
execFileSync('node', [scriptPath, '--repo', repo, '--ack']);
|
|
138
|
+
ok('cli: --notice silent after --ack', execFileSync('node', [scriptPath, '--repo', repo, '--notice'], { encoding: 'utf8' }) === '');
|
|
139
|
+
}
|
|
140
|
+
// L'hook SessionStart porta l'offerta nel contesto della sessione.
|
|
141
|
+
{
|
|
142
|
+
const repo = mktmp();
|
|
143
|
+
execFileSync('git', ['init', '-q', repo]);
|
|
144
|
+
writeGuard(repo, oldText);
|
|
145
|
+
const hookPath = path.resolve(__dirname, '..', 'trailhead-check-update.js');
|
|
146
|
+
const run = () => execFileSync('node', [hookPath], {
|
|
147
|
+
input: JSON.stringify({ cwd: repo }), encoding: 'utf8',
|
|
148
|
+
env: { ...process.env, XDG_CACHE_HOME: path.join(repo, '.cache'), CLAUDE_CONFIG_DIR: path.join(repo, '.cfg') },
|
|
149
|
+
});
|
|
150
|
+
const ctx = (s) => (s ? JSON.parse(s).hookSpecificOutput.additionalContext : '');
|
|
151
|
+
ok('hook: SessionStart surfaces the label guard offer', ctx(run()).includes('label guard in this repo'));
|
|
152
|
+
ackGuard({ repoDir: repo });
|
|
153
|
+
ok('hook: no offer once acked', !ctx(run()).includes('label guard in this repo'));
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
console.log(`label-guard-sync: ${passed} passed`);
|
|
@@ -163,6 +163,29 @@ function reviewNotice(cwd) {
|
|
|
163
163
|
}
|
|
164
164
|
}
|
|
165
165
|
|
|
166
|
+
// Offerta di upgrade del label guard del repo (stesso schema della review dei
|
|
167
|
+
// modelli): solo quando il guard committato è la versione senza `if:` a
|
|
168
|
+
// livello di job e l'utente non ha già risposto per quel file. Lo script di
|
|
169
|
+
// sync vive in templates/: ../templates per plugin, dev e Codex, la copia npm
|
|
170
|
+
// sotto <configDir>/trailhead/templates. Sola lettura; ogni errore -> null.
|
|
171
|
+
function labelGuardNotice(cwd) {
|
|
172
|
+
try {
|
|
173
|
+
const candidates = [
|
|
174
|
+
path.join(__dirname, '..', 'templates'),
|
|
175
|
+
path.join(configDir(), 'trailhead', 'templates'),
|
|
176
|
+
];
|
|
177
|
+
const dir = candidates.find((d) => fs.existsSync(path.join(d, 'trailhead-label-guard-sync.js')));
|
|
178
|
+
if (!dir) return null;
|
|
179
|
+
const repoDir = run('git', ['-C', cwd, 'rev-parse', '--show-toplevel']);
|
|
180
|
+
if (!repoDir) return null;
|
|
181
|
+
const scriptPath = path.join(dir, 'trailhead-label-guard-sync.js');
|
|
182
|
+
const templateText = fs.readFileSync(path.join(dir, 'trailhead-label-guard.yml'), 'utf8');
|
|
183
|
+
return require(scriptPath).sessionNotice({ repoDir, templateText, scriptPath }) || null;
|
|
184
|
+
} catch {
|
|
185
|
+
return null;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
166
189
|
// Legge il JSON di SessionStart da stdin in modo asincrono, con timeout, come
|
|
167
190
|
// gli altri hook: una lettura sincrona di fd 0 si bloccherebbe per sempre su
|
|
168
191
|
// un TTY o su una pipe mai chiusa. Su TTY o input assente si prosegue con {}.
|
|
@@ -190,9 +213,10 @@ function main(input) {
|
|
|
190
213
|
const cwd = (input && typeof input.cwd === 'string' && input.cwd) || process.cwd();
|
|
191
214
|
|
|
192
215
|
const review = reviewNotice(cwd);
|
|
216
|
+
const guard = labelGuardNotice(cwd);
|
|
193
217
|
const update = updateNotice();
|
|
194
218
|
|
|
195
|
-
const parts = [review, update].filter(Boolean);
|
|
219
|
+
const parts = [review, guard, update].filter(Boolean);
|
|
196
220
|
if (!parts.length) return;
|
|
197
221
|
|
|
198
222
|
process.stdout.write(JSON.stringify({
|
|
@@ -18,3 +18,5 @@ Before doing anything, read the shared core, in order:
|
|
|
18
18
|
Load the **effective config** (per `../_shared/configuration.md`) at session start, from the map's project root (`.trailhead/config.json` plus the global file), before any isolation workspace is set up.
|
|
19
19
|
|
|
20
20
|
**Pinned model review, once per session.** After loading the effective config, run the read-only check: `node <lib> check <project root>`, where `<lib>` is the first of these two paths (relative to this `_shared/` dir) that exists: `../../hooks/lib/model-defaults-review.js` (Claude plugin, Claude npm copy, dev-symlink install), else `../trailhead/hooks/lib/model-defaults-review.js` (Codex). If neither exists, skip silently. If it prints a notice, offer it to the user without blocking the current request; only after the user answers any of its three choices, run `node <lib> ack <project root>`. Do this at most once per session: skip it if the SessionStart hook (or an earlier config load this session) already surfaced the same offer. Repeats across sessions are expected until the user answers.
|
|
21
|
+
|
|
22
|
+
**Label guard check, once per session.** Same pattern for the repo's label guard: run `node <templates>/trailhead-label-guard-sync.js --repo <project root> --notice`, with `<templates>` resolved as for the commit-msg sync script (see `teamwork.md`, Trust & provenance → Repo-side enforcement; skip silently if none of the template dirs has the script). It prints an offer only when the committed guard is the outdated one (no job-level `if:`, so every label event bills a runner) and the user has not answered for that file yet. Offer it without blocking the current request and follow its instructions; only after the user picks either choice, run the `--ack` command it names. At most once per session: skip it if the SessionStart hook already surfaced it.
|
|
@@ -200,5 +200,5 @@ gh api repos/{owner}/{repo}/issues/<map> --jq '.sub_issues_summary.total'
|
|
|
200
200
|
|
|
201
201
|
## First-use repo setup (do ALL THREE, every chart or adopt, never skip any)
|
|
202
202
|
1. Create any missing labels with `gh label create` (all eighteen: `trailhead:map`, `trailhead:codebase`, `trailhead:conventions`, `trailhead:dashboard`, `trailhead:ticket`, the six type labels, `trailhead:blocked`, `trailhead:seed`, `trailhead:out-of-scope`, `trailhead:superseded`, `trailhead:unverified`, `trailhead:fog`, `trailhead:whiteboard`).
|
|
203
|
-
2. **Check the label guard is installed**: `gh api repos/{owner}/{repo}/contents/.github/workflows/trailhead-label-guard.yml`; if it's absent (404), install it: **read `teamwork.md` (Trust & provenance → Repo-side enforcement) for the exact steps**. This is part of standing up trailhead in a repo, not an optional extra: *check every time*, so a repo can never end up with the labels but no guard.
|
|
203
|
+
2. **Check the label guard is installed and current**: `gh api repos/{owner}/{repo}/contents/.github/workflows/trailhead-label-guard.yml`; if it's absent (404), install it; if it's present, run the label guard sync script (`trailhead-label-guard-sync.js`, check-only) on the working root and, when it reports `outdated` (a pre-0.12.1 guard with no job-level `if:`, which starts a billed runner on every `issues: labeled` event), offer the upgrade: **read `teamwork.md` (Trust & provenance → Repo-side enforcement) for the exact steps**. This is part of standing up trailhead in a repo, not an optional extra: *check every time*, so a repo can never end up with the labels but no guard, or with a guard that burns Actions minutes.
|
|
204
204
|
3. **Sync the commit-msg git hook**: run the commit-msg hook sync script (`trailhead-commit-msg-sync.js`; installs it if absent, upgrades an outdated trailhead hook in place, never touches a foreign one) so trailhead's commit discipline (Conventional Commits subject, no `Co-Authored-By`, and a `Refs: #<n>` trailer while a `.trailhead/session-ticket` marker is active) runs on **every** `git commit` regardless of host (the Claude-Code `PreToolUse` guard stays too, defence in depth): **read `teamwork.md` (Trust & provenance → Repo-side enforcement) for the exact steps**. If the script is missing or node errors (missing means none of the three template dirs listed in `teamwork.md` has it: try them all first), fall back to inspecting the hooks dir (`git rev-parse --git-path hooks`) per `teamwork.md`, never a blind "not enforced". Like the label guard, *check every time*; unlike it, `.git/hooks/` is per-clone local state (untracked), so it is installed, never committed.
|
|
@@ -94,4 +94,6 @@ The map issue's **body** is a shared mutable document: two sessions appending to
|
|
|
94
94
|
|
|
95
95
|
**Repo-side enforcement (part of first-use setup, not an afterthought).** trailhead **installs the label guard for you**; don't tell the user to copy files. **Every chart or adopt, *check* whether `.github/workflows/trailhead-label-guard.yml` exists** (the first-use repo setup, in SKILL.md's Substrate section); if it's absent, install it, by default on a repo with other collaborators, and as a quick offer on a clearly solo/private one (cheap insurance), but **never silently skip the check**: that's exactly how a repo ends up with trailhead labels and no guard. On go-ahead, write the shipped label-guard template (`${CLAUDE_PLUGIN_ROOT}/templates/trailhead-label-guard.yml` for a plugin install, or `~/.claude/trailhead/templates/trailhead-label-guard.yml` for an npm install, take whichever exists) to the repo's `.github/workflows/trailhead-label-guard.yml` and commit it (conventional message; the commit-guard hook keeps it clean). The Action strips any `trailhead:*` label added by a non-write actor and comments why; set an optional `TRAILHEAD_LABEL_ALLOWLIST` repo variable to whitelist extra logins (e.g. a bot trailhead runs as). **Caveat:** pushing a workflow file needs a token with the `workflow` scope; if the push is rejected, run `gh auth refresh -s workflow` and retry (or, as a last resort, tell the user to add the file by hand).
|
|
96
96
|
|
|
97
|
+
**Keeping the guard current (migration).** The guard filters at **job level** (`jobs.guard.if`: a `trailhead:` label, a sender other than the repo owner, not in `TRAILHEAD_LABEL_ALLOWLIST`), because a skipped job is not billed while a job that starts and returns early from its script costs at least one Actions minute; the in-script permission check stays the real defence. Guards installed before 0.12.1 lack that `if:` and start a runner on **every** label event, which with trailhead labelling all the time runs into thousands of billed runs a month. So whenever the guard is present (every chart/adopt, and after `/trailhead:update`), run `node <templates>/trailhead-label-guard-sync.js --repo <working-root>` (same `<templates>` lookup as the commit-msg sync script below). It prints one JSON status line: `current` (nothing to do), `outdated` (offer the upgrade and always explain why in the question, in plain words: today every label added to an issue starts a run billed at least one minute even when it does nothing, trailhead labels constantly so that adds up to thousands of billed runs a month and can exhaust the Actions minutes, and the updated guard skips irrelevant events before a runner starts with the same protection), `custom` (has the job-level filter but differs from the template: leave it, mention it only if asked), `foreign` (not a trailhead workflow: never touched), `absent` (install as above). On go-ahead for `outdated`, re-run it with `--apply` (it rewrites the file from the template in the working tree only, never a foreign or customised guard), then commit it (`fix: skip trailhead label guard job for irrelevant label events`) and push, with the same `workflow`-scope caveat. The SessionStart hook (or, on a host with no hook bus, the load-first check) also runs it with `--notice` at every session start and surfaces the same upgrade offer until the user answers it; `--ack` records the answer in the gitignored `.trailhead/label-guard-ack` (the guard's hash), so a declined offer comes back only if the file changes. If the script is missing (an outdated install), fall back to reading the file and checking `jobs.guard` for an `if:` containing `startsWith(github.event.label.name, 'trailhead:')`.
|
|
98
|
+
|
|
97
99
|
**The commit-msg git hook (host-independent commit guard).** On every host trailhead ALSO installs a git **`commit-msg`** hook as a host-independent backstop (defence in depth alongside the host's own `PreToolUse` guard on Claude Code and Codex alike), enforcing the two rules (a Conventional Commits subject of at most 72 chars; never a `Co-Authored-By` trailer) plus, while a `.trailhead/session-ticket` marker is present at the worktree root, a `Refs: #<n>` trailer matching its number, on every `git commit`, whatever host drives it (decision #16). **Every chart or adopt, and at every `work`/`quick` ticket start, run the sync script**: `node <templates>/trailhead-commit-msg-sync.js --repo <working-root>`, where `<templates>` is the first of `${CLAUDE_PLUGIN_ROOT}/templates/`, `~/.claude/trailhead/templates/`, `~/.codex/skills/trailhead/templates/` that contains `trailhead-commit-msg-sync.js`: try all three in order, skipping the first when `CLAUDE_PLUGIN_ROOT` is unset or empty (it is often absent from the Bash environment), and never stop at the first miss. It prints one JSON status line and handles every case itself: absent -> installs it; an older trailhead hook (recognised by its own header line, not by trusting the filename) -> upgraded in place; already current -> left alone; a foreign (non-trailhead) hook -> never touched, only reported (offer to chain it); under a repo's `core.hooksPath`, an existing trailhead hook there is still upgraded in place, but nothing is newly added to that directory. Unlike the label guard, `.git/hooks/` is not version-controlled, so this is a per-clone local install with nothing to commit. **If the sync script is missing (none of the three dirs has it: an outdated install) or node errors, never claim git enforces nothing before looking**: resolve the effective hooks dir with `git -C <working-root> rev-parse --git-path hooks` (resolve a relative output against the working root; it honours `core.hooksPath` and the git common dir shared by linked worktrees), read `<hooks dir>/commit-msg`, and classify it by its header line (a trailhead hook has `// trailhead commit-msg hook (git).` among its first 5 lines, trimmed; never trust the filename alone; a symlinked `commit-msg` counts as foreign, as the script treats it). Report one result-oriented line: (a) trailhead hook present and executable (`test -x`): git does enforce trailhead's rules here, but its freshness could not be checked or upgraded, so suggest updating the trailhead install; a trailhead hook that is NOT executable: git skips it, so it does not enforce trailhead's rules here (suggest updating the install so the sync script can fix the mode); (b) a foreign hook (or a symlink): left untouched, git does not enforce trailhead's rules here; (c) no hook: git does not enforce trailhead's rules here (suggest updating the install so the sync script can install it). Never blocks the work.
|
|
@@ -51,4 +51,8 @@ After the cache refresh, run a **lightweight integrity check** (fs existence plu
|
|
|
51
51
|
|
|
52
52
|
The check is lightweight and degrades silently: a probe that can't run (an unreadable path, a host detail absent) is reported as unchecked, never a hard failure of the update.
|
|
53
53
|
|
|
54
|
+
## 6. Realign this repo's label guard
|
|
55
|
+
|
|
56
|
+
An update can change the shipped label guard, but a guard already committed to a repo is not touched by the install. If the current directory is a repo with `.github/workflows/trailhead-label-guard.yml`, run the label guard sync script check-only (see `../../_shared/teamwork.md`, Trust & provenance → Keeping the guard current) and, on `outdated`, offer the upgrade (`--apply`, then commit and push); `current`, `custom` and `foreign` need no action. Skip silently when there is no guard or no repo.
|
|
57
|
+
|
|
54
58
|
Never edit `package.json`/`plugin.json` versions here: this command **consumes** releases, it does not cut them.
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
// trailhead label guard sync (self-contained, ships in templates/ so it
|
|
4
|
+
// reaches every install channel). Classifies the repo's
|
|
5
|
+
// .github/workflows/trailhead-label-guard.yml against the sibling template
|
|
6
|
+
// and, with --apply, installs it when absent or upgrades an OUTDATED trailhead
|
|
7
|
+
// guard (one without the job-level `if:` filter, so every `issues: labeled`
|
|
8
|
+
// event started a billed runner). Never touches a foreign or customised guard.
|
|
9
|
+
//
|
|
10
|
+
// CLI: node trailhead-label-guard-sync.js [--repo <dir>] [--apply | --notice | --ack]
|
|
11
|
+
// Default and --apply print exactly one JSON line: {"status","file","message"}.
|
|
12
|
+
// --notice prints the session-start upgrade offer (plain text) when the guard
|
|
13
|
+
// is outdated and the user has not declined it for this exact file, else
|
|
14
|
+
// nothing. --ack records that the user answered the offer: it stores the
|
|
15
|
+
// guard's hash in the gitignored .trailhead/label-guard-ack, so the offer
|
|
16
|
+
// comes back only if the file changes.
|
|
17
|
+
// status (check): absent | current | outdated | custom | foreign | skipped
|
|
18
|
+
// status (--apply): installed | upgraded, or the check status when nothing
|
|
19
|
+
// was written
|
|
20
|
+
// Writing only changes the working tree: committing (and pushing, which needs
|
|
21
|
+
// the `workflow` token scope) stays with the caller.
|
|
22
|
+
// Exit 0 on every normal outcome (including unexpected errors: this must never
|
|
23
|
+
// block the caller); exit 2 only on a usage error.
|
|
24
|
+
|
|
25
|
+
const fs = require('fs');
|
|
26
|
+
const path = require('path');
|
|
27
|
+
const crypto = require('crypto');
|
|
28
|
+
|
|
29
|
+
const GUARD_REL = path.join('.github', 'workflows', 'trailhead-label-guard.yml');
|
|
30
|
+
// Prima riga di ogni versione del guard trailhead (tra le prime 5, trim).
|
|
31
|
+
const TRAILHEAD_MARK = '# trailhead label guard:';
|
|
32
|
+
const ACK_REL = path.join('.trailhead', 'label-guard-ack');
|
|
33
|
+
// Il filtro che deve stare nell'`if:` a livello di job.
|
|
34
|
+
const JOB_FILTER = "startsWith(github.event.label.name, 'trailhead:')";
|
|
35
|
+
|
|
36
|
+
function normalize(text) {
|
|
37
|
+
return String(text == null ? '' : text).replace(/\r\n/g, '\n');
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function isTrailheadGuard(text) {
|
|
41
|
+
return normalize(text).split('\n').slice(0, 5).some((line) => line.trim().startsWith(TRAILHEAD_MARK));
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Estrae il testo dell'`if:` diretto di jobs.guard (indentazione 4, prima del
|
|
45
|
+
// successivo `steps:`/chiave), incluse le righe di continuazione di `>-`.
|
|
46
|
+
// null se jobs.guard non ha un `if:` a livello di job.
|
|
47
|
+
function jobGuardIf(text) {
|
|
48
|
+
const lines = normalize(text).split('\n');
|
|
49
|
+
const start = lines.findIndex((l) => /^ {2}guard:\s*$/.test(l));
|
|
50
|
+
if (start < 0) return null;
|
|
51
|
+
for (let i = start + 1; i < lines.length; i++) {
|
|
52
|
+
const line = lines[i];
|
|
53
|
+
if (/^\S/.test(line) || /^ {2}\S/.test(line)) return null; // fine del job
|
|
54
|
+
const m = /^ {4}if:\s*(.*)$/.exec(line);
|
|
55
|
+
if (!m) continue;
|
|
56
|
+
const parts = [m[1]];
|
|
57
|
+
for (let j = i + 1; j < lines.length && /^ {6,}\S/.test(lines[j]); j++) parts.push(lines[j].trim());
|
|
58
|
+
return parts.join(' ');
|
|
59
|
+
}
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function hasJobLevelFilter(text) {
|
|
64
|
+
const cond = jobGuardIf(text);
|
|
65
|
+
return cond !== null && cond.includes(JOB_FILTER);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function classify({ existingText, templateText }) {
|
|
69
|
+
if (existingText == null) return 'absent';
|
|
70
|
+
if (!isTrailheadGuard(existingText)) return 'foreign';
|
|
71
|
+
if (normalize(existingText) === normalize(templateText)) return 'current';
|
|
72
|
+
return hasJobLevelFilter(existingText) ? 'custom' : 'outdated';
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const MESSAGES = {
|
|
76
|
+
absent: 'no trailhead label guard in this repo.',
|
|
77
|
+
current: 'the trailhead label guard is already current.',
|
|
78
|
+
outdated: 'the trailhead label guard is outdated: no job-level filter, so every label event starts a billed runner.',
|
|
79
|
+
custom: 'the trailhead label guard has the job-level filter but differs from the template; left untouched.',
|
|
80
|
+
foreign: 'a non-trailhead workflow sits at the label guard path; left untouched.',
|
|
81
|
+
installed: 'installed the trailhead label guard (commit it).',
|
|
82
|
+
upgraded: 'upgraded the trailhead label guard to the current template (commit it).',
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
function writeAtomic(filePath, text, fsMod) {
|
|
86
|
+
const dir = path.dirname(filePath);
|
|
87
|
+
fsMod.mkdirSync(dir, { recursive: true });
|
|
88
|
+
const tmpPath = path.join(dir, `.trailhead-label-guard-${process.pid}-${crypto.randomBytes(4).toString('hex')}`);
|
|
89
|
+
try {
|
|
90
|
+
fsMod.writeFileSync(tmpPath, text);
|
|
91
|
+
fsMod.renameSync(tmpPath, filePath);
|
|
92
|
+
} catch (err) {
|
|
93
|
+
try { fsMod.unlinkSync(tmpPath); } catch { /* best effort cleanup */ }
|
|
94
|
+
throw err;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function syncLabelGuard({ repoDir, templateText, apply = false, fs: fsMod = fs }) {
|
|
99
|
+
const file = path.join(repoDir, GUARD_REL);
|
|
100
|
+
const existingText = fsMod.existsSync(file) ? fsMod.readFileSync(file, 'utf8') : null;
|
|
101
|
+
const status = classify({ existingText, templateText });
|
|
102
|
+
if (apply && (status === 'absent' || status === 'outdated')) {
|
|
103
|
+
writeAtomic(file, templateText, fsMod);
|
|
104
|
+
const done = status === 'absent' ? 'installed' : 'upgraded';
|
|
105
|
+
return { status: done, file, message: MESSAGES[done] };
|
|
106
|
+
}
|
|
107
|
+
return { status, file, message: MESSAGES[status] };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function sha256(text) {
|
|
111
|
+
return crypto.createHash('sha256').update(normalize(text)).digest('hex');
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function readGuard(repoDir, fsMod) {
|
|
115
|
+
const file = path.join(repoDir, GUARD_REL);
|
|
116
|
+
return fsMod.existsSync(file) ? fsMod.readFileSync(file, 'utf8') : null;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Registra la risposta all'offerta: hash del guard attuale, cosi' l'offerta
|
|
120
|
+
// torna solo se il file cambia. false se non c'e' un guard da ackare.
|
|
121
|
+
function ackGuard({ repoDir, fs: fsMod = fs }) {
|
|
122
|
+
const existingText = readGuard(repoDir, fsMod);
|
|
123
|
+
if (existingText == null) return false;
|
|
124
|
+
const file = path.join(repoDir, ACK_REL);
|
|
125
|
+
fsMod.mkdirSync(path.dirname(file), { recursive: true });
|
|
126
|
+
fsMod.writeFileSync(file, sha256(existingText) + '\n');
|
|
127
|
+
return true;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function isAcked(repoDir, existingText, fsMod) {
|
|
131
|
+
try {
|
|
132
|
+
return fsMod.readFileSync(path.join(repoDir, ACK_REL), 'utf8').trim() === sha256(existingText);
|
|
133
|
+
} catch {
|
|
134
|
+
return false;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Offerta di sessione (SessionStart o load-first inline): solo per un guard
|
|
139
|
+
// trailhead `outdated` non ancora ackato. Sola lettura, mai eccezioni.
|
|
140
|
+
function sessionNotice({ repoDir, templateText, scriptPath, fs: fsMod = fs }) {
|
|
141
|
+
try {
|
|
142
|
+
const existingText = readGuard(repoDir, fsMod);
|
|
143
|
+
if (classify({ existingText, templateText }) !== 'outdated') return null;
|
|
144
|
+
if (isAcked(repoDir, existingText, fsMod)) return null;
|
|
145
|
+
const cmd = `node "${scriptPath}" --repo "${repoDir}"`;
|
|
146
|
+
return [
|
|
147
|
+
`trailhead's label guard in this repo (${GUARD_REL}) is outdated: it has no job-level \`if:\`, so every \`issues: labeled\` event starts a GitHub Actions runner billed at least one minute, even for labels it ignores.`,
|
|
148
|
+
'Offer the user, once, without blocking their current request, two choices, and put the reason in the question itself, in plain words, not just the choices. Say that the workflow currently starts a GitHub Actions run every time any label is added to an issue, that GitHub bills each run at least one minute even when the workflow then does nothing, that trailhead adds labels all the time so this can reach thousands of billed runs a month and use up the Actions minutes, and that the updated version skips irrelevant label events before a runner starts (skipped runs are free) while keeping the same protection.',
|
|
149
|
+
`1) upgrade it: run \`${cmd} --apply\`, then commit the file (\`fix: skip trailhead label guard job for irrelevant label events\`) and push (a workflow file needs the \`workflow\` token scope: run \`gh auth refresh -s workflow\` if the push is rejected);`,
|
|
150
|
+
'2) keep it as it is.',
|
|
151
|
+
`This offer reappears every session until answered: once the user has picked either choice, run: ${cmd} --ack`,
|
|
152
|
+
].join('\n');
|
|
153
|
+
} catch {
|
|
154
|
+
return null;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function parseArgs(argv) {
|
|
159
|
+
let repo = null;
|
|
160
|
+
let apply = false;
|
|
161
|
+
let mode = 'sync';
|
|
162
|
+
for (let i = 0; i < argv.length; i++) {
|
|
163
|
+
const arg = argv[i];
|
|
164
|
+
if (arg === '--repo') {
|
|
165
|
+
i++;
|
|
166
|
+
if (i >= argv.length) return { error: 'usage: --repo requires a value' };
|
|
167
|
+
repo = argv[i];
|
|
168
|
+
} else if (arg === '--apply') {
|
|
169
|
+
apply = true;
|
|
170
|
+
} else if (arg === '--notice' || arg === '--ack') {
|
|
171
|
+
if (mode !== 'sync') return { error: 'usage: --notice and --ack are exclusive' };
|
|
172
|
+
mode = arg.slice(2);
|
|
173
|
+
} else {
|
|
174
|
+
return { error: `usage: unknown argument: ${arg}` };
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
if (apply && mode !== 'sync') return { error: `usage: --apply cannot be combined with --${mode}` };
|
|
178
|
+
return { repo, apply, mode };
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function main(argv, { cwd, stdout } = {}) {
|
|
182
|
+
const out = stdout || process.stdout;
|
|
183
|
+
const parsed = parseArgs(argv || []);
|
|
184
|
+
if (parsed.error) {
|
|
185
|
+
process.stderr.write(parsed.error + '\n');
|
|
186
|
+
return 2;
|
|
187
|
+
}
|
|
188
|
+
const repoDir = path.resolve(cwd || process.cwd(), parsed.repo || '.');
|
|
189
|
+
try {
|
|
190
|
+
const templateText = fs.readFileSync(path.join(__dirname, 'trailhead-label-guard.yml'), 'utf8');
|
|
191
|
+
if (parsed.mode === 'notice') {
|
|
192
|
+
const notice = sessionNotice({ repoDir, templateText, scriptPath: __filename });
|
|
193
|
+
if (notice) out.write(notice + '\n');
|
|
194
|
+
return 0;
|
|
195
|
+
}
|
|
196
|
+
if (parsed.mode === 'ack') {
|
|
197
|
+
ackGuard({ repoDir });
|
|
198
|
+
return 0;
|
|
199
|
+
}
|
|
200
|
+
out.write(JSON.stringify(syncLabelGuard({ repoDir, templateText, apply: parsed.apply })) + '\n');
|
|
201
|
+
} catch (err) {
|
|
202
|
+
if (parsed.mode !== 'sync') return 0; // notice/ack: mai rumore
|
|
203
|
+
out.write(JSON.stringify({ status: 'skipped', file: path.join(repoDir, GUARD_REL), message: 'error: ' + (err && err.message ? err.message : String(err)) }) + '\n');
|
|
204
|
+
}
|
|
205
|
+
return 0;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
module.exports = { normalize, isTrailheadGuard, jobGuardIf, hasJobLevelFilter, classify, syncLabelGuard, sessionNotice, ackGuard, parseArgs, main, GUARD_REL, ACK_REL, JOB_FILTER };
|
|
209
|
+
|
|
210
|
+
if (require.main === module) {
|
|
211
|
+
process.exit(main(process.argv.slice(2), { cwd: process.cwd(), stdout: process.stdout }));
|
|
212
|
+
}
|
|
@@ -18,6 +18,11 @@ permissions:
|
|
|
18
18
|
|
|
19
19
|
jobs:
|
|
20
20
|
guard:
|
|
21
|
+
# Filtro a livello di job: un job saltato non consuma minuti Actions.
|
|
22
|
+
if: >-
|
|
23
|
+
startsWith(github.event.label.name, 'trailhead:') &&
|
|
24
|
+
github.event.sender.login != github.repository_owner &&
|
|
25
|
+
!contains(format(',{0},', vars.TRAILHEAD_LABEL_ALLOWLIST), format(',{0},', github.event.sender.login))
|
|
21
26
|
runs-on: ubuntu-latest
|
|
22
27
|
steps:
|
|
23
28
|
- uses: actions/github-script@v7
|