Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
bbeeae3
bump postcss from 8.5.9 to 8.5.10
mikeharder Apr 24, 2026
6f3ad0f
Merge branch 'Azure:main' into main
mikeharder Apr 28, 2026
0ee2638
Merge branch 'Azure:main' into main
mikeharder May 1, 2026
ee12473
stub js-based typespec-requirement
mikeharder May 1, 2026
154a125
Merge branch 'main' into tsr-v2
mikeharder May 1, 2026
a6020fb
sparse clone
mikeharder May 1, 2026
f73fb1d
Merge branch 'tsr-v2' of https://github.com/mikeharder/azure-rest-api…
mikeharder May 1, 2026
d231a16
move tsr to ubuntu-24.04
mikeharder May 1, 2026
dc91268
log lengths
mikeharder May 1, 2026
fdc5ef8
log changed files
mikeharder May 1, 2026
2fd58a9
add handwritten swagger in new api version
mikeharder May 1, 2026
4d80abd
log changed files count
mikeharder May 1, 2026
697931a
[exec.test.js] allow any stderr
mikeharder May 1, 2026
63e996b
mock simple-git
mikeharder May 1, 2026
fce05b8
filter swaggers
mikeharder May 1, 2026
805e635
mock git diff
mikeharder May 1, 2026
9b41cc8
getchangedfilesstatuses
mikeharder May 2, 2026
5745104
git.show
mikeharder May 2, 2026
ae92824
add typespecGenerated and isNewAPiVersion
mikeharder May 2, 2026
9b7d781
add typespec-gen test
mikeharder May 2, 2026
ea63164
exit fast if valid
mikeharder May 2, 2026
8c4ef1d
sort tests
mikeharder May 2, 2026
09b2631
cover rename
mikeharder May 2, 2026
41e1dd1
split into 5 tests
mikeharder May 2, 2026
b9c4c1b
refactor
mikeharder May 2, 2026
d9af50a
refactor
mikeharder May 2, 2026
df6e2b9
parameterize tests
mikeharder May 2, 2026
dbaee97
refactor
mikeharder May 2, 2026
ab8b433
use core.setfailed
mikeharder May 4, 2026
b1f6a16
make swagger valid
mikeharder May 4, 2026
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
11 changes: 10 additions & 1 deletion .github/shared/src/changed-files.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
import debug from "debug";
import { simpleGit } from "simple-git";
import { KeyedCache } from "./cache.js";
Expand Down Expand Up @@ -65,6 +65,15 @@
return files;
}

/**
* @typedef {Object} ChangedFilesStatuses
* @property {string[]} additions
* @property {string[]} modifications
* @property {string[]} deletions
* @property {{from: string, to: string}[]} renames
* @property {number} total
*/

