Skip to content
Merged
3 changes: 2 additions & 1 deletion packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
"lint": "eslint \"src/**/*.ts\" vitest.config.ts"
},
"devDependencies": {
"vitest": "catalog:"
"vitest": "catalog:",
"yaml": "catalog:"
}
}
82 changes: 82 additions & 0 deletions packages/core/src/workflows/deploy-yaml.live.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
// Opt-in live regression test. Fetches the CURRENT deploy workflow templates
// from radius-project/radius `.github/extension/` and asserts that, once the
// extension fills them, every generated file parses as valid YAML.
//
// Why this matters: the extension renders templates fetched from
// radius-project/radius at RUNTIME (RADIUS_REF, currently `main`) — it bundles
// no copy. `generateDeployWorkflow` injects GitHub Actions expressions whose
// string defaults are single-quoted (`${{ vars.RADIUS_BUILD_ARCH_MODE ||
// 'detect' }}`). If an upstream template wraps such a placeholder in a
// single-quoted YAML scalar, the injected quotes nest and the rendered file
// becomes invalid YAML — every deploy dispatch then fails upstream with
// `HTTP 422: failed to parse workflow`. That is exactly issue #407
// (radius-project/ai-extensions#407), caused by radius-project/radius#12640 and
// fixed by radius-project/radius#12721. The hermetic tests in deploy.test.ts use
// inline fixtures, so only a LIVE render of the real templates catches an
// upstream quoting regression.
//
// This hits the network and depends on an external repo's moving ref, so it is
// NOT part of the default hermetic suite: it runs only when
// RUN_LIVE_WORKFLOW_TESTS is set (e.g. locally or in a scheduled job), never in
// normal CI.
import { describe, it, expect } from "vitest";
import { parse as parseYaml } from "yaml";
import {
RADIUS_WORKFLOW_REPO,
RADIUS_WORKFLOW_DIR,
RADIUS_REF,
DEPLOY_DISPATCHER_FILE,
DEPLOY_AZURE_FILE,
DEPLOY_AWS_FILE,
generateDeployWorkflow
} from "./deploy.js";

const LIVE = !!process.env.RUN_LIVE_WORKFLOW_TESTS;

async function fetchWorkflow(file: string): Promise<string> {
const url = `https://raw.githubusercontent.com/${RADIUS_WORKFLOW_REPO}/${RADIUS_REF}/${RADIUS_WORKFLOW_DIR}/${file}`;
const res = await fetch(url);
if (!res.ok) {
throw new Error(`failed to fetch ${url}: ${res.status} ${res.statusText}`);
}
return res.text();
}

describe.skipIf(!LIVE)(
"live deploy-workflow YAML validity (opt-in: set RUN_LIVE_WORKFLOW_TESTS)",
() => {
// Render the three real upstream templates through the extension and assert
// every generated file parses as valid YAML. This is the only test that
// would have caught the radius#12640 quoting regression, because the
// extension renders templates fetched from `main` at runtime.
it(
"renders valid YAML from the current upstream deploy templates",
async () => {
const files = [
DEPLOY_DISPATCHER_FILE,
DEPLOY_AZURE_FILE,
DEPLOY_AWS_FILE
];
const bodies = await Promise.all(files.map((f) => fetchWorkflow(f)));
const templates: Record<string, string> = {};
files.forEach((f, i) => {
templates[f] = bodies[i];
});

const generated = generateDeployWorkflow(
"prod",
".radius/app.bicep",
templates
);

for (const [name, body] of Object.entries(generated)) {
expect(
() => parseYaml(body),
`${name} rendered from upstream ${RADIUS_REF} should parse as valid YAML`
).not.toThrow();
}
},
30_000
);
}
);
122 changes: 122 additions & 0 deletions packages/core/src/workflows/deploy.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { describe, expect, it } from "vitest";
import { parse as parseYaml } from "yaml";
import {
DEFAULT_TARGET_CLUSTER_ARCH_FALLBACK_PLATFORMS,
DEFAULT_TARGET_CLUSTER_ARCH_MODE,
Expand Down Expand Up @@ -182,3 +183,124 @@ describe("generateDeployWorkflow", () => {
);
});
});

