k-skill/scripts/workflow-actions.test.js
Jeffrey (Dongkyu) Kim 876077c7c9
Feature/#26269601403 (#280)
* ci: bump GHA actions to Node.js 24 runtime majors

GitHub Actions runner는 2026-06-02부터 Node.js 20 기반 action들을 강제로
Node.js 24로 돌리고, 2026-09-16에는 Node.js 20 자체를 runner에서 제거한다.
2026-05-22 Deploy k-skill-proxy run #26269601403에서 deprecation annotation
관측: 'actions/checkout@v4, google-github-actions/setup-gcloud@v2 ...running
on Node.js 20'.

이번 핫픽스는 우리 모든 워크플로의 action pin을 명시적으로 Node 24 major로
올려둔다. node24 runtime 확인은 action repo의 action.yml runs.using 값을
직접 조회해 검증했다.

변경 (10 replacements across 5 workflows):

- actions/checkout: v4 -> v5  (v5/v6 모두 node24, 안정 stable인 v5 채택)
- actions/setup-node: v4 -> v5  (v4은 node20, v5+가 node24)
- google-github-actions/setup-gcloud: v2 -> v3  (v2은 node20, v3이 node24)

이미 node24인 채로 pin돼 있어 손대지 않은 항목 (sanity):

- google-github-actions/auth@v3       (v3 = node24)
- google-github-actions/deploy-cloudrun@v3 (v3 = node24)
- changesets/action@v1                (v1.8.0 = node24, major pin이 자동 follow)
- googleapis/release-please-action@v4 (v4.4.1 = node24, major pin이 자동 follow)

검증:

- yaml grammar는 ast-grep yaml replace로 보존 (10 surgical replacements only).
- runtime은 'gh api .../action.yml | grep using:' 으로 모든 새 ref가 node24임을
  실제 확인. 추측 없음.
- 머지 후 첫 deploy run에서 deprecation annotation이 사라지는지 최종 검증.

* Keep workflow actions ahead of Node 20 removal

Release-please was still pinned to a Node 20 runtime major in the Python release scaffold, so the workflow set was not fully clean for the runner cutoff. Add a workflow regression test to keep the reviewed action majors on Node 24 refs.

Constraint: GitHub-hosted runners begin forcing Node 20 actions to Node 24 on 2026-06-02 and remove Node 20 on 2026-09-16.

Rejected: Leaving release-please-action@v4 as scaffold-only | it would become a latent release workflow break once Python packages are added.

Confidence: high

Scope-risk: narrow

Directive: Keep workflow action runtime-major claims backed by action.yml metadata checks and regression tests.

Tested: node --test scripts/workflow-actions.test.js; Ruby YAML.load_file for workflows; direct GitHub API action.yml runs.using checks; npm run ci attempted through lint/typecheck before local pip pyexpat blocker; equivalent Node/Python/workspace tests, validate-skills.sh, and npm run pack:dry-run passed.

Not-tested: npm run ci end-to-end due local Homebrew Python 3.14 pyexpat dynamic-link failure during pip install.

* Protect workflow action guardrails from commented uses refs

The Node 24 migration guard should catch reviewed stale action majors even when a valid workflow line carries an inline comment or YAML quotes. Reuse one extractor for fixtures and real workflow scans so the regression covers production behavior.\n\nConstraint: PR #279 review round 3 found inline-commented uses lines could be skipped by the text extractor.\nRejected: Full YAML parser adoption | unnecessary for the bounded guardrail and would add complexity to a no-dependency test.\nConfidence: high\nScope-risk: narrow\nDirective: Keep workflow action runtime guardrails deterministic and dependency-free unless broad runtime metadata validation is explicitly required.\nTested: node --test scripts/workflow-actions.test.js; npm run lint; npm run typecheck; direct root/workspace/Python test segments; validate-skills; pack:dry-run; git diff --check; package.json JSON parse.\nNot-tested: npm run test end-to-end past the initial pip install gate because local Homebrew Python 3.14 pyexpat linkage fails before repo tests run.

* Clarify curated workflow action runtime guard

Document the reviewed scope behind the Node runtime action guard so future maintainers do not mistake the hotfix inventory for exhaustive workflow enforcement.

Constraint: Follow-up to PR #280 review watchlist; keep behavior scoped to the reviewed Node 20 to Node 24 action migration set.

Rejected: Broadening the guard to every external action | outside this hotfix scope and explicitly deferred by review.

Confidence: high

Scope-risk: narrow

Directive: Expand the source URL map and tests if the guard becomes comprehensive runtime enforcement.

Tested: node --test scripts/workflow-actions.test.js; npm run lint; npm run typecheck; git diff --check; python3 -m json.tool package.json; downstream direct test fragments; workspace tests; ./scripts/validate-skills.sh; npm run pack:dry-run

