Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .github/aw/releases.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot rename this file to "releases.json" and rename json schema accordingly. Update all references.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in 04475fd — renamed config.jsonreleases.json and config.schema.jsonreleases.schema.json. Updated the $schema reference inside releases.json, the raw.githubusercontent.com URL in check_version_updates.cjs, the path triggers and validation step in ci.yml, and the comment in compiler_activation_job.go.

"$schema": "./releases.schema.json",
"blockedVersions": [],
"minimumVersion": "v0.64.4",
"minRecommendedVersion": "v0.64.4"
}
36 changes: 36 additions & 0 deletions .github/aw/releases.schema.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "gh-aw update check configuration",
"description": "Configuration for the compile-agentic version update check. This file is fetched at runtime by the activation job to validate that the compiled version is still supported.",
"type": "object",
"additionalProperties": false,
"properties": {
"$schema": {
"type": "string",
"description": "JSON Schema reference"
},
"blockedVersions": {
"type": "array",
"description": "List of blocked compile-agentic versions that are not allowed to run (e.g. due to a security compromise). Workflows compiled with a blocked version will fail at activation.",
"items": {
"type": "string",
"pattern": "^v[0-9]+\\.[0-9]+\\.[0-9]+$",
"description": "A blocked version string in vMAJOR.MINOR.PATCH format (e.g. 'v1.2.3')"
},
"uniqueItems": true,
"default": []
},
"minimumVersion": {
"type": "string",
"description": "The minimum supported compile-agentic version in vMAJOR.MINOR.PATCH format. Workflows compiled with a version below this will fail at activation. Use an empty string to disable this check.",
"pattern": "^(v[0-9]+\\.[0-9]+\\.[0-9]+)?$",
"default": ""
},
"minRecommendedVersion": {
"type": "string",
"description": "The minimum recommended compile-agentic version in vMAJOR.MINOR.PATCH format. Workflows compiled with a version below this will emit a warning (but not fail) at activation, nudging users to upgrade. Use an empty string to disable this check.",
"pattern": "^(v[0-9]+\\.[0-9]+\\.[0-9]+)?$",
"default": ""
}
}
}
89 changes: 89 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ on:
- 'actions/**'
- '.github/workflows/ci.yml'
- '.github/workflows/**/*.md'
- '.github/aw/releases.json'
- '.github/aw/releases.schema.json'
workflow_dispatch:
jobs:
test:
Expand Down Expand Up @@ -836,6 +838,93 @@ jobs:

echo "✅ All URLs in $AGENT_FILE correctly use 'main' branch"

- name: Validate releases.json structure and version formats
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
with:
script: |
const fs = require('fs');
const CONFIG_FILE = '.github/aw/releases.json';
const SCHEMA_FILE = '.github/aw/releases.schema.json';

core.info(`🔍 Validating ${CONFIG_FILE} against ${SCHEMA_FILE}...`);

if (!fs.existsSync(CONFIG_FILE)) {
core.setFailed(`ERROR: ${CONFIG_FILE} not found`);
return;
}
if (!fs.existsSync(SCHEMA_FILE)) {
core.setFailed(`ERROR: ${SCHEMA_FILE} not found`);
return;
}

let config;
try {
config = JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf8'));
} catch (err) {
core.setFailed(`ERROR: ${CONFIG_FILE} is not valid JSON: ${err.message}`);
return;
}
core.info(`✅ ${CONFIG_FILE} is valid JSON`);

const errors = [];

// Check additionalProperties (only allow known keys)
const allowedKeys = new Set(['$schema', 'blockedVersions', 'minimumVersion', 'minRecommendedVersion']);
for (const key of Object.keys(config)) {
if (!allowedKeys.has(key)) {
errors.push(`Unknown property: '${key}'`);
}
}

// Validate blockedVersions
if ('blockedVersions' in config) {
const bv = config.blockedVersions;
if (!Array.isArray(bv)) {
errors.push("'blockedVersions' must be an array");
} else {
const versionPattern = /^v[0-9]+\.[0-9]+\.[0-9]+$/;
const seen = new Set();
bv.forEach((v, i) => {
if (typeof v !== 'string') {
errors.push(`'blockedVersions[${i}]' must be a string`);
} else if (!versionPattern.test(v)) {
errors.push(`'blockedVersions[${i}]' ('${v}') does not match expected version pattern (vMAJOR.MINOR.PATCH, e.g. 'v1.2.3')`);
} else if (seen.has(v)) {
errors.push(`'blockedVersions' contains duplicate entry: '${v}'`);
} else {
seen.add(v);
}
});
}
}