// Fixtures that FAITHFULLY MIRROR the quoting of the real radius-project/radius
// deploy templates (`.github/extension/run-rad-commands*.yml`), unlike the
// inline fixtures above which use double quotes throughout. Reproducing the real
// mix is what lets these tests catch the issue #407 class of bug:
// - plain values are injected into SINGLE-quoted scalars (`APP_FILE`, `ENV`)
// and into unquoted GHA expressions (`APP_IMAGE`, dispatcher `environment`);
// - the arch placeholders are injected into DOUBLE-quoted scalars, because the
// value core injects is itself a GHA expression whose default is
// single-quoted (`${{ vars.X || 'detect' }}`). A single-quoted scalar there
// nests the quotes and produces invalid YAML (the #407 regression).
const REALISTIC_TEMPLATES = {
[DEPLOY_DISPATCHER_FILE]: `name: deploy
on:
workflow_dispatch:
inputs:
environment:
default: '{{ENV}}'
jobs:
detect:
runs-on: ubuntu-latest
environment: \${{ inputs.environment || '{{ENV}}' }}
env:
TARGET_CLUSTER_ARCH_MODE: "{{TARGET_CLUSTER_ARCH_MODE}}"
TARGET_CLUSTER_ARCH_FALLBACK_PLATFORMS: "{{TARGET_CLUSTER_ARCH_FALLBACK_PLATFORMS}}"
steps:
- run: echo \${{ github.sha }}
`,
[DEPLOY_AZURE_FILE]: `name: deploy-azure
env:
APP_FILE: '{{APP_FILE}}'
APP_IMAGE: \${{ inputs.image || github.sha || 'latest' }}
TARGET_CLUSTER_ARCH_MODE: "{{TARGET_CLUSTER_ARCH_MODE}}"
TARGET_CLUSTER_ARCH_FALLBACK_PLATFORMS: "{{TARGET_CLUSTER_ARCH_FALLBACK_PLATFORMS}}"
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: radius-project/radius/.github/extension/actions/run-rad-commands@{{RADIUS_REF}}
`,
[DEPLOY_AWS_FILE]: `name: deploy-aws
env:
APP_FILE: '{{APP_FILE}}'
APP_IMAGE: \${{ inputs.image || github.sha || 'latest' }}
TARGET_CLUSTER_ARCH_MODE: "{{TARGET_CLUSTER_ARCH_MODE}}"
TARGET_CLUSTER_ARCH_FALLBACK_PLATFORMS: "{{TARGET_CLUSTER_ARCH_FALLBACK_PLATFORMS}}"
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: radius-project/radius/.github/extension/actions/run-rad-commands@{{RADIUS_REF}}
`
};

describe("generateDeployWorkflow YAML validity", () => {
Comment thread
sk593 marked this conversation as resolved.
// Positive guard: with the real templates' quoting and the default arch vars
// (single-quoted GHA expressions), every rendered file must parse as valid
// YAML. Prior tests only did `.toContain(...)` substring checks and never
// parsed the output, so they stayed green against invalid YAML.
it("renders valid YAML for every file when arch scalars are double-quoted", () => {
const files = generateDeployWorkflow(
"prod",
".radius/app.bicep",
REALISTIC_TEMPLATES
);

for (const [name, body] of Object.entries(files)) {
expect(
() => parseYaml(body),
`${name} should parse as valid YAML`
).not.toThrow();
}
});

// The injected GHA expression (with its single-quoted default) must survive
// substitution intact inside the double-quoted scalar.
it("preserves the injected GHA arch expressions as YAML string values", () => {
const files = generateDeployWorkflow(
"prod",
".radius/app.bicep",
REALISTIC_TEMPLATES
);
const azure = parseYaml(files[DEPLOY_AZURE_FILE]) as {
env: Record<string, string>;
};

expect(azure.env.TARGET_CLUSTER_ARCH_MODE).toBe(
`\${{ vars.${RADIUS_BUILD_ARCH_MODE_VAR} || '${DEFAULT_TARGET_CLUSTER_ARCH_MODE}' }}`
);
expect(azure.env.TARGET_CLUSTER_ARCH_FALLBACK_PLATFORMS).toBe(
`\${{ vars.${RADIUS_BUILD_PLATFORMS_VAR} || '${DEFAULT_TARGET_CLUSTER_ARCH_FALLBACK_PLATFORMS}' }}`
);
// The plain single-quoted scalars stay intact too.
expect(azure.env.APP_FILE).toBe(".radius/app.bicep");
Comment thread
sk593 marked this conversation as resolved.
});

// Negative regression guard: this is exactly the #407 bug. When the arch
// scalar is SINGLE-quoted (as the radius templates were before #12721), the
// single-quoted default inside the injected GHA expression nests and YAML
// terminates the scalar early, so the rendered file is invalid YAML. This test
// locks in WHY the scalar must be double-quoted: if someone reintroduces the
// single-quoted scalar (or an injected value that nests quotes), it fails.
it("produces invalid YAML when an arch scalar is single-quoted (issue #407)", () => {
const broken = {
...REALISTIC_TEMPLATES,
[DEPLOY_AZURE_FILE]: REALISTIC_TEMPLATES[DEPLOY_AZURE_FILE].replace(
'TARGET_CLUSTER_ARCH_MODE: "{{TARGET_CLUSTER_ARCH_MODE}}"',
"TARGET_CLUSTER_ARCH_MODE: '{{TARGET_CLUSTER_ARCH_MODE}}'"
)
};
// Substitution still succeeds (no unresolved placeholder), so generation
// does not throw — the breakage only surfaces when the YAML is parsed.
const files = generateDeployWorkflow(
"prod",
".radius/app.bicep",
broken
);

expect(() => parseYaml(files[DEPLOY_AZURE_FILE])).toThrow();
});
});
6 changes: 6 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 4 additions & 0 deletions pnpm-workspace.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -25,5 +25,9 @@ catalog:
"@vitest/coverage-v8": ^4.1.10
typescript: ^7.0.2
vitest: ^4.1.10
# YAML parser used only by @radius-project/core tests to assert generated
# workflow files parse as valid YAML (dev-only; core ships no runtime deps).
# Pinned to the version already resolved in the lockfile.
Comment thread
sk593 marked this conversation as resolved.
Outdated
yaml: ^2.9.0

minimumReleaseAge: 11520 # 8 days
Loading