Not-tested: npm run test remains blocked before repo tests by local Homebrew Python 3.14 pyexpat/pip import linker error
2026-05-23 10:25:24 +09:00

127 lines
4.3 KiB
JavaScript

"use strict";
const test = require("node:test");
const assert = require("node:assert/strict");
const fs = require("node:fs");
const path = require("node:path");
const repoRoot = path.resolve(__dirname, "..");
const workflowDir = path.join(repoRoot, ".github", "workflows");
// Reviewed action runtime coverage is intentionally curated, not exhaustive:
// these pins are the Node 20 deprecation migration set verified from each
// listed action ref's GitHub `action.yml` metadata on the review date below.
const actionRuntimeGuardScope = {
coverage: "curated migration set; not exhaustive for every external workflow action",
reviewedAt: "2026-05-22",
sourceUrls: new Map([
["actions/checkout", "https://github.com/actions/checkout/blob/v5/action.yml"],
["actions/setup-node", "https://github.com/actions/setup-node/blob/v5/action.yml"],
[
"google-github-actions/setup-gcloud",
"https://github.com/google-github-actions/setup-gcloud/blob/v3/action.yml",
],
[
"googleapis/release-please-action",
"https://github.com/googleapis/release-please-action/blob/v5/action.yml",
],
]),
};
const expectedNode24ActionPins = new Map([
["actions/checkout", "v5"],
["actions/setup-node", "v5"],
["google-github-actions/setup-gcloud", "v3"],
["googleapis/release-please-action", "v5"],
]);
const knownNode20ActionPins = new Map([
["actions/checkout", new Set(["v4"])],
["actions/setup-node", new Set(["v4"])],
["google-github-actions/setup-gcloud", new Set(["v2"])],
["googleapis/release-please-action", new Set(["v4"])],
]);
const usesLinePattern = /^\s*(?:-\s*)?uses:\s*['"]?([^'"\s#]+)['"]?(?:\s*#.*)?\s*$/gm;
function readWorkflow(name) {
return fs.readFileSync(path.join(workflowDir, name), "utf8");
}
function listWorkflowFiles() {
return fs
.readdirSync(workflowDir)
.filter((name) => name.endsWith(".yml") || name.endsWith(".yaml"))
.sort();
}
function usesFromWorkflowBody(name, body) {
return [...body.matchAll(usesLinePattern)].map((match) => {
const spec = match[1];
const at = spec.lastIndexOf("@");
assert.notEqual(at, -1, `${name} action use must be pinned with @ref: ${spec}`);
return { file: name, action: spec.slice(0, at), ref: spec.slice(at + 1), spec };
});
}
function workflowUses() {
return listWorkflowFiles().flatMap((name) => {
const body = readWorkflow(name);
return usesFromWorkflowBody(name, body);
});
}
test("workflow action extractor includes uses lines with inline comments", () => {
const uses = usesFromWorkflowBody(
"inline-comment.yml",
[
"jobs:",
" validate:",
" steps:",
" - uses: actions/checkout@v4 # intentionally stale fixture",
" - uses: 'actions/setup-node@v5' # quoted fixture",
' - uses: "google-github-actions/setup-gcloud@v3"',
].join("\n"),
);
assert.deepEqual(
uses.map((use) => use.spec),
["actions/checkout@v4", "actions/setup-node@v5", "google-github-actions/setup-gcloud@v3"],
);
});
test("workflow action runtime guard documents its reviewed coverage scope", () => {
assert.match(actionRuntimeGuardScope.coverage, /curated/i);
assert.match(actionRuntimeGuardScope.coverage, /not exhaustive/i);
assert.match(actionRuntimeGuardScope.reviewedAt, /^\d{4}-\d{2}-\d{2}$/);
for (const action of expectedNode24ActionPins.keys()) {
assert.match(actionRuntimeGuardScope.sourceUrls.get(action), /^https:\/\/github\.com\/.+\/action\.yml$/);
}
});
test("workflow action pins avoid reviewed Node 20 action majors", () => {
for (const use of workflowUses()) {
const bannedRefs = knownNode20ActionPins.get(use.action);
if (!bannedRefs) continue;
assert.ok(
!bannedRefs.has(use.ref),
`${use.file} must not use ${use.spec}; this action major is known to run on Node 20`,
);
}
});
test("workflow action pins use the selected Node 24 runtime majors", () => {
const uses = workflowUses();
for (const [action, expectedRef] of expectedNode24ActionPins) {
const refs = uses.filter((use) => use.action === action).map((use) => `${use.file}:${use.ref}`);
assert.ok(refs.length > 0, `expected at least one workflow use of ${action}`);
assert.deepEqual(
[...new Set(refs.map((entry) => entry.split(":").at(-1)))],
[expectedRef],
`${action} should be pinned to ${expectedRef} everywhere it appears (${refs.join(", ")})`,
);
}
});