Skip to content
Open
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
70 changes: 70 additions & 0 deletions .github/copilot-instructions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
# Copilot Instructions — VersionReaderTask

## What this project is

An Azure DevOps pipeline extension (VSIX) that reads version tags (`<Version>`, `<AssemblyVersion>`, `<VersionPrefix>`, etc.) from SDK-format `.csproj`/`.vbproj` files and exposes them as pipeline build variables. The task is written in TypeScript targeting Node 20.

## Repository layout

- `vss-extension.json` — Extension manifest (publisher, version, contribution metadata)
- `VersionReaderTask/` — The actual pipeline task (self-contained Node package)
- `index.ts` / `index.js` — Task entry point; instantiates `versionReader` and calls `execute()`
- `utils.ts` / `utils.js` — All business logic: file matching, XML parsing, variable setting
- `task.json` — Azure Pipelines task definition (inputs, execution runtime)
- `tests/` — Mocha test suite; uses `azure-pipelines-task-lib/mock-run` and `mock-test`
- `package.cmd` — Packages the VSIX using `tfx extension create`

Note: the `.csproj` files are test files and should not be confused with actual project files for the task.

## Build, test, and lint

All commands run inside `VersionReaderTask/`:

```bash
cd VersionReaderTask

# Compile TypeScript (output .js files sit alongside .ts sources — checked in)
npx tsc

# Run full test suite
npm test
# equivalent: npx mocha ./tests/_suite.js

# Run a single test file directly
npx mocha --require ./tests/_suite.js # all tests loaded via _suite.js
```

> **Important:** The compiled `.js` files are committed alongside `.ts` sources. After editing any `.ts` file, recompile with `npx tsc` before running tests or packaging.

## Key conventions

### Two-package structure
The root `package.json` is minimal (extension tooling only). The task's own `package.json` and `node_modules` live inside `VersionReaderTask/`. Install dependencies with `npm install` inside `VersionReaderTask/`, not the root.

### Version tag priority
`getFirstMatch()` in `utils.ts` resolves the version in this order:
1. `<Version>`
2. `<AssemblyVersion>`
3. `<VersionPrefix>`
4. Falls back to `"1.0.0"` if none are found

### Output variables
The task sets these Azure Pipelines variables:
- `VERSION_BUILD` — version + build separator + `BUILD_BUILDID` (e.g. `1.2.3.5678`)
- `Version`, `AssemblyVersion`, `VersionPrefix`, `VersionSuffix`, `PackageVersion`, `FileVersion` — raw values from the project file

If `variablesPrefix` is set (e.g. `DEMO`), all variable names are prefixed: `DEMO_VERSION_BUILD`, `DEMO_Version`, etc.

### Testing pattern
Each integration test has a dedicated mock runner file in `tests/` (e.g. `runVersion.ts`). These use `TaskMockRunner` to mock `findMatch` answers and task inputs. The `_suite.ts` file imports `utilsTest` directly (unit tests) then registers integration tests via `MockTestRunner`. Tests assert against `##vso[task.setvariable ...]` lines in stdout using `assertSet()`.

Test fixture `.csproj` files live in `VersionReaderTask/tests/` (`Version.csproj`, `VersionMissing.csproj`, `VersionPrefix.csproj`).

### Extension packaging
```cmd
package.cmd
```
This deletes a conflicting markdown file from `xpath/docs/`, then runs `tfx extension create --manifest-globs vss-extension.json`. Requires `tfx-cli` installed globally.