// Validate minimumVersion
if ('minimumVersion' in config) {
const mv = config.minimumVersion;
if (typeof mv !== 'string') {
errors.push("'minimumVersion' must be a string");
} else if (mv !== '' && !/^v[0-9]+\.[0-9]+\.[0-9]+$/.test(mv)) {
errors.push(`'minimumVersion' ('${mv}') does not match expected version pattern (vMAJOR.MINOR.PATCH, e.g. 'v1.2.3' or empty string)`);
}
}

// Validate minRecommendedVersion
if ('minRecommendedVersion' in config) {
const mrv = config.minRecommendedVersion;
if (typeof mrv !== 'string') {
errors.push("'minRecommendedVersion' must be a string");
} else if (mrv !== '' && !/^v[0-9]+\.[0-9]+\.[0-9]+$/.test(mrv)) {
errors.push(`'minRecommendedVersion' ('${mrv}') does not match expected version pattern (vMAJOR.MINOR.PATCH, e.g. 'v1.2.3' or empty string)`);
}
}

if (errors.length > 0) {
core.setFailed(`❌ ${CONFIG_FILE} schema validation failed:\n${errors.map(e => ` - ${e}`).join('\n')}`);
return;
}

core.info(`✅ ${CONFIG_FILE} is valid and conforms to ${SCHEMA_FILE}`);

js:
runs-on: ubuntu-latest
timeout-minutes: 10
Expand Down
158 changes: 158 additions & 0 deletions actions/setup/js/check_version_updates.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
// @ts-check
/// <reference types="@actions/github-script" />

/**
* Check compile-agentic version against the remote update configuration.
*
* This script:
* 1. Reads the compiled version from GH_AW_COMPILED_VERSION env var.
* 2. Skips the check if the version is not in vMAJOR.MINOR.PATCH official release format.
* 3. Fetches .github/aw/releases.json from the gh-aw repository via raw.githubusercontent.com.
* - Uses withRetry to handle transient network failures.
* 4. If the download fails or config is invalid JSON, the check is skipped (soft failure).
* 5. Validates that the compiled version is not in the blocked list.
* 6. Validates that the compiled version meets the minimum supported version.
*
* Fails the activation job when validation fails.
*/

const { withRetry, isTransientError } = require("./error_recovery.cjs");

const CONFIG_URL = "https://raw.githubusercontent.com/github/gh-aw/main/.github/aw/releases.json";

/**
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot do not handle both format, if the version is not vmajor.minor.patch, it should NOT be treated as an official release and ignore.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in 9849ac9 — removed normalizeVersion. parseVersion now requires a leading v; any version without it returns null. In main(), after the dev-build skip, the compiled version is parsed and if it's not vMAJOR.MINOR.PATCH the check is skipped entirely ("not an official release version"). Blocked version config entries without a v prefix are also silently ignored.

* Parse an official version string (must be in vMAJOR.MINOR.PATCH format).
* Versions without a leading "v" are not treated as official releases and return null.
* Versions with unknown syntax also return null.
*
* @param {string} version
* @returns {number[]|null}
*/
function parseVersion(version) {
if (!version.startsWith("v")) return null;
const parts = version.slice(1).split(".");
if (parts.length !== 3) return null;
const nums = parts.map(Number);
if (nums.some(isNaN)) return null;
return nums;
Comment on lines +31 to +37
Copy link

Copilot AI Mar 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

parseVersion() claims to enforce strict vMAJOR.MINOR.PATCH but currently accepts versions with extra dot segments (e.g. v1.2.3.4 parses as [1,2,3]). This can incorrectly treat non-release/invalid versions as official releases and run comparisons against truncated versions. Require exactly 3 numeric parts (or use a strict regex) and add a unit test for an extra-segment version to prevent regressions.

Copilot uses AI. Check for mistakes.
}

/**
* Compare two official version strings (both must be in vMAJOR.MINOR.PATCH format).
* Returns a negative number if a < b, 0 if equal, positive if a > b.
* Returns 0 (treat as equal/unknown) if either version cannot be parsed.
*
* @param {string} a
* @param {string} b
* @returns {number}
*/
function compareVersions(a, b) {
const pa = parseVersion(a);
const pb = parseVersion(b);
if (!pa || !pb) return 0;
for (let i = 0; i < 3; i++) {
if (pa[i] !== pb[i]) return pa[i] - pb[i];
}
return 0;
}

/**
* @typedef {object} UpdateConfig
* @property {string[]} [blockedVersions]
* @property {string} [minimumVersion]
* @property {string} [minRecommendedVersion]
*/