/**
* Get a list of changed files in a git repository with statuses for additions,
* modifications, deletions, and renames. Warning: rename behavior can vary
Expand All @@ -77,7 +86,7 @@
* @param {string} [options.headCommitish] Default: "HEAD".
* @param {import('./logger.js').ILogger} [options.logger]
* @param {string[]} [options.paths] Limits the diff to the named paths. If not set, includes all paths in repo. Default: []
* @returns {Promise<{additions: string[], modifications: string[], deletions: string[], renames: {from: string, to: string}[], total: number}>}
* @returns {Promise<ChangedFilesStatuses>}
*/
export async function getChangedFilesStatuses(options = {}) {
const {
Expand Down
2 changes: 1 addition & 1 deletion .github/shared/test/exec.test.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
import { dirname, resolve } from "path";
import semver from "semver";
import { fileURLToPath } from "url";
Expand Down Expand Up @@ -72,7 +72,7 @@
it("runs prettier", async () => {
await expect(execNpmExec(["prettier", "--version"], options)).resolves.toEqual({
stdout: /** @type {unknown} */ (expect.toSatisfy((v) => semver.valid(String(v)) !== null)),
stderr: "",
stderr: /** @type {unknown} */ (expect.any(String)),
error: undefined,
});
});
Expand Down
66 changes: 66 additions & 0 deletions .github/workflows/src/typespec-requirement.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import debug from "debug";
import { dirname } from "path";
import { simpleGit } from "simple-git";
import { inspect } from "util";
import { getChangedFilesStatuses, swagger } from "../../shared/src/changed-files.js";
import { Swagger } from "../../shared/src/swagger.js";
import { CoreLogger } from "./core-logger.js";

// Enable simple-git debug logging to improve console output
debug.enable("simple-git");

// TODO: Need to add "brownfield" label if spec already exists in main?
// would need API call to query main, not just use git client to query HEAD^ (like typespec-requirement.ps1)

/**
* @param {import('@actions/github-script').AsyncFunctionArguments} AsyncFunctionArguments
* @returns {Promise<void>}
*/
export default async function typespecRequirement({ core }) {
const options = {
cwd: process.env.GITHUB_WORKSPACE,
paths: ["specification"],
logger: new CoreLogger(core),
};

const changedFiles = await getChangedFilesStatuses(options);

const changedSwaggers = [
...changedFiles.additions,
...changedFiles.modifications,
...changedFiles.renames.map((r) => r.to),
].filter(swagger);

core.debug(`changed files count: ${changedFiles.total}`);
core.debug(`changed swaggers:\n ${changedSwaggers.join("\n ")}`);

const git = simpleGit(options.cwd);

for (const swaggerPath of changedSwaggers) {
core.debug(swaggerPath);

const swaggerText = await git.show([`HEAD:${swaggerPath}`]);
core.debug(` swaggerText length: ${swaggerText.length}`);

const swagger = new Swagger(swaggerPath, { content: swaggerText });
const typespecGenerated = await swagger.getTypeSpecGenerated();
core.debug(` typespecGenerated: ${inspect(typespecGenerated)}`);

if (typespecGenerated) {
continue;
}

const existingApiVersion = await git
.catFile(["-e", `HEAD^:${dirname(swaggerPath)}`])
.then(() => true)
.catch(() => false);

core.debug(` existingApiVersion: ${existingApiVersion}`);

if (existingApiVersion) {
continue;
}

core.setFailed(` NEW API VERSION MUST USE TYPESPEC`);
}
}
134 changes: 134 additions & 0 deletions .github/workflows/test/typespec-requirement.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
import { simpleGit } from "simple-git";
import { describe, expect, it, vi } from "vitest";
import { getChangedFilesStatuses } from "../../shared/src/changed-files.js";
import typespecRequirementSrc from "../src/typespec-requirement.js";
import { createMockCore } from "./mocks.js";

vi.mock("../../shared/src/changed-files.js", async (importOriginal) => {
const mod = await importOriginal();
return {
.../** @type {typeof import("../../shared/src/changed-files.js")} */ (mod),
getChangedFilesStatuses: vi.fn(),
};
});

vi.mock("simple-git", () => ({
simpleGit: vi.fn().mockReturnValue({
catFile: vi.fn().mockResolvedValue(""),
show: vi.fn().mockResolvedValue(""),
}),
}));

/**
* @param {unknown} asyncFunctionArgs
*/
function typespecRequirement(asyncFunctionArgs) {
return typespecRequirementSrc(
/** @type {import("@actions/github-script").AsyncFunctionArguments} */ (asyncFunctionArgs),
);
}

describe("typespecRequirement", () => {
/**
* @param {{ changedFiles?: Partial<import("../../shared/src/changed-files.js").ChangedFilesStatuses>, existingApiVersion?: boolean, typespecGenerated?: boolean }} options
*/
async function runTest(options) {
const core = createMockCore();

vi.mocked(getChangedFilesStatuses).mockResolvedValue(
/** @type {import("../../shared/src/changed-files.js").ChangedFilesStatuses} */ ({
additions: [],
modifications: [],
deletions: [],
renames: [],
total: 1,
...options.changedFiles,
}),
);

vi.mocked(simpleGit).mockReturnValue(
/** @type {any} */ ({
catFile: vi.fn().mockImplementation(async () => {
await Promise.resolve();
if (options.existingApiVersion === false) {
throw new Error();
}
return "";
}),
show: vi.fn().mockImplementation(async () => {
await Promise.resolve();
return options.typespecGenerated
? JSON.stringify({ info: { "x-typespec-generated": [{}] } })
: "{}";
}),
}),
);

await typespecRequirement({ core });
return core;
}

it.each([
{
name: "allows typespec-generated swaggers",
options: {
changedFiles: {
additions: [
"specification/qux/resource-manager/Microsoft.Qux/stable/2024-01-01/qux.json",
],
},
typespecGenerated: true,
},
expected: true,
},
{
name: "allows swaggers in existing api versions",
options: {
changedFiles: {
additions: [
"specification/foo/resource-manager/Microsoft.Foo/stable/2024-01-01/foo.json",
],
},
},
expected: true,
},
{
name: "blocks swaggers in new api versions",
options: {
existingApiVersion: false,
changedFiles: {
modifications: ["specification/bar/data-plane/Microsoft.Bar/stable/2024-01-01/bar.json"],
},
},
expected: false,
},
{
name: "ignores examples",
options: {
changedFiles: {
renames: [
{
from: "specification/foo/resource-manager/Microsoft.Foo/stable/2024-01-01/examples/old_foo.json",
to: "specification/foo/resource-manager/Microsoft.Foo/stable/2024-01-01/examples/foo.json",
},
],
},
},
expected: true,
},
{
name: "ignores non-swagger files",
options: {
changedFiles: { additions: ["specification/baz/main.tsp"] },
},
expected: true,
},
])("$name", async ({ options, expected }) => {
const core = await runTest(options);
if (expected) {
expect(core.setFailed).not.toHaveBeenCalled();
} else {
expect(core.setFailed).toHaveBeenCalled();
}
});
});
41 changes: 24 additions & 17 deletions .github/workflows/typespec-requirement.yaml
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
name: TypeSpec Requirement

on:
Expand All @@ -14,30 +14,37 @@
contents: read

jobs:
TypeSpec-Requirement:
typespec-requirement:
name: TypeSpec Requirement

runs-on: ubuntu-slim
runs-on: ubuntu-24.04

steps:
- uses: actions/checkout@v6
with:
# Required since "HEAD^" is passed to TypeSpec-Requirement.ps1
# We compare HEAD (PR merge commit) against HEAD^ (base) via git diff,
# so we only need enough history to include the merge commit and its parent.
fetch-depth: 2
# Check only needs to view contents of changed files as a string, so can use
# "git show" on specific files instead of cloning whole repo
sparse-checkout: |
.github

- name: Setup Node and install deps
uses: ./.github/actions/setup-node-install-deps
- name: Install dependencies for github-script actions
uses: ./.github/actions/install-deps-github-script

- run: |
eng/scripts/TypeSpec-Requirement.ps1 `
-BaseCommitish HEAD^ `
-HeadCommitish HEAD `
id: tsr-ps1
shell: pwsh

# Always add label artifact, even if "brownfield=false", to ensure label is removed when necessary
- uses: ./.github/actions/add-label-artifact
name: Upload artifact with results
- id: typespec-requirement
name: TypeSpec Requirement
uses: actions/github-script@v8
with:
name: "brownfield"
value: "${{ steps.tsr-ps1.outputs.brownfield == 'true' }}"
script: |
const { default: typespecRequirement } =
await import('${{ github.workspace }}/.github/workflows/src/typespec-requirement.js');
return await typespecRequirement({ github, context, core });

# # Always add label artifact, even if "brownfield=false", to ensure label is removed when necessary
# - uses: ./.github/actions/add-label-artifact
# name: Upload artifact with results
# with:
# name: "brownfield"
# value: "${{ steps.tsr-ps1.outputs.brownfield == 'true' }}"
Loading
Loading