### TypeScript config
Targets ES6, `module: NodeNext`, strict mode enabled. `esModuleInterop: true` is required for the `xmldom` default import (`import xdom from 'xmldom'`).
2 changes: 1 addition & 1 deletion VersionReaderTask/.taskkey
Original file line number Diff line number Diff line change
@@ -1 +1 @@
4a0b6e2a-5a8f-4057-a52e-c0ab244aefef
2f57d672-ba28-4444-adb7-03212754e2f6
7 changes: 7 additions & 0 deletions VersionReaderTask/tests/AssemblyVersion.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>netstandard2.0</TargetFramework>
<AssemblyVersion>2.0.0</AssemblyVersion>
<Description>File with only AssemblyVersion tag set</Description>
</PropertyGroup>
</Project>
7 changes: 7 additions & 0 deletions VersionReaderTask/tests/VersionWhitespace.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>netstandard2.0</TargetFramework>
<Version> 1.3.0 </Version>
<Description>File with whitespace around version value</Description>
</PropertyGroup>
</Project>
46 changes: 45 additions & 1 deletion VersionReaderTask/tests/_suite.js
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,7 @@ utilsTests.run();
});
});
(0, mocha_1.it)('Version reverts to 1.0.0 if not found (runVersionMissing.ts)', function (done) {
// reads the Version.csproj file with TEST prefix
// reads the VersionMissing.csproj file with no prefix
let tp = path.join(__dirname, 'runVersionMissing.js');
let tr = new ttm.MockTestRunner(tp);
tr.runAsync().then(() => {
Expand All @@ -127,6 +127,50 @@ utilsTests.run();
done();
});
});
(0, mocha_1.it)('VersionPrefix is used when Version is absent (runVersionPrefix.ts)', function (done) {
// reads VersionPrefix.csproj which has only <VersionPrefix> set
let tp = path.join(__dirname, 'runVersionPrefix.js');
let tr = new ttm.MockTestRunner(tp);
process.env.BUILD_BUILDID = "5678";
tr.runAsync().then(() => {
console.log("Task result = " + tr.succeeded);
assert.equal(tr.succeeded, true, 'should have succeeded');
assert.equal(tr.errorIssues.length, 0, "should have 0 errors");
console.log(tr.stdout);
assertSet(tr, "VERSION_BUILD", "1.2.5.5678");
assertSet(tr, "VersionPrefix", "1.2.5");
done();
});
});
(0, mocha_1.it)('AssemblyVersion is used when Version is absent (runAssemblyVersion.ts)', function (done) {
// reads AssemblyVersion.csproj which has only <AssemblyVersion> set
let tp = path.join(__dirname, 'runAssemblyVersion.js');
let tr = new ttm.MockTestRunner(tp);
process.env.BUILD_BUILDID = "5678";
tr.runAsync().then(() => {
console.log("Task result = " + tr.succeeded);
assert.equal(tr.succeeded, true, 'should have succeeded');
assert.equal(tr.errorIssues.length, 0, "should have 0 errors");
console.log(tr.stdout);
assertSet(tr, "VERSION_BUILD", "2.0.0.5678");
assertSet(tr, "AssemblyVersion", "2.0.0");
done();
});
});
(0, mocha_1.it)('Empty buildPrefix concatenates version and build ID directly (runVersionNoBuildPrefix.ts)', function (done) {
// empty buildPrefix means no separator between version and build ID
let tp = path.join(__dirname, 'runVersionNoBuildPrefix.js');
let tr = new ttm.MockTestRunner(tp);
process.env.BUILD_BUILDID = "5678";
tr.runAsync().then(() => {
console.log("Task result = " + tr.succeeded);
assert.equal(tr.succeeded, true, 'should have succeeded');
assert.equal(tr.errorIssues.length, 0, "should have 0 errors");
console.log(tr.stdout);
assertSet(tr, "VERSION_BUILD", "1.2.35678");
done();
});
});
});
// check an environment var was set
function assertSet(tr, envVar, value) {
Expand Down
61 changes: 60 additions & 1 deletion VersionReaderTask/tests/_suite.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@ describe('VersionReaderTask v3 tests', () => {


it('Version reverts to 1.0.0 if not found (runVersionMissing.ts)', function (done: Mocha.Done) {
// reads the Version.csproj file with TEST prefix
// reads the VersionMissing.csproj file with no prefix
let tp = path.join(__dirname, 'runVersionMissing.js');
let tr: ttm.MockTestRunner = new ttm.MockTestRunner(tp);

Expand All @@ -119,6 +119,65 @@ describe('VersionReaderTask v3 tests', () => {
});
});


it('VersionPrefix is used when Version is absent (runVersionPrefix.ts)', function (done: Mocha.Done) {
// reads VersionPrefix.csproj which has only <VersionPrefix> set
let tp = path.join(__dirname, 'runVersionPrefix.js');
let tr: ttm.MockTestRunner = new ttm.MockTestRunner(tp);

process.env.BUILD_BUILDID = "5678";

tr.runAsync().then(() => {
console.log("Task result = " + tr.succeeded);
assert.equal(tr.succeeded, true, 'should have succeeded');
assert.equal(tr.errorIssues.length, 0, "should have 0 errors");
console.log(tr.stdout);

assertSet(tr, "VERSION_BUILD", "1.2.5.5678");
assertSet(tr, "VersionPrefix", "1.2.5");
done();
});
});


it('AssemblyVersion is used when Version is absent (runAssemblyVersion.ts)', function (done: Mocha.Done) {
// reads AssemblyVersion.csproj which has only <AssemblyVersion> set
let tp = path.join(__dirname, 'runAssemblyVersion.js');
let tr: ttm.MockTestRunner = new ttm.MockTestRunner(tp);

process.env.BUILD_BUILDID = "5678";

tr.runAsync().then(() => {
console.log("Task result = " + tr.succeeded);
assert.equal(tr.succeeded, true, 'should have succeeded');
assert.equal(tr.errorIssues.length, 0, "should have 0 errors");
console.log(tr.stdout);

assertSet(tr, "VERSION_BUILD", "2.0.0.5678");
assertSet(tr, "AssemblyVersion", "2.0.0");
done();
});
});


it('Empty buildPrefix concatenates version and build ID directly (runVersionNoBuildPrefix.ts)', function (done: Mocha.Done) {
// empty buildPrefix means no separator between version and build ID
let tp = path.join(__dirname, 'runVersionNoBuildPrefix.js');
let tr: ttm.MockTestRunner = new ttm.MockTestRunner(tp);

process.env.BUILD_BUILDID = "5678";

tr.runAsync().then(() => {
console.log("Task result = " + tr.succeeded);
assert.equal(tr.succeeded, true, 'should have succeeded');
assert.equal(tr.errorIssues.length, 0, "should have 0 errors");
console.log(tr.stdout);

assertSet(tr, "VERSION_BUILD", "1.2.35678");
done();
});
});

});

// check an environment var was set
Expand Down
51 changes: 51 additions & 0 deletions VersionReaderTask/tests/runVersionNoBuildPrefix.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
"use strict";
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
var ownKeys = function(o) {
ownKeys = Object.getOwnPropertyNames || function (o) {
var ar = [];
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
return ar;
};
return ownKeys(o);
};
return function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
__setModuleDefault(result, mod);
return result;
};
})();
Object.defineProperty(exports, "__esModule", { value: true });
const tmrm = __importStar(require("azure-pipelines-task-lib/mock-run"));
const path = __importStar(require("node:path"));
let taskPath = path.join(__dirname, '..', 'index.js');
let tmr = new tmrm.TaskMockRunner(taskPath);
// set findMatch answer:
tmr.setAnswers({
"findMatch": {
"Version.csproj": ["./tests/Version.csproj"]
}
});
// dummy build value
process.env.BUILD_BUILDID = "5678";
// set searchPattern — no buildPrefix (empty string means direct concatenation)
tmr.setInput("searchPattern", "Version.csproj");
tmr.setInput("buildPrefix", "");
tmr.run();
21 changes: 21 additions & 0 deletions VersionReaderTask/tests/runVersionNoBuildPrefix.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import * as tmrm from 'azure-pipelines-task-lib/mock-run';
import * as path from 'node:path';

let taskPath = path.join(__dirname, '..', 'index.js');
let tmr: tmrm.TaskMockRunner = new tmrm.TaskMockRunner(taskPath);

// set findMatch answer:
tmr.setAnswers({
"findMatch": {
"Version.csproj": ["./tests/Version.csproj"]
}
});

// dummy build value
process.env.BUILD_BUILDID = "5678";

// set searchPattern — no buildPrefix (empty string means direct concatenation)
tmr.setInput("searchPattern", "Version.csproj");
tmr.setInput("buildPrefix", "");

tmr.run();
Loading
Loading