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
5 changes: 5 additions & 0 deletions .changeset/review-version-sync.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"review": patch
---

Keep the pinned `ref:` inside review.md in sync with the released version. The release flow's version command now runs `utils/sync-workflow-versions.ts` after `changeset version`, rewriting every workflow's pinned `<name>-v<semver>` literals (for this workflow, the `ref:` inside review.md) to the version being released so the bump lands in the same Version Packages commit that gets tagged; `workflows/review/version-sync.test.ts` fails CI whenever the ref and the `review` package version diverge. Also bumps the currently stale ref from review-v1.2.2 to review-v1.4.0 (v1.3.0 through v1.4.0 shipped with the lagging ref, so an un-overridden consumer checked out a lib without `lib/provenance.ts`).
6 changes: 6 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,12 @@ jobs:
if: steps.has-changesets.outputs.result == 'true'
uses: changesets/action@6a0a831ff30acef54f2c6aa1cbbc1096b066edaf # v1
with:
# `version-packages` runs `changeset version` and then syncs each
# workflow's pinned `<name>-v<semver>` literals (e.g. the checkout
# ref inside workflows/review/review.md) to the version being
# released, so the bump lands in the same Version Packages commit
# that gets tagged (see utils/sync-workflow-versions.ts).
version: pnpm run version-packages
publish: exit 1
env:
GITHUB_TOKEN: ${{ secrets.KHAN_ACTIONS_BOT_TOKEN }}
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
"repository": "git@github.com:Khan/actions.git",
"private": true,
"scripts": {
"version-packages": "changeset version && node -r @swc-node/register utils/sync-workflow-versions.ts",
"lint": "eslint --ext .js,.mjs,.ts .",
"fix": "pnpm run lint --fix",
"test": "vitest",
Expand Down
214 changes: 214 additions & 0 deletions utils/sync-workflow-versions-lib.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,214 @@
import {describe, expect, it, vi} from "vitest";
import {
syncWorkflowVersionContent,
syncWorkflowVersions,
type DirEntryLike,
} from "./sync-workflow-versions-lib.ts";

describe("syncWorkflowVersionContent", () => {
it("rewrites the pinned checkout ref to the given version", () => {
// Arrange: a stale ref, as shipped in review-v1.3.0 through v1.4.0.
const content = [
"pre-agent-steps:",
" - uses: actions/checkout@abc # v5",
" with:",
" repository: Khan/actions",
" ref: review-v1.2.2",
].join("\n");

// Act
const result = syncWorkflowVersionContent(content, "review", "9.9.9");

// Assert
expect(result.replaced).toBe(1);
expect(result.content).toContain("ref: review-v9.9.9");
expect(result.content).not.toContain("review-v1.2.2");
});

it("rewrites every semver literal but leaves templates alone", () => {
// Arrange
const content =
"ref: review-v1.2.2\n" +
"the marker is `v=review-v<version>` (a template)\n" +
"released as review-v1.3.0\n";

// Act
const result = syncWorkflowVersionContent(content, "review", "2.0.0");

// Assert
expect(result.replaced).toBe(2);
expect(result.content).toBe(
"ref: review-v2.0.0\n" +
"the marker is `v=review-v<version>` (a template)\n" +
"released as review-v2.0.0\n",
);
});

it("leaves other workflows' literals alone", () => {
// Arrange
const content = "ref: review-v1.2.2\nsee also triage-v3.0.0\n";

// Act
const result = syncWorkflowVersionContent(content, "review", "2.0.0");

// Assert
expect(result.replaced).toBe(1);
expect(result.content).toBe(
"ref: review-v2.0.0\nsee also triage-v3.0.0\n",
);
});

it("returns unchanged content when already at the version", () => {
// Act
const result = syncWorkflowVersionContent(
"ref: review-v1.4.0",
"review",
"1.4.0",
);

// Assert
expect(result.replaced).toBe(1);
expect(result.content).toBe("ref: review-v1.4.0");
});
});

describe("syncWorkflowVersions", () => {
const makeDeps = (files: Record<string, string>) => {
const writes: Record<string, string> = {};
const readdirSyncImpl = (dir: string): DirEntryLike[] => {
const children = new Map<string, boolean>();
for (const path of Object.keys(files)) {
if (!path.startsWith(`${dir}/`)) {
continue;
}
const rest = path.slice(dir.length + 1);
const slash = rest.indexOf("/");
children.set(
slash === -1 ? rest : rest.slice(0, slash),
slash !== -1,
);
}
return [...children].map(([name, isDir]) => ({
name,
isDirectory: () => isDir,
isFile: () => !isDir,
}));
};
return {
writes,
deps: {
readFileSyncImpl: (path: string) => {
const content = files[path];
if (content === undefined) {
throw new Error(`unexpected read: ${path}`);
}
return content;
},
writeFileSyncImpl: (path: string, data: string) => {
writes[path] = data;
},
readdirSyncImpl,
log: vi.fn(),
},
};
};

it("writes review.md with the package version substituted", () => {
// Arrange
const {writes, deps} = makeDeps({
"workflows/review/package.json":
'{"name": "review", "version": "1.5.0"}',
"workflows/review/review.md": " ref: review-v1.2.2\n",
});

// Act
syncWorkflowVersions(deps);

// Assert
expect(writes["workflows/review/review.md"]).toBe(
" ref: review-v1.5.0\n",
);
});

it("syncs every workflow to its own version", () => {
// Arrange
const {writes, deps} = makeDeps({
"workflows/review/package.json":
'{"name": "review", "version": "1.5.0"}',
"workflows/review/review.md": "ref: review-v1.2.2\n",
"workflows/triage/package.json":
'{"name": "triage", "version": "2.1.0"}',
"workflows/triage/triage.md":
"ref: triage-v2.0.0\nsee review-v1.2.2\n",
});

// Act
syncWorkflowVersions(deps);

// Assert: each workflow gets its own version, and a mention of
// another workflow's tag is not rewritten.
expect(writes["workflows/review/review.md"]).toBe(
"ref: review-v1.5.0\n",
);
expect(writes["workflows/triage/triage.md"]).toBe(
"ref: triage-v2.1.0\nsee review-v1.2.2\n",
);
});

it("never rewrites CHANGELOG.md", () => {
// Arrange: the changelog holds historic versions on purpose.
const {writes, deps} = makeDeps({
"workflows/review/package.json":
'{"name": "review", "version": "1.5.0"}',
"workflows/review/review.md": "ref: review-v1.5.0\n",
"workflows/review/CHANGELOG.md": "## review-v1.2.2 notes\n",
});

// Act
syncWorkflowVersions(deps);

// Assert
expect(writes).toEqual({});
});

it("is a no-op for a workflow with no version literals", () => {
// Arrange
const {writes, deps} = makeDeps({
"workflows/triage/package.json":
'{"name": "triage", "version": "2.0.0"}',
"workflows/triage/triage.md": "no pinned ref here\n",
});

// Act
syncWorkflowVersions(deps);

// Assert
expect(writes).toEqual({});
});

it("does not write when the ref is already current", () => {
// Arrange
const {writes, deps} = makeDeps({
"workflows/review/package.json":
'{"name": "review", "version": "1.5.0"}',
"workflows/review/review.md": " ref: review-v1.5.0\n",
});

// Act
syncWorkflowVersions(deps);

// Assert
expect(writes).toEqual({});
});

it("throws when a workflow's package.json has no version", () => {
// Arrange
const {deps} = makeDeps({
"workflows/review/package.json": '{"name": "review"}',
"workflows/review/review.md": "ref: review-v1.2.2\n",
});

// Act + Assert
expect(() => syncWorkflowVersions(deps)).toThrow(/No version found/);
});
});
122 changes: 122 additions & 0 deletions utils/sync-workflow-versions-lib.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
import * as fs from "fs";

/**
* Keep released-version literals inside each workflow's markdown in sync
* with that workflow's package version.
*
* A workflow under `workflows/<name>/` is released as a `<name>-v<version>`
* git tag named after the directory (see publishWorkflow in
* utils/publish.ts), and its markdown may embed that tag as a literal:
* review.md, for example, checks out Khan/actions at a pinned
* `ref: review-v<version>` tag to fetch the deterministic lib the prompt
* invokes at runtime. Changesets bumps package.json but knows nothing about
* prose in a markdown file, so this script runs alongside `changeset version`
* (see the root package.json "version-packages" script, used by
* .github/workflows/release.yml) to rewrite every `<name>-v<semver>` literal
* in each workflow's markdown to the version being released. That way the
* bump lands in the same Version Packages commit that gets tagged, and each
* tag's markdown pins its own release.
*
* CHANGELOG.md is skipped (changesets writes historic versions there on
* purpose), and a workflow whose markdown embeds no literal is a no-op. A
* workflow that must embed one enforces that with its own contract test;
* workflows/review/version-sync.test.ts is the CI backstop that fails when
* review.md's literals and the `review` package version diverge.
*/

export const WORKFLOWS_DIR = "workflows";

const escapeRegExp = (value: string): string =>
value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");

export const workflowVersionRe = (name: string): RegExp =>
new RegExp(`${escapeRegExp(name)}-v\\d+\\.\\d+\\.\\d+`, "g");

export const syncWorkflowVersionContent = (
content: string,
name: string,
version: string,
): {content: string; replaced: number} => {
let replaced = 0;
const next = content.replace(workflowVersionRe(name), () => {
replaced += 1;
return `${name}-v${version}`;
});
return {content: next, replaced};
};

export type DirEntryLike = {
name: string;
isDirectory: () => boolean;
isFile: () => boolean;
};

export type SyncWorkflowVersionsDeps = {
readFileSyncImpl?: (path: string, encoding: "utf-8") => string;
writeFileSyncImpl?: (path: string, data: string) => void;
readdirSyncImpl?: (path: string) => DirEntryLike[];
log?: (message: string) => void;
};

export const syncWorkflowVersions = (
deps: SyncWorkflowVersionsDeps = {},
): void => {
const readFileSyncImpl =
deps.readFileSyncImpl ??
((path: string, encoding: "utf-8") => fs.readFileSync(path, encoding));
const writeFileSyncImpl = deps.writeFileSyncImpl ?? fs.writeFileSync;
const readdirSyncImpl =
deps.readdirSyncImpl ??
((path: string) => fs.readdirSync(path, {withFileTypes: true}));
const log = deps.log ?? console.log;

const workflowNames = readdirSyncImpl(WORKFLOWS_DIR)
.filter((entry) => entry.isDirectory())
.map((entry) => entry.name);

for (const name of workflowNames) {
// The release tag is named after the directory, not package.json's
// "name" field (see publishWorkflow in utils/publish.ts).
const pkgPath = `${WORKFLOWS_DIR}/${name}/package.json`;
const pkg = JSON.parse(readFileSyncImpl(pkgPath, "utf-8"));
if (!pkg.version) {
throw new Error(`No version found in ${pkgPath}`);
}

const markdownFiles = readdirSyncImpl(`${WORKFLOWS_DIR}/${name}`)
.filter(
(entry) =>
entry.isFile() &&
entry.name.endsWith(".md") &&
// Changesets writes historic versions to the changelog;
// those must stay as released.
entry.name !== "CHANGELOG.md",
)
.map((entry) => entry.name);

for (const fileName of markdownFiles) {
const filePath = `${WORKFLOWS_DIR}/${name}/${fileName}`;
const content = readFileSyncImpl(filePath, "utf-8");
const {content: synced, replaced} = syncWorkflowVersionContent(
content,
name,
pkg.version,
);

if (replaced === 0) {
continue;
}

if (synced === content) {
log(`${filePath}: already at ${name}-v${pkg.version}`);
continue;
}

writeFileSyncImpl(filePath, synced);
log(
`${filePath}: synced ${replaced} ${name}-v literal(s) ` +
`to ${name}-v${pkg.version}`,
);
}
}
};
14 changes: 14 additions & 0 deletions utils/sync-workflow-versions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
/**
* Rewrite each workflow's `<name>-v<semver>` literals (e.g. the pinned
* Khan/actions checkout ref inside workflows/review/review.md) to that
* workflow's current package version.
*
* Runs as part of the changesets version command (root package.json
* "version-packages", wired into .github/workflows/release.yml) so the bump
* lands in the same Version Packages commit that gets tagged.
*
* Usage: node -r @swc-node/register utils/sync-workflow-versions.ts
*/
import {syncWorkflowVersions} from "./sync-workflow-versions-lib.ts";

syncWorkflowVersions();
Loading
Loading