Skip to content

Commit c61dd6a

Browse files
radicalCopilot
andcommitted
ci: add weekly actionlint updater
The pinned actionlint version in .github/actionlint-version.json would otherwise go stale silently. Add update-actionlint.yml, which runs weekly and on demand: - Resolves the latest actionlint release and verifies the Linux archive against the sha256 digest published by the release API. - Lints the handwritten workflows with the candidate binary through the same script the CI gate uses, so a bump that would break the gate is never proposed. - validate mode (manual default) stops after linting and is safe on forks. propose mode, used by the schedule and restricted to the default branch, opens or refreshes a draft PR. - A concurrency group keeps overlapping runs from racing on the update branch. Scheduled failures are reported through report-pipeline-failure and watched by monitor-scheduled-workflows. The reporter's issue title and body now say "Scheduled" instead of "Nightly" because this workflow is weekly. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
1 parent 1d30197 commit c61dd6a

15 files changed

Lines changed: 867 additions & 22 deletions

‎.github/workflows/README.md‎

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,8 +39,22 @@ workflow sources still trigger it even before their generated locks exist.
3939

4040
Compile-time lint diagnostics alone are not a blocking gate. The lint command
4141
owns the actionlint image and compatibility exceptions; `.github/actionlint.yaml`
42-
only scopes the known stale-check output workaround to the affected generated
43-
workflows.
42+
is shared by both lint paths: it configures the runner label for handwritten
43+
workflow linting and scopes the known stale-check output workaround to the
44+
affected generated workflows. Main CI separately runs pinned core actionlint
45+
over handwritten workflows and their local actions through
46+
`lint-handwritten-workflows.sh`, which selects the files and excludes gh-aw output.
47+
The version and linux_amd64 archive SHA-256 are pinned in `.github/actionlint-version.json`,
48+
outside `.github/workflows`, so the Aspire bot can update them without workflow-write access.
49+
The weekly `update-actionlint.yml` workflow opens a draft PR when a newer release exists. It
50+
verifies the downloaded archive against the release API `sha256:` digest and lints the
51+
handwritten workflows with the candidate before proposing the bump. That digest comes from the
52+
same upstream release, so review the release notes; the committed hash then makes CI detect any
53+
later substitution of the archive. Manual dispatch defaults to `validate`, which does not change
54+
the updater branch or open a PR and is safe to run from a fork; `propose` runs only from the
55+
upstream repository's default branch and follows the scheduled PR-update path when a newer release
56+
is available. Scheduled and `propose` runs are serialized end to end by a shared concurrency
57+
group, so each run picks its release only after the previous run has pushed.
4458

4559
Explicit action versions in Markdown survive recompilation, so update deprecated
4660
inputs and action runtimes in the sources, not just the generated YAML. The

‎.github/workflows/monitor-scheduled-workflows.config.json‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
{ "file": "tests-outerloop.yml", "name": "Outerloop Tests", "selfReports": true },
1919
{ "file": "tests-quarantine.yml", "name": "Quarantined Tests", "selfReports": true },
2020
{ "file": "tests-daily-smoke.yml", "name": "Daily CLI Smoke Tests", "selfReports": true, "labels": ["area-cli"] },
21-
{ "file": "deployment-tests.yml", "name": "Deployment E2E Tests", "selfReports": true, "labels": ["area-testing", "deployment-e2e"] }
21+
{ "file": "deployment-tests.yml", "name": "Deployment E2E Tests", "selfReports": true, "labels": ["area-testing", "deployment-e2e"] },
22+
{ "file": "update-actionlint.yml", "name": "Update actionlint", "selfReports": true, "labels": ["area-engineering-systems"] }
2223
]
2324
}