/**
* Main entry point.
*/
async function main() {
const compiledVersion = process.env.GH_AW_COMPILED_VERSION || "";

if (!compiledVersion || compiledVersion === "dev") {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot ignore unknown versions, or unknown syntax

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in 9849ac9 — unknown/non-parseable versions are now ignored at every point: compiled version with unknown syntax skips the check early; blocked version entries with unknown syntax are filtered out; a minimumVersion with unknown syntax is treated as absent and the minimum check is skipped.

core.info(`Skipping version update check: version is '${compiledVersion || "(empty)"}' (dev build)`);
return;
}

// Only check official releases in vMAJOR.MINOR.PATCH format; ignore unknown syntax
if (!parseVersion(compiledVersion)) {
core.info(`Skipping version update check: '${compiledVersion}' is not an official release version (expected vMAJOR.MINOR.PATCH format)`);
return;
}

core.info(`Checking compile-agentic version: ${compiledVersion}`);
core.info(`Fetching update configuration from: ${CONFIG_URL}`);

/** @type {UpdateConfig} */
let config;
try {
config = await withRetry(
async () => {
const res = await fetch(CONFIG_URL);
if (!res.ok) {
const err = new Error(`HTTP ${res.status} fetching ${CONFIG_URL}`);
// @ts-ignore - Attach status so the retry predicate can inspect it
err.status = res.status;
throw err;
}
Comment on lines +89 to +97
Copy link

Copilot AI Mar 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The retry behavior for HTTP responses does not match the PR description (“5xx responses are retried”). Here, a non-OK response throws Error("HTTP <status> fetching ..."), but isTransientError() only matches specific strings like "502 bad gateway"/"503 service unavailable" and will treat HTTP 500 ... as non-retryable (so no retries happen). Consider making the fetch retry predicate explicitly retry on res.status >= 500 (e.g., by throwing an error that carries status, or by checking status before throwing) and add a test asserting a 500 triggers retries.

Copilot uses AI. Check for mistakes.
const parsed = JSON.parse(await res.text());
// Guard: JSON.parse("null") returns null; treat non-object/null/array as empty config
return parsed !== null && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
},
{
shouldRetry: err =>
isTransientError(err) ||
// Retry on any HTTP 5xx response (server errors)
(err !== null && typeof err === "object" && "status" in err && Number(err.status) >= 500),
},
"fetch update configuration"
);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
core.info(`Could not fetch update configuration (${message}). Skipping version check.`);
return;
}

const blockedVersions = Array.isArray(config.blockedVersions) ? config.blockedVersions : [];
const minimumVersion = typeof config.minimumVersion === "string" ? config.minimumVersion : "";
const minRecommendedVersion = typeof config.minRecommendedVersion === "string" ? config.minRecommendedVersion : "";

// Check blocked versions — only consider entries in vMAJOR.MINOR.PATCH format; ignore unknown syntax
const isBlocked = blockedVersions.some(v => parseVersion(v) !== null && compareVersions(compiledVersion, v) === 0);
if (isBlocked) {
core.summary
.addRaw("### ❌ Blocked compile-agentic version\n\n")
.addRaw(`The compile-agentic version \`${compiledVersion}\` is **blocked** and cannot be used to run workflows.\n\n`)
.addRaw("This version has been revoked, typically due to a security issue.\n\n")
.addRaw("**Action required:** Update `gh-aw` to the latest version and recompile your workflow with `gh aw compile`.\n");
await core.summary.write();
core.setFailed(`Blocked compile-agentic version: ${compiledVersion} is in the blocked versions list. Update gh-aw to the latest version and recompile your workflow.`);
return;
}

// Check minimum version — skip if minimumVersion is absent, empty, or has unknown syntax
if (minimumVersion && parseVersion(minimumVersion) !== null) {
if (compareVersions(compiledVersion, minimumVersion) < 0) {
core.summary
.addRaw("### ❌ Outdated compile-agentic version\n\n")
.addRaw(`The compile-agentic version \`${compiledVersion}\` is below the minimum supported version \`${minimumVersion}\`.\n\n`)
.addRaw("**Action required:** Update `gh-aw` to the latest version and recompile your workflow with `gh aw compile`.\n");
await core.summary.write();
core.setFailed(`Outdated compile-agentic version: ${compiledVersion} is below the minimum supported version ${minimumVersion}. Update gh-aw to the latest version and recompile your workflow.`);
return;
}
}

// Check recommended version — skip if minRecommendedVersion is absent, empty, or has unknown syntax
if (minRecommendedVersion && parseVersion(minRecommendedVersion) !== null) {
if (compareVersions(compiledVersion, minRecommendedVersion) < 0) {
core.warning(
`Recommended upgrade: compile-agentic version ${compiledVersion} is below the recommended version ${minRecommendedVersion}. Consider updating gh-aw to the latest version and recompiling your workflow with \`gh aw compile\`.`
);
}
}

core.info(`✅ Version check passed: ${compiledVersion}`);
}

module.exports = { main };
Loading
Loading