‎.github/workflows/report-pipeline-failure.js‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
// Nightly-pipeline failure reporter, used by deployment-tests.yml and
2-
// tests-daily-smoke.yml. Invoked from an actions/github-script step via report().
1+
// Scheduled-pipeline failure reporter, used by deployment-tests.yml,
2+
// tests-daily-smoke.yml, and the weekly update-actionlint.yml. Invoked from an actions/github-script step via report().
33
//
44
// These scheduled pipelines otherwise fail silently — GitHub only emails whoever
55
// last edited the workflow file. This reporter files a single deduplicated issue
@@ -42,13 +42,13 @@ function buildMarker(workflowFile) {
4242
}
4343

4444
function buildIssueTitle(displayName) {
45-
return `Nightly run failing: ${displayName}`;
45+
return `Scheduled run failing: ${displayName}`;
4646
}
4747

4848
// Builds the static issue body. Each failed run is recorded as a comment (see
4949
// report()), so the body is a fixed description written once at filing.
5050
function buildIssueBody({ marker, displayName, workflowFile, cc = '' }) {
51-
const lead = `The scheduled workflow [\`${workflowFile}\`](../../actions/workflows/${workflowFile}) (**${displayName}**) is failing on its nightly run.`;
51+
const lead = `The scheduled workflow [\`${workflowFile}\`](../../actions/workflows/${workflowFile}) (**${displayName}**) is failing on its scheduled run.`;
5252

5353
return tracking.buildBody({
5454
marker,
Lines changed: 230 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,230 @@
1+
'use strict';
2+
3+
// Keeps the actionlint pin in .github/actionlint-version.json current.
4+
//
5+
// node update-actionlint.js check
6+
// Compares the latest published rhysd/actionlint release with the pin. When
7+
// newer, downloads the linux_amd64 archive into RUNNER_TEMP, verifies it
8+
// against the release API asset digest, and emits step outputs. Never edits
9+
// the repository.
10+
//
11+
// node update-actionlint.js apply <version> <sha256>
12+
// Rewrites the pin file with the verified version and SHA-256.
13+
//
14+
// The release API digest comes from the same upstream as the archive, so it is
15+
// not an independent trust anchor. Its purpose is to make the reviewed,
16+
// committed hash match the bytes the release served, so CI detects any later
17+
// substitution of the archive.
18+
19+
const crypto = require('node:crypto');
20+
const fs = require('node:fs/promises');
21+
const path = require('node:path');
22+
23+
const REPOSITORY = 'rhysd/actionlint';
24+
const LATEST_RELEASE_URL = `https://api.github.com/repos/${REPOSITORY}/releases/latest`;
25+
const SEMVER = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/;
26+
const SHA256 = /^[0-9a-f]{64}$/;
27+
28+
function parseVersion(value, description) {
29+
const match = typeof value === 'string' ? SEMVER.exec(value) : null;
30+
if (!match) {
31+
throw new Error(`${description} '${value}' is not a MAJOR.MINOR.PATCH version.`);
32+
}
33+
34+
return match.slice(1).map(Number);
35+
}
36+
37+
function compareVersions(left, right) {
38+
const a = parseVersion(left, 'Version');
39+
const b = parseVersion(right, 'Version');
40+
for (let i = 0; i < 3; i++) {
41+
if (a[i] !== b[i]) {
42+
return a[i] < b[i] ? -1 : 1;
43+
}
44+
}
45+
46+
return 0;
47+
}
48+
49+
function parseReleaseTag(tag) {
50+
if (typeof tag !== 'string' || !tag.startsWith('v') || !SEMVER.test(tag.slice(1))) {
51+
throw new Error(`Release tag '${tag}' is not of the form vMAJOR.MINOR.PATCH.`);
52+
}
53+
54+
return tag.slice(1);
55+
}
56+
57+
// Pin file shape (.github/actionlint-version.json):
58+
// { "version": "1.7.12", "linuxAmd64Sha256": "8aca8db9..." }
59+
function readPin(content) {
60+
let pin;
61+
try {
62+
pin = JSON.parse(content);
63+
} catch (error) {
64+
throw new Error(`Pin file is not valid JSON: ${error.message}`);
65+
}
66+
67+
const keys = Object.keys(pin ?? {}).sort().join(',');
68+
if (keys !== 'linuxAmd64Sha256,version') {
69+
throw new Error(`Pin file must contain exactly 'version' and 'linuxAmd64Sha256' but has '${keys}'.`);
70+
}
71+
72+
parseVersion(pin.version, 'Pinned version');
73+
if (!SHA256.test(pin.linuxAmd64Sha256)) {
74+
throw new Error(`Pinned linuxAmd64Sha256 '${pin.linuxAmd64Sha256}' is not a lowercase SHA-256 hex digest.`);
75+
}
76+
77+
return { version: pin.version, sha256: pin.linuxAmd64Sha256 };
78+
}
79+
80+
function applyPin(content, version, sha256) {
81+
const current = readPin(content);
82+
if (compareVersions(version, current.version) <= 0) {
83+
throw new Error(`Refusing to change actionlint from ${current.version} to ${version}; only upgrades are allowed.`);
84+
}
85+
86+
if (!SHA256.test(sha256)) {
87+
throw new Error(`SHA-256 '${sha256}' is not a lowercase SHA-256 hex digest.`);
88+
}
89+
90+
return `${JSON.stringify({ version, linuxAmd64Sha256: sha256 }, null, 2)}\n`;
91+
}
92+
93+
// Release API asset shape (https://docs.github.com/rest/releases/releases#get-the-latest-release):
94+
// { "name": "actionlint_1.7.12_linux_amd64.tar.gz",
95+
// "browser_download_url": "https://github.com/rhysd/actionlint/releases/download/v1.7.12/actionlint_1.7.12_linux_amd64.tar.gz",
96+
// "digest": "sha256:8aca8db9..." }
97+
// Older releases can have a null digest; those are rejected rather than trusted.
98+
function selectLinuxAmd64Asset(release, version) {
99+
if (release.draft || release.prerelease) {
100+
throw new Error(`Release ${release.tag_name} is a draft or prerelease.`);
101+
}
102+
103+
const name = `actionlint_${version}_linux_amd64.tar.gz`;
104+
const assets = (release.assets ?? []).filter(asset => asset.name === name);
105+
if (assets.length !== 1) {
106+
throw new Error(`Expected exactly one '${name}' asset in release ${release.tag_name} but found ${assets.length}.`);
107+
}
108+
109+
const [asset] = assets;
110+
const url = `https://github.com/${REPOSITORY}/releases/download/v${version}/${name}`;
111+
if (asset.browser_download_url !== url) {
112+
throw new Error(`Asset '${name}' has unexpected download URL '${asset.browser_download_url}'.`);
113+
}
114+
115+
const digest = /^sha256:([0-9a-f]{64})$/.exec(asset.digest ?? '');
116+
if (!digest) {
117+
throw new Error(`Asset '${name}' has no sha256 digest (got '${asset.digest}').`);
118+
}
119+
120+
return { name, url, sha256: digest[1] };
121+
}
122+
123+
function verifySha256(bytes, expected, name) {
124+
const actual = crypto.createHash('sha256').update(bytes).digest('hex');
125+
if (actual !== expected) {
126+
throw new Error(`Downloaded '${name}' has SHA-256 ${actual} but the release API digest is ${expected}.`);
127+
}
128+
}
129+
130+
async function check({ pinContent, outputDir, fetchJson, fetchBytes, writeFile, log }) {
131+
const current = readPin(pinContent);
132+
const release = await fetchJson(LATEST_RELEASE_URL);
133+
const latest = parseReleaseTag(release.tag_name);
134+
const comparison = compareVersions(latest, current.version);
135+
136+
if (comparison < 0) {
137+
throw new Error(`Latest actionlint release ${latest} is older than the pinned ${current.version}; refusing to downgrade.`);
138+
}
139+
140+
if (comparison === 0) {
141+
log(`actionlint ${current.version} is the latest release; nothing to update.`);
142+
return { updated: false, previousVersion: current.version };
143+
}
144+
145+
const asset = selectLinuxAmd64Asset(release, latest);
146+
const bytes = await fetchBytes(asset.url);
147+
verifySha256(bytes, asset.sha256, asset.name);
148+
149+
const archivePath = path.join(outputDir, asset.name);
150+
await writeFile(archivePath, bytes);
151+
log(`actionlint ${latest} is available (pinned ${current.version}); verified ${asset.name} SHA-256 ${asset.sha256}.`);
152+
153+
return { updated: true, previousVersion: current.version, version: latest, sha256: asset.sha256, archivePath };
154+
}
155+
156+
// Formats check() results as GITHUB_OUTPUT lines, e.g.:
157+
// updated=true
158+
// previous-version=1.7.12
159+
// version=1.7.13
160+
// sha256=<64 hex chars>
161+
// archive-path=/home/runner/work/_temp/actionlint_1.7.13_linux_amd64.tar.gz
162+
// update-actionlint.yml consumes these names, so keep them in sync.
163+
function formatStepOutputs(result) {
164+
const lines = [`updated=${result.updated}`, `previous-version=${result.previousVersion}`];
165+
if (result.updated) {
166+
lines.push(`version=${result.version}`, `sha256=${result.sha256}`, `archive-path=${result.archivePath}`);
167+
}
168+
169+
return `${lines.join('\n')}\n`;
170+
}
171+
172+
async function main(argv, env) {
173+
const pinPath = path.resolve('.github/actionlint-version.json');
174+
const pinContent = await fs.readFile(pinPath, 'utf8');
175+
const [command, ...args] = argv;
176+
177+
if (command === 'apply') {
178+
const [version, sha256] = args;
179+
await fs.writeFile(pinPath, applyPin(pinContent, version, sha256));
180+
console.log(`Updated ${pinPath} to actionlint ${version}.`);
181+
return;
182+
}
183+
184+
if (command !== 'check') {
185+
throw new Error(`Unknown command '${command}'. Expected 'check' or 'apply <version> <sha256>'.`);
186+
}
187+
188+
const apiHeaders = {
189+
Accept: 'application/vnd.github+json',
190+
'X-GitHub-Api-Version': '2022-11-28',
191+
...(env.GITHUB_TOKEN ? { Authorization: `Bearer ${env.GITHUB_TOKEN}` } : {}),
192+
};
193+
194+
const result = await check({
195+
pinContent,
196+
outputDir: env.RUNNER_TEMP ?? process.cwd(),
197+
fetchJson: async url => {
198+
const response = await fetch(url, { headers: apiHeaders });
199+
if (!response.ok) {
200+
throw new Error(`GET ${url} failed with HTTP ${response.status}.`);
201+
}
202+
203+
return response.json();
204+
},
205+
// The download redirects to a release-asset CDN, so it is fetched without the API token.
206+
fetchBytes: async url => {
207+
const response = await fetch(url);
208+
if (!response.ok) {
209+
throw new Error(`GET ${url} failed with HTTP ${response.status}.`);
210+
}
211+
212+
return Buffer.from(await response.arrayBuffer());
213+
},
214+
writeFile: fs.writeFile,
215+
log: message => console.log(message),
216+
});
217+
218+
if (env.GITHUB_OUTPUT) {
219+
await fs.appendFile(env.GITHUB_OUTPUT, formatStepOutputs(result));
220+
}
221+
}
222+
223+
module.exports = { applyPin, check, compareVersions, formatStepOutputs, parseReleaseTag, readPin, selectLinuxAmd64Asset, verifySha256 };
224+
225+
if (require.main === module) {
226+
main(process.argv.slice(2), process.env).catch(error => {
227+
console.error(`::error::${error.message}`);
228+
process.exitCode = 1;
229+
});
230+
}

0 commit comments

Comments
 